diff --git a/.env.example b/.env.example index 74abc4a..bd25df2 100644 --- a/.env.example +++ b/.env.example @@ -21,10 +21,20 @@ ACCESS_COOKIE_NAME=dhv2_access REFRESH_COOKIE_NAME=dhv2_refresh CSRF_COOKIE_NAME=dhv2_csrf +INSPECTION_SIGNATURE_ROOT=/app/storage/asset-media/inspection-signatures INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions - INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf + +# Enlace público de un solo uso para firma/manifestación de empresa. +# En producción: https://dhv2.korexlabs.com/firma-acta +# En entorno de prueba: https://prueba.dhv2.korexlabs.com/firma-acta +COMPANY_SIGNATURE_PUBLIC_BASE_URL=https://dhv2.korexlabs.com/firma-acta + +# Clave maestra de 32 bytes (64 hex o base64) para cifrar la contraseña SMTP guardada por Superadmin. +SMTP_SETTINGS_MASTER_KEY=CHANGE_ME_WITH_32_RANDOM_BYTES + +# Fallback SMTP. Se usa sólo hasta que Superadmin guarde una configuración en la base de datos. SMTP_HOST= SMTP_PORT=587 SMTP_SECURE=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3f10d2..4f5dca7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,9 @@ jobs: docker run --rm \ -v "$PWD/api-v3/test:/app/test:ro" \ -v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + -v "$PWD/docker-compose.yml:/docker-compose.yml:ro" \ + -v "$PWD/web-v2:/web-v2:ro" \ + -v "$PWD/android-app:/android-app:ro" \ "$image" npm test docker image rm "$image" >/dev/null 2>&1 || true - name: Build production images diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt index 5124554..96a0be7 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt @@ -151,11 +151,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } - fun createActForSelectedInventory() { + fun createActForSelectedInventory(urgency: String = "NON_URGENT") { val currentVisit = visit ?: return val asset = selectedFieldAsset?.asset if (currentVisit.status != "IN_PROGRESS") { - error = "La inspección debe estar en curso para crear un Acta." + error = "La Inspección debe estar en curso para crear un Acta." + return + } + if (urgency !in setOf("URGENT", "NON_URGENT")) { + error = "Elegí si el Acta es urgente o no urgente." return } if (asset == null) { @@ -163,15 +167,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } if (acts.any { it.status == "DRAFT" }) { - error = "Ya existe un Acta en borrador. Cerrala o cancelala antes de crear la siguiente." + error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente." return } launchBusy { - val created = actsRepository.create(currentVisit.id, asset.id, currentVisit.code) + val created = actsRepository.create( + currentVisit.id, + asset.id, + currentVisit.code, + urgency, + ) selectedAct = created actClosure = actsRepository.closure(created.id) loadActsInternal(currentVisit.id, selectDraft = false) - notice = "${created.code} creada. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta." + 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." if (selectedFieldAsset?.capture?.readyForFinding == true) { loadFindingOptionsInternal(currentVisit.id, asset.id, created.id) } @@ -196,7 +206,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val visitId = visit?.id ?: return launchBusy { selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) - notice = "Inventario agregado a la inspección." + notice = "Inventario agregado a la Inspección." inventory = repository.fieldInventory(visitId, null, null).data val draft = selectedDraftAct() if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { @@ -222,7 +232,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ) { val visitId = visit?.id ?: return if (visit?.status != "IN_PROGRESS") { - error = "La inspección debe estar en curso para dar de alta Inventario." + error = "La Inspección debe estar en curso para dar de alta Inventario." return } if (type.familyRequired && familyId == null) { @@ -449,26 +459,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { absenceReason = reason.trim(), ), ) - notice = "Ausencia del responsable registrada." + notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta." } } fun prepareSelectedAct() { val actId = selectedAct?.id ?: return launchBusy { - actClosure = actsRepository.prepare(actId) + actClosure = actsRepository.lock(actId) refreshSelectedActInternal(actId) - notice = "Acta preparada. Su contenido quedó congelado para las firmas." + notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones." } } fun reopenSelectedAct() { - val actId = selectedAct?.id ?: return - launchBusy { - actClosure = actsRepository.reopen(actId) - refreshSelectedActInternal(actId) - notice = "Acta reabierta. Podés corregirla antes de volver a preparar." - } + error = "Un Acta bloqueada es inmutable y no puede volver a borrador." } fun signSelectedActAsInspector( @@ -507,13 +512,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun recordCompanyOutcome(status: String, reason: String) { val actId = selectedAct?.id ?: return + if (status != "REFUSED") { + error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa." + return + } if (reason.trim().length < 10) { error = "Indicá un motivo de al menos 10 caracteres." return } launchBusy { actClosure = actsRepository.companyOutcome(actId, status, reason) - notice = if (status == "ABSENT") "Ausencia de empresa asentada." else "Negativa a firmar asentada." + notice = "Negativa a firmar asentada." } } @@ -521,11 +530,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val currentVisit = visit ?: return val actId = selectedAct?.id ?: return launchBusy { - actClosure = actsRepository.closeAct(actId) + actClosure = actsRepository.seal(actId) refreshSelectedActInternal(actId) loadActsInternal(currentVisit.id, selectDraft = false) clearFindingState() - notice = "${selectedAct?.code ?: "Acta"} cerrada e inmutable. Podés crear otra Acta o finalizar la inspección." + notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección." } } @@ -534,7 +543,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { launchBusy { visit = actsRepository.closeVisit(visitId) loadVisitsInternal() - notice = "Inspección cerrada. Las Actas y documentos quedan disponibles para oficina." + notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina." } } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt index 87bff44..b7e5eec 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt @@ -90,7 +90,6 @@ data class PersonSummary( data class VisitSummary( val id: String, val code: String, - val title: String? = null, val objective: String? = null, val status: String, val scopeAsset: AssetSummary? = null, @@ -153,7 +152,6 @@ data class ChecklistSummary( data class VisitDetail( val id: String, val code: String, - val title: String? = null, val objective: String? = null, val status: String, val operationalArea: AssetSummary? = null, diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt index 22bb6eb..49ca613 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt @@ -37,6 +37,15 @@ data class MobileActSummary( val title: String, val summary: String, val observations: String? = null, + val urgency: String = "NON_URGENT", + val deadlineDays: Int? = null, + val deadlineDayType: String? = null, + val deadlineBasis: String? = null, + val deadlineBaseAt: String? = null, + val deadlineAt: String? = null, + val lockedAt: String? = null, + val lockedSha256: String? = null, + val sealedAt: String? = null, val currentVersion: Int = 0, val closedAt: String? = null, val closureSha256: String? = null, @@ -53,6 +62,15 @@ data class MobileActDetail( val title: String, val summary: String, val observations: String? = null, + val urgency: String = "NON_URGENT", + val deadlineDays: Int? = null, + val deadlineDayType: String? = null, + val deadlineBasis: String? = null, + val deadlineBaseAt: String? = null, + val deadlineAt: String? = null, + val lockedAt: String? = null, + val lockedSha256: String? = null, + val sealedAt: String? = null, val currentVersion: Int = 0, val closedAt: String? = null, val closureSha256: String? = null, @@ -75,6 +93,7 @@ data class MobileActListResponse( data class CreateMobileActRequest( val occurredAt: String, + val urgency: String, val title: String, val summary: String, val observations: String? = null, @@ -113,6 +132,15 @@ data class MobileActClosureHeader( val code: String, val status: String, val visitId: String, + val urgency: String = "NON_URGENT", + val deadlineDays: Int? = null, + val deadlineDayType: String? = null, + val deadlineBasis: String? = null, + val deadlineBaseAt: String? = null, + val deadlineAt: String? = null, + val lockedAt: String? = null, + val lockedSha256: String? = null, + val sealedAt: String? = null, val currentVersion: Int = 0, val closedAt: String? = null, val closureSha256: String? = null, @@ -169,7 +197,7 @@ data class MobileCompanyOutcomeRequest( val reason: String, ) -data class MobileCloseActRequest( +data class MobileSealActRequest( val clientClosedAt: String = Instant.now().toString(), val uploadMode: String = "ONLINE", ) @@ -219,14 +247,8 @@ private interface MobileActsApi { @Body request: MobileResponsibleRequest, ): MobileActClosure - @POST("inspection-acts/{actId}/ready") - suspend fun ready( - @Header("Authorization") authorization: String, - @Path("actId") actId: String, - ): MobileActClosure - - @POST("inspection-acts/{actId}/reopen") - suspend fun reopen( + @POST("inspection-acts/{actId}/lock") + suspend fun lock( @Header("Authorization") authorization: String, @Path("actId") actId: String, ): MobileActClosure @@ -268,11 +290,11 @@ private interface MobileActsApi { @Body request: MobileCompanyOutcomeRequest, ): MobileActClosure - @POST("inspection-acts/{actId}/close") - suspend fun closeAct( + @POST("inspection-acts/{actId}/seal") + suspend fun sealAct( @Header("Authorization") authorization: String, @Path("actId") actId: String, - @Body request: MobileCloseActRequest, + @Body request: MobileSealActRequest, ): MobileActClosure @POST("inspection-visits/{visitId}/close") @@ -305,14 +327,20 @@ class MobileActsRepository(context: Context) { api.act("Bearer ${session.accessToken}", actId) } - suspend fun create(visitId: String, assetId: String, visitCode: String): MobileActDetail = authorized { session -> + suspend fun create( + visitId: String, + assetId: String, + visitCode: String, + urgency: String = "NON_URGENT", + ): MobileActDetail = authorized { session -> api.createAct( "Bearer ${session.accessToken}", visitId, CreateMobileActRequest( occurredAt = Instant.now().toString(), + urgency = urgency, title = "Acta de inspección $visitCode", - summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la visita.", + summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.", assetIds = listOf(assetId), ), ) @@ -335,12 +363,8 @@ class MobileActsRepository(context: Context) { api.responsible("Bearer ${session.accessToken}", actId, request) } - suspend fun prepare(actId: String): MobileActClosure = authorized { session -> - api.ready("Bearer ${session.accessToken}", actId) - } - - suspend fun reopen(actId: String): MobileActClosure = authorized { session -> - api.reopen("Bearer ${session.accessToken}", actId) + suspend fun lock(actId: String): MobileActClosure = authorized { session -> + api.lock("Bearer ${session.accessToken}", actId) } suspend fun signInspector( @@ -378,8 +402,8 @@ class MobileActsRepository(context: Context) { ) } - suspend fun closeAct(actId: String): MobileActClosure = authorized { session -> - api.closeAct("Bearer ${session.accessToken}", actId, MobileCloseActRequest()) + suspend fun seal(actId: String): MobileActClosure = authorized { session -> + api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest()) } suspend fun closeVisit(visitId: String): VisitDetail = authorized { session -> diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt index 6d2a2dc..2f2d3d9 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt @@ -60,6 +60,7 @@ fun MobileActsScreen( val context = LocalContext.current val scope = rememberCoroutineScope() + var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") } var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") } @@ -129,19 +130,20 @@ fun MobileActsScreen( Card(Modifier.fillMaxWidth()) { 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()) { Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.") } model.acts.forEach { act -> val active = selected?.id == act.id + val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente" OutlinedButton( onClick = { model.selectAct(act.id) }, modifier = Modifier.fillMaxWidth(), ) { Text( (if (active) "✓ " else "") + - "${act.code} · ${actStatusLabel(act.status)} · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}", + "${act.code} · ${actStatusLabel(act.status)} · $urgency · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}", ) } } @@ -156,12 +158,31 @@ fun MobileActsScreen( ) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Nueva Acta", fontWeight = FontWeight.Bold) - if (model.acts.any { it.status == "READY" }) { + if (model.acts.any { it.status == "LOCKED" }) { Text( - "Puede existir una nueva Acta en borrador aunque haya Actas preparadas pendientes de firma de empresa. 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, ) } + Text("Urgencia del Acta", fontWeight = FontWeight.Bold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip( + onClick = { newActUrgency = "NON_URGENT" }, + label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") }, + ) + AssistChip( + onClick = { newActUrgency = "URGENT" }, + label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") }, + ) + } + Text( + if (newActUrgency == "URGENT") { + "El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla." + } else { + "El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla." + }, + style = MaterialTheme.typography.bodySmall, + ) val selectedInventory = model.selectedFieldAsset?.asset if (selectedInventory == null) { Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.") @@ -171,7 +192,7 @@ fun MobileActsScreen( } else { Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}") Button( - onClick = { model.createActForSelectedInventory() }, + onClick = { model.createActForSelectedInventory(newActUrgency) }, enabled = !model.busy, modifier = Modifier.fillMaxWidth(), ) { Text("Crear nueva Acta") } @@ -186,9 +207,14 @@ fun MobileActsScreen( 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(if (selected.urgency == "URGENT") "Urgente" else "No urgente") Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario") - Text(selected.summary, style = MaterialTheme.typography.bodySmall) - selected.closureSha256?.let { Text("Hash final: $it", style = MaterialTheme.typography.bodySmall) } + 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) } } } @@ -234,31 +260,28 @@ fun MobileActsScreen( HorizontalDivider() Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita. Un Acta de verificación puede prepararse sin Hallazgos nuevos si la verificación ya fue registrada.") + 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. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("Al preparar, el contenido se congela y se calcula su hash. El servidor exige al menos un Hallazgo o una verificación registrada.") + 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.") Button( onClick = { model.prepareSelectedAct() }, enabled = !model.busy && closure?.responsible != null, modifier = Modifier.fillMaxWidth(), - ) { Text("Preparar Acta para firmas") } + ) { Text("Finalizar y BLOQUEAR Acta") } } - "READY" -> { + "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" - Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) } - if (signatures.isEmpty()) { - OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) { - Text("Volver a borrador") - } - } + 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) } HorizontalDivider() Text("Firma del inspector", fontWeight = FontWeight.Bold) @@ -274,29 +297,21 @@ fun MobileActsScreen( } HorizontalDivider() - Text("Recepción de la empresa", fontWeight = FontWeight.Bold) - if (companyOutcome != null) { + Text("Manifestación de la empresa", fontWeight = FontWeight.Bold) + if (companyOutcome != null && companyResolved) { val detail = when (companyOutcome.status) { - "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada" + "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad" "REFUSED" -> "Negativa a firmar" - "ABSENT" -> "Responsable ausente" 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 responsable fue registrado como ausente.") - Button( - onClick = { - model.recordCompanyOutcome( - "ABSENT", - closure.responsible.absenceReason ?: "Responsable de empresa ausente durante la inspección", - ) - }, - enabled = !model.busy && inspectorSigned, - modifier = Modifier.fillMaxWidth(), - ) { Text("Asentar ausencia en el Acta") } + Text( + "El responsable 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, + ) } else { Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -333,28 +348,28 @@ fun MobileActsScreen( } HorizontalDivider() - if (inspectorSigned && companyOutcome != null) { + if (inspectorSigned && companyResolved) { Button( onClick = { model.closeSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth(), - ) { Text("Cerrar Acta definitivamente") } - } else if (inspectorSigned) { + ) { Text("SELLAR Acta definitivamente") } + } else { Text( - "La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.", + "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, ) } } - "CLOSED" -> { + "SEALED" -> { Card( Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), ) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { - Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold) - Text("El PDF, informe Word y entregas documentales se generan desde este cierre.") + Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold) + Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.") closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) } } } @@ -366,16 +381,19 @@ fun MobileActsScreen( if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) { HorizontalDivider() - val drafts = model.acts.count { it.status == "DRAFT" } - Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text( - "No puede quedar ninguna Acta en borrador. Al cerrar, el servidor verifica que cada Acta preparada tenga firma de inspector; si falta alguna, indicará cuál debe completarse.", - ) + val activeActs = model.acts.filter { it.status != "CANCELLED" } + val pendingActs = activeActs.filter { it.status != "SEALED" } + Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + if (pendingActs.isEmpty()) { + Text("Todas las Actas están SELLADAS. La Inspección ya puede cerrarse.") + } else { + Text("No puede cerrarse todavía. Falta SELLAR: ${pendingActs.joinToString { it.code }}") + } Button( onClick = { model.closeInspection() }, - enabled = !model.busy && drafts == 0, + enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(), modifier = Modifier.fillMaxWidth(), - ) { Text("Cerrar inspección y salir de la empresa") } + ) { Text("Cerrar Inspección y salir de la empresa") } } } } @@ -397,8 +415,8 @@ private fun F32ActMessage(model: MainViewModel) { private fun actStatusLabel(status: String): String = when (status) { "DRAFT" -> "Borrador" - "READY" -> "Preparada para firmas" - "CLOSED" -> "Cerrada" + "LOCKED" -> "BLOQUEADA" + "SEALED" -> "SELLADA" "CANCELLED" -> "Cancelada" else -> status } diff --git a/api-v3/src/act-administration/act-administration.service.ts b/api-v3/src/act-administration/act-administration.service.ts index c582b48..38f4e2d 100644 --- a/api-v3/src/act-administration/act-administration.service.ts +++ b/api-v3/src/act-administration/act-administration.service.ts @@ -49,7 +49,7 @@ export class ActAdministrationService { COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_count FROM inspection_findings f WHERE f.act_id = ia.id ) fc ON true - WHERE ia.status IN ('CLOSED', 'RECTIFIED') + WHERE ia.status IN ('SEALED', 'CLOSED', 'RECTIFIED') ), classified AS ( SELECT *, CASE WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED' @@ -110,7 +110,7 @@ export class ActAdministrationService { const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]); const act = rows[0]; if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' }); - if (!['CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_CLOSED', message: 'El seguimiento administrativo comienza cuando el Acta está cerrada.' }); + if (!['SEALED', 'CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_SEALED', message: 'El seguimiento administrativo comienza cuando el Acta está sellada.' }); return act; } diff --git a/api-v3/src/act-administration/field-briefing.service.ts b/api-v3/src/act-administration/field-briefing.service.ts index c718ca8..5b7f917 100644 --- a/api-v3/src/act-administration/field-briefing.service.ts +++ b/api-v3/src/act-administration/field-briefing.service.ts @@ -129,7 +129,7 @@ export class FieldBriefingService { ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC LIMIT 1 ) response ON true - WHERE act.status IN ('CLOSED', 'RECTIFIED') + WHERE act.status IN ('SEALED', 'CLOSED', 'RECTIFIED') AND source_visit.id <> $1 AND source_visit.operational_area_id = $2::uuid AND source_visit.operator_company_id = $3::uuid diff --git a/api-v3/src/app.module.ts b/api-v3/src/app.module.ts index ed63e7f..6d0eacb 100644 --- a/api-v3/src/app.module.ts +++ b/api-v3/src/app.module.ts @@ -15,8 +15,6 @@ import { HealthController } from './health.controller'; import { HealthService } from './health.service'; import { DashboardModule } from './dashboard/dashboard.module'; import { AssetMasterModule } from './asset-master/asset-master.module'; -import { SurveyPlanningModule } from './survey-planning/survey-planning.module'; -import { SurveyExecutionModule } from './survey-execution/survey-execution.module'; import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module'; import { InspectionActsModule } from './inspection-acts/inspection-acts.module'; import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module'; @@ -69,8 +67,6 @@ function required(config: ConfigService, key: string): string { AdministrationModule, DashboardModule, AssetMasterModule, - SurveyPlanningModule, - SurveyExecutionModule, InspectionVisitsModule, InspectionActsModule, InspectionFindingsModule, diff --git a/api-v3/src/asset-master/asset-history.service.ts b/api-v3/src/asset-master/asset-history.service.ts index 5dc216d..ddd9f73 100644 --- a/api-v3/src/asset-master/asset-history.service.ts +++ b/api-v3/src/asset-master/asset-history.service.ts @@ -41,14 +41,14 @@ export interface AssetVersionDetail extends AssetVersionSummary { function assetNotFound(): NotFoundException { return new NotFoundException({ code: 'ASSET_NOT_FOUND', - message: 'Activo no encontrado', + message: 'Inventario no encontrado', }); } function versionNotFound(): NotFoundException { return new NotFoundException({ code: 'ASSET_VERSION_NOT_FOUND', - message: 'Versión de activo no encontrada', + message: 'Versión de Inventario no encontrada', }); } @@ -79,7 +79,7 @@ export class AssetHistoryService { const versionNumber = Number(versionRow.current_version); if (!Number.isInteger(versionNumber) || versionNumber < 1) { - throw new Error(`Versión de activo inválida después de incrementar: ${versionRow.current_version}`); + throw new Error(`Versión de Inventario inválida después de incrementar: ${versionRow.current_version}`); } const snapshot = await this.loadCurrentSnapshot(manager, assetId); @@ -101,17 +101,8 @@ export class AssetHistoryService { asset_id, version_number, change_type, changed_fields, snapshot, actor_user_id, actor_username, source, request_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - [ - assetId, - versionNumber, - changeType, - changedFields, - snapshot, - principal.userId, - principal.username, - source, - request.requestId, - ], + [assetId, versionNumber, changeType, changedFields, snapshot, + principal.userId, principal.username, source, request.requestId], ); return versionNumber; } @@ -123,38 +114,21 @@ export class AssetHistoryService { parameters.push(value); return `$${parameters.length}`; }; - if (query.search?.trim()) { const search = add(`%${query.search.trim()}%`); - conditions.push(`( - version.snapshot->>'code' ILIKE ${search} - OR version.snapshot->>'name' ILIKE ${search} - OR version.actor_username ILIKE ${search} - )`); - } - if (query.typeId) { - conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`); - } - if (query.status) { - conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`); - } - if (query.changeType) { - conditions.push(`version.change_type = ${add(query.changeType)}`); + conditions.push(`(version.snapshot->>'code' ILIKE ${search} OR version.snapshot->>'name' ILIKE ${search} OR version.actor_username ILIKE ${search})`); } + if (query.typeId) conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`); + if (query.status) conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`); + if (query.changeType) conditions.push(`version.change_type = ${add(query.changeType)}`); if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`); if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`); - return this.listWithConditions(query.page, query.pageSize, conditions, parameters); } async listForAsset(assetId: string, query: AssetVersionPageQueryDto) { await this.requireAsset(assetId); - return this.listWithConditions( - query.page, - query.pageSize, - ['version.asset_id = $1'], - [assetId], - ); + return this.listWithConditions(query.page, query.pageSize, ['version.asset_id = $1'], [assetId]); } async getVersion(assetId: string, versionNumber: number): Promise { @@ -170,17 +144,9 @@ export class AssetHistoryService { return row; } - private async listWithConditions( - page: number, - pageSize: number, - conditions: string[], - parameters: unknown[], - ) { + private async listWithConditions(page: number, pageSize: number, conditions: string[], parameters: unknown[]) { const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; - const [countRow] = (await this.dataSource.query( - `SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`, - parameters, - )) as Array<{ total: number }>; + const [countRow] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`, parameters)) as Array<{ total: number }>; const total = Number(countRow?.total ?? 0); const paginated = [...parameters, pageSize, (page - 1) * pageSize]; const limit = `$${parameters.length + 1}`; @@ -194,51 +160,29 @@ export class AssetHistoryService { LIMIT ${limit} OFFSET ${offset}`, paginated, )) as AssetVersionSummary[]; - - return { - data, - meta: { - page, - pageSize, - total, - totalPages: total === 0 ? 0 : Math.ceil(total / pageSize), - }, - }; + return { data, meta: { page, pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) } }; } private selectSummary(): string { return `SELECT - version.id, - version.asset_id AS "assetId", - version.snapshot->>'code' AS "assetCode", - version.snapshot->>'name' AS "assetName", - version.snapshot #>> '{type,id}' AS "typeId", - version.snapshot #>> '{type,name}' AS "typeName", + version.id, version.asset_id AS "assetId", + version.snapshot->>'code' AS "assetCode", version.snapshot->>'name' AS "assetName", + version.snapshot #>> '{type,id}' AS "typeId", version.snapshot #>> '{type,name}' AS "typeName", version.snapshot->>'informationStatus' AS "informationStatus", version.snapshot->>'operationalStatus' AS "operationalStatus", - version.version_number AS "versionNumber", - version.change_type AS "changeType", - version.changed_fields AS "changedFields", - version.occurred_at AS "occurredAt", - version.actor_user_id AS "actorUserId", - version.actor_username AS "actorUsername", - version.source, - version.request_id AS "requestId", + version.version_number AS "versionNumber", version.change_type AS "changeType", + version.changed_fields AS "changedFields", version.occurred_at AS "occurredAt", + version.actor_user_id AS "actorUserId", version.actor_username AS "actorUsername", + version.source, version.request_id AS "requestId", (version.version_number = current_asset.current_version) AS "isCurrent"`; } private async requireAsset(assetId: string): Promise { - const [row] = (await this.dataSource.query( - 'SELECT 1 FROM assets WHERE id = $1', - [assetId], - )) as unknown[]; + const [row] = (await this.dataSource.query('SELECT 1 FROM assets WHERE id = $1', [assetId])) as unknown[]; if (!row) throw assetNotFound(); } - private async loadCurrentSnapshot( - manager: EntityManager, - assetId: string, - ): Promise> { + private async loadCurrentSnapshot(manager: EntityManager, assetId: string): Promise> { const [row] = (await manager.query( `SELECT JSONB_BUILD_OBJECT( 'id', asset.id, @@ -246,82 +190,49 @@ export class AssetHistoryService { 'name', asset.name, 'commonName', asset.common_name, 'description', asset.description, - 'type', JSONB_BUILD_OBJECT( - 'id', asset_type.id, - 'code', asset_type.code, - 'name', asset_type.name, - 'operationalRole', asset_type.operational_role - ), - 'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', parent.id, - 'code', parent.code, - 'name', parent.name - ) END, - 'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', operational_area.id, - 'code', operational_area.code, - 'name', operational_area.name - ) END, - 'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', operator_company.id, - 'code', operator_company.code, - 'name', operator_company.name - ) END, + 'type', JSONB_BUILD_OBJECT('id', asset_type.id,'code', asset_type.code,'name', asset_type.name,'operationalRole', asset_type.operational_role), + 'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', parent.id,'code', parent.code,'name', parent.name) END, + 'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operational_area.id,'code', operational_area.code,'name', operational_area.name) END, + 'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operator_company.id,'code', operator_company.code,'name', operator_company.name) END, 'informationStatus', asset.information_status, 'operationalStatus', asset.operational_status, + 'currentFunction', ( + SELECT JSONB_BUILD_OBJECT( + 'assignmentId', assignment.id, + 'id', fn.id, + 'code', fn.code, + 'name', fn.name, + 'validFrom', assignment.valid_from, + 'reason', assignment.change_reason + ) + FROM inventory_function_assignments assignment + JOIN inventory_functions fn ON fn.id=assignment.function_id + WHERE assignment.asset_id=asset.id AND assignment.valid_until IS NULL + ORDER BY assignment.valid_from DESC LIMIT 1 + ), 'attributes', COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'definitionId', definition.id, - 'code', definition.code, - 'name', definition.name, - 'dataType', definition.data_type, - 'isRequired', definition.is_required, - 'unit', definition.unit, - 'options', definition.options, - 'sortOrder', definition.sort_order, - 'value', value.value + 'definitionId', definition.id,'code', definition.code,'name', definition.name, + 'dataType', definition.data_type,'isRequired', definition.is_required, + 'unit', definition.unit,'options', definition.options,'sortOrder', definition.sort_order,'value', value.value ) ORDER BY definition.sort_order, definition.name) FROM asset_attribute_definitions definition - LEFT JOIN asset_attribute_values value - ON value.definition_id = definition.id - AND value.asset_id = asset.id - WHERE definition.asset_type_id = asset.asset_type_id - AND definition.is_active = true + LEFT JOIN asset_attribute_values value ON value.definition_id = definition.id AND value.asset_id = asset.id + WHERE definition.asset_type_id = asset.asset_type_id AND definition.is_active = true ), '[]'::jsonb), 'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'assetId', geometry.asset_id, - 'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb, - 'geometryType', geometry.geometry_type, - 'source', geometry.source, - 'accuracyM', geometry.accuracy_m::double precision, - 'capturedAt', geometry.captured_at, - 'deviceLabel', geometry.device_label, - 'createdAt', geometry.created_at, - 'updatedAt', geometry.updated_at, - 'updatedBy', geometry.updated_by + 'assetId', geometry.asset_id,'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,'geometryType', geometry.geometry_type, + 'source', geometry.source,'accuracyM', geometry.accuracy_m::double precision,'capturedAt', geometry.captured_at, + 'deviceLabel', geometry.device_label,'createdAt', geometry.created_at,'updatedAt', geometry.updated_at,'updatedBy', geometry.updated_by ) END, 'media', COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'id', media.id, - 'kind', media.kind, - 'originalName', media.original_name, - 'mimeType', media.mime_type, - 'sizeBytes', media.size_bytes, - 'sha256', media.sha256, - 'title', media.title, - 'description', media.description, - 'capturedAt', media.captured_at, - 'latitude', media.latitude, - 'longitude', media.longitude, - 'accuracyM', media.accuracy_m, - 'source', media.source, - 'uploadedBy', media.uploaded_by, - 'createdAt', media.created_at, - 'updatedAt', media.updated_at + 'id', media.id,'kind', media.kind,'originalName', media.original_name,'mimeType', media.mime_type, + 'sizeBytes', media.size_bytes,'sha256', media.sha256,'title', media.title,'description', media.description, + 'capturedAt', media.captured_at,'latitude', media.latitude,'longitude', media.longitude,'accuracyM', media.accuracy_m, + 'source', media.source,'uploadedBy', media.uploaded_by,'createdAt', media.created_at,'updatedAt', media.updated_at ) ORDER BY media.created_at, media.id) - FROM asset_media media - WHERE media.asset_id = asset.id - AND media.deleted_at IS NULL + FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL ), '[]'::jsonb), 'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id), 'organizationMemberships', COALESCE(( @@ -337,22 +248,8 @@ export class AssetHistoryService { 'legalRights', COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id ),'[]'::jsonb), - 'provenance', JSONB_BUILD_OBJECT( - 'origin', asset.data_origin, - 'sourceName', asset.source_name, - 'sourceReference', asset.source_reference, - 'observedAt', asset.source_observed_at, - 'notes', asset.source_notes, - 'verifiedAt', asset.provenance_verified_at, - 'verifiedBy', asset.provenance_verified_by, - 'updatedAt', asset.provenance_updated_at, - 'updatedBy', asset.provenance_updated_by - ), - 'createdAt', asset.created_at, - 'updatedAt', asset.updated_at, - 'createdBy', asset.created_by, - 'updatedBy', asset.updated_by, - 'currentVersion', asset.current_version + 'provenance', JSONB_BUILD_OBJECT('origin', asset.data_origin,'sourceName', asset.source_name,'sourceReference', asset.source_reference,'observedAt', asset.source_observed_at,'notes', asset.source_notes,'verifiedAt', asset.provenance_verified_at,'verifiedBy', asset.provenance_verified_by,'updatedAt', asset.provenance_updated_at,'updatedBy', asset.provenance_updated_by), + 'createdAt', asset.created_at,'updatedAt', asset.updated_at,'createdBy', asset.created_by,'updatedBy', asset.updated_by,'currentVersion', asset.current_version ) AS snapshot FROM assets asset INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id diff --git a/api-v3/src/asset-master/asset-master.module.ts b/api-v3/src/asset-master/asset-master.module.ts index b649323..f4484e3 100644 --- a/api-v3/src/asset-master/asset-master.module.ts +++ b/api-v3/src/asset-master/asset-master.module.ts @@ -29,6 +29,8 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller'; import { InventoryMergeService } from './inventory-merge.service'; import { MergedInventoryDossierService } from './merged-inventory-dossier.service'; +import { InventoryFunctionController } from './inventory-function.controller'; +import { InventoryFunctionService } from './inventory-function.service'; @Module({ imports: [AuditModule], @@ -37,6 +39,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic AssetsController, InventoryStructureController, InventoryFamilyCatalogController, + InventoryFunctionController, InventoryMergeController, FieldInventoryMergeController, AssetGeometriesController, @@ -53,6 +56,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic AssetsService, InventoryStructureService, InventoryFamilyCatalogService, + InventoryFunctionService, InventoryMergeService, MergedInventoryDossierService, AssetGeometriesService, @@ -67,6 +71,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic exports: [ AssetHistoryService, AssetsService, + InventoryFunctionService, InventoryMergeService, MergedInventoryDossierService, AssetGeometriesService, diff --git a/api-v3/src/asset-master/assets.service.ts b/api-v3/src/asset-master/assets.service.ts index 53a6481..f2fb7a4 100644 --- a/api-v3/src/asset-master/assets.service.ts +++ b/api-v3/src/asset-master/assets.service.ts @@ -298,7 +298,7 @@ export class AssetsService { const [visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions] = await Promise.all([ this.dataSource.query( - `SELECT DISTINCT visit.id, visit.code, visit.title, visit.status, + `SELECT DISTINCT visit.id, visit.code, visit.status, visit.planned_start_at AS "plannedStartAt", visit.actual_started_at AS "actualStartedAt", visit.actual_closed_at AS "actualClosedAt", @@ -322,7 +322,7 @@ export class AssetsService { `SELECT DISTINCT act.id, act.visit_id AS "visitId", act.code, act.status, act.occurred_at AS "occurredAt", act.title, act.summary, act.closed_at AS "closedAt", act.current_version AS "currentVersion", - visit.code AS "visitCode", visit.title AS "visitTitle" + visit.code AS "visitCode" FROM inspection_acts act JOIN inspection_visits visit ON visit.id = act.visit_id LEFT JOIN inspection_act_assets act_asset @@ -342,7 +342,7 @@ export class AssetsService { finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes", finding.created_at AS "createdAt", finding.updated_at AS "updatedAt", act.code AS "actCode", act.occurred_at AS "actOccurredAt", - visit.id AS "visitId", visit.code AS "visitCode", visit.title AS "visitTitle" + visit.id AS "visitId", visit.code AS "visitCode" FROM inspection_findings finding JOIN inspection_acts act ON act.id = finding.act_id JOIN inspection_visits visit ON visit.id = act.visit_id @@ -389,7 +389,7 @@ export class AssetsService { verification_link.result_recorded_at AS "resultRecordedAt", verification_link.rescheduled_control_on AS "rescheduledControlOn", finding.code AS "findingCode", finding.title AS "findingTitle", - visit.code AS "visitCode", visit.title AS "visitTitle", visit.status AS "visitStatus", + visit.code AS "visitCode", visit.status AS "visitStatus", (SELECT COUNT(*)::integer FROM inspection_finding_evidence verification_evidence WHERE verification_evidence.finding_id = finding.id @@ -484,7 +484,7 @@ export class AssetsService { kind: 'INSPECTION', occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt, title: `Inspección ${String(visit.code)}`, - description: visit.title, + description: null, href: `/inspecciones/${String(visit.id)}`, meta: { status: visit.status }, })); @@ -867,7 +867,7 @@ export class AssetsService { private fieldDiscoverySelect(): string { return `SELECT discovery.id, discovery.status, discovery.discovery_notes AS "discoveryNotes", discovery.observed_at AS "observedAt", discovery.reviewed_at AS "reviewedAt", discovery.review_notes AS "reviewNotes", discovery.created_at AS "createdAt", JSONB_BUILD_OBJECT('id',asset.id,'code',asset.code,'name',asset.name,'commonName',asset.common_name,'informationStatus',asset.information_status,'typeId',asset.asset_type_id,'typeName',asset_type.name,'parentId',asset.parent_id,'operationalAreaId',asset.operational_area_id,'operatorCompanyId',asset.operator_company_id) AS asset, - JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'title',visit.title,'status',visit.status) AS visit, + JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'status',visit.status) AS visit, JSONB_BUILD_OBJECT('id',creator.id,'username',creator.username,'firstName',creator.first_name,'lastName',creator.last_name) AS creator, CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',reviewer.id,'username',reviewer.username,'firstName',reviewer.first_name,'lastName',reviewer.last_name) END AS reviewer, CASE WHEN matched.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',matched.id,'code',matched.code,'name',matched.name,'commonName',matched.common_name) END AS "matchedAsset" diff --git a/api-v3/src/asset-master/dto/inventory-function.dto.ts b/api-v3/src/asset-master/dto/inventory-function.dto.ts new file mode 100644 index 0000000..b47e34a --- /dev/null +++ b/api-v3/src/asset-master/dto/inventory-function.dto.ts @@ -0,0 +1,79 @@ +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsISO8601, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export class CreateInventoryFunctionDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') : value) + @IsString() + @MinLength(2) + @MaxLength(120) + code!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(240) + name!: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + description?: string | null; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100000) + sortOrder?: number; +} + +export class UpdateInventoryFunctionDto { + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(240) + name?: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + description?: string | null; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100000) + sortOrder?: number; +} + +export class ChangeInventoryFunctionDto { + @IsUUID('4') + functionId!: string; + + @IsOptional() + @IsISO8601({ strict: true }) + effectiveAt?: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + reason?: string | null; +} diff --git a/api-v3/src/asset-master/inventory-function.controller.ts b/api-v3/src/asset-master/inventory-function.controller.ts new file mode 100644 index 0000000..618d5c4 --- /dev/null +++ b/api-v3/src/asset-master/inventory-function.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + ChangeInventoryFunctionDto, + CreateInventoryFunctionDto, + UpdateInventoryFunctionDto, +} from './dto/inventory-function.dto'; +import { InventoryFunctionService } from './inventory-function.service'; + +@Controller() +export class InventoryFunctionController { + constructor(private readonly functions: InventoryFunctionService) {} + + @Get('inventory-functions') + @RequirePermissions('assets.read') + list(@Query('includeInactive') includeInactive?: string) { + return this.functions.list(includeInactive === 'true'); + } + + @Post('inventory-functions') + @RequirePermissions('asset_types.manage') + create( + @Body() dto: CreateInventoryFunctionDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.functions.create(dto, principal, request); + } + + @Patch('inventory-functions/:id') + @RequirePermissions('asset_types.manage') + update( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateInventoryFunctionDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.functions.update(id, dto, principal, request); + } + + @Get('assets/:id/function-history') + @RequirePermissions('assets.read_history') + history(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.functions.getForAsset(id); + } + + @Post('assets/:id/function') + @RequirePermissions('assets.update') + change( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ChangeInventoryFunctionDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.functions.changeForAsset(id, dto, principal, request); + } +} diff --git a/api-v3/src/asset-master/inventory-function.service.ts b/api-v3/src/asset-master/inventory-function.service.ts new file mode 100644 index 0000000..f0a1e39 --- /dev/null +++ b/api-v3/src/asset-master/inventory-function.service.ts @@ -0,0 +1,186 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AssetVersionChangeType, AuditAction } from '../database/entities'; +import type { + ChangeInventoryFunctionDto, + CreateInventoryFunctionDto, + UpdateInventoryFunctionDto, +} from './dto/inventory-function.dto'; +import { AssetHistoryService } from './asset-history.service'; + +export interface InventoryFunctionRow { + id: string; code: string; name: string; description: string | null; + isActive: boolean; sortOrder: number; createdAt: Date; updatedAt: Date; +} + +export interface FunctionAssignmentRow { + id: string; assetId: string; functionId: string; functionCode: string; functionName: string; + validFrom: Date; validUntil: Date | null; reason: string | null; + changedBy: string | null; changedByUsername: string | null; createdAt: Date; +} + +interface FunctionEligibleAsset { + id: string; code: string; name: string; typeCode: string; typeName: string; + familyCode: string | null; familyName: string | null; +} + +function normalized(value: string | null): string { + return (value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); +} + +@Injectable() +export class InventoryFunctionService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly history: AssetHistoryService, + ) {} + + async list(includeInactive = false): Promise<{ data: InventoryFunctionRow[] }> { + const data = await this.dataSource.query(` + SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder", + created_at AS "createdAt",updated_at AS "updatedAt" + FROM inventory_functions + ${includeInactive ? '' : 'WHERE is_active=true'} + ORDER BY sort_order,name,code + `) as InventoryFunctionRow[]; + return { data }; + } + + async create(dto: CreateInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext): Promise { + return this.dataSource.transaction(async (manager) => { + const [duplicate] = await manager.query('SELECT 1 FROM inventory_functions WHERE lower(code)=lower($1) LIMIT 1', [dto.code]) as unknown[]; + if (duplicate) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CODE_EXISTS', message: 'Ya existe una función con ese código' }); + const [created] = await manager.query(` + INSERT INTO inventory_functions(code,name,description,is_active,sort_order,created_by,updated_by) + VALUES($1,$2,$3,true,$4,$5,$5) + RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder", + created_at AS "createdAt",updated_at AS "updatedAt" + `, [dto.code, dto.name, dto.description ?? null, dto.sortOrder ?? 0, principal.userId]) as InventoryFunctionRow[]; + await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INVENTORY_FUNCTION_CREATED, + entityType: 'inventory_function', entityId: created.id, afterData: created as unknown as Record }, manager); + return created; + }); + } + + async update(id: string, dto: UpdateInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext): Promise { + if (Object.keys(dto).length === 0) throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + return this.dataSource.transaction(async (manager) => { + const before = await this.requireFunction(manager, id, false); + const [updated] = await manager.query(` + UPDATE inventory_functions SET + name=COALESCE($2,name), description=CASE WHEN $3::boolean THEN $4 ELSE description END, + is_active=COALESCE($5,is_active), sort_order=COALESCE($6,sort_order), + updated_by=$7,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder", + created_at AS "createdAt",updated_at AS "updatedAt" + `, [id, dto.name ?? null, dto.description !== undefined, dto.description ?? null, dto.isActive ?? null, dto.sortOrder ?? null, principal.userId]) as InventoryFunctionRow[]; + await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INVENTORY_FUNCTION_UPDATED, + entityType: 'inventory_function', entityId: id, beforeData: before as unknown as Record, + afterData: updated as unknown as Record }, manager); + return updated; + }); + } + + async getForAsset(assetId: string) { + return this.dataSource.transaction((manager) => this.getForAssetWithManager(manager, assetId)); + } + + async changeForAsset(assetId: string, dto: ChangeInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext) { + return this.dataSource.transaction(async (manager) => { + const asset = await this.requireEligibleAsset(manager, assetId, true); + const nextFunction = await this.requireFunction(manager, dto.functionId, true); + const effectiveAt = dto.effectiveAt ? new Date(dto.effectiveAt) : new Date(); + if (!Number.isFinite(effectiveAt.getTime())) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_INVALID', message: 'La fecha efectiva del cambio de función no es válida' }); + if (effectiveAt.getTime() > Date.now() + 60_000) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_FUTURE_DATE_NOT_ALLOWED', message: 'El cambio de función no puede registrarse con fecha futura' }); + + const current = await this.currentAssignment(manager, assetId, true); + if (current?.functionId === nextFunction.id) return this.getForAssetWithManager(manager, assetId); + if (current && effectiveAt.getTime() <= new Date(current.validFrom).getTime()) { + throw new ConflictException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_OVERLAP', message: 'La fecha efectiva debe ser posterior al inicio de la función vigente' }); + } + if (current) await manager.query(`UPDATE inventory_function_assignments SET valid_until=$2 WHERE id=$1 AND valid_until IS NULL`, [current.id, effectiveAt]); + await manager.query(`INSERT INTO inventory_function_assignments(asset_id,function_id,valid_from,change_reason,changed_by) VALUES($1,$2,$3,$4,$5)`, + [assetId, nextFunction.id, effectiveAt, dto.reason ?? null, principal.userId]); + await manager.query(`UPDATE assets SET updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [assetId, principal.userId]); + + const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.FUNCTION_CHANGED, principal, request); + const after = await this.getForAssetWithManager(manager, assetId); + await this.audit.record({ + ...administrationAuditContext(principal, request), action: AuditAction.ASSET_FUNCTION_CHANGED, + entityType: 'asset', entityId: assetId, + beforeData: current ? { functionId: current.functionId, functionCode: current.functionCode, functionName: current.functionName, validFrom: current.validFrom } : { functionId: null }, + afterData: { functionId: nextFunction.id, functionCode: nextFunction.code, functionName: nextFunction.name, effectiveAt, reason: dto.reason ?? null, versionNumber }, + metadata: { inventoryCode: asset.code, inventoryName: asset.name, temporal: true, historySource: 'inventory_function_assignments', assetVersionChangeType: AssetVersionChangeType.FUNCTION_CHANGED }, + }, manager); + return after; + }); + } + + private async getForAssetWithManager(manager: EntityManager, assetId: string) { + const asset = await this.requireEligibleAsset(manager, assetId, false); + const currentFunction = await this.currentAssignment(manager, assetId, false); + const history = await manager.query(` + SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId", + fn.code AS "functionCode",fn.name AS "functionName", + assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil", + assignment.change_reason AS reason,assignment.changed_by AS "changedBy", + actor.username AS "changedByUsername",assignment.created_at AS "createdAt" + FROM inventory_function_assignments assignment + JOIN inventory_functions fn ON fn.id=assignment.function_id + LEFT JOIN users actor ON actor.id=assignment.changed_by + WHERE assignment.asset_id=$1 + ORDER BY assignment.valid_from DESC,assignment.created_at DESC + `, [assetId]) as FunctionAssignmentRow[]; + return { asset: { id: asset.id, code: asset.code, name: asset.name, typeCode: asset.typeCode, typeName: asset.typeName, familyCode: asset.familyCode, familyName: asset.familyName }, currentFunction, history }; + } + + private async currentAssignment(manager: EntityManager, assetId: string, lock: boolean): Promise { + const [row] = await manager.query(` + SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId", + fn.code AS "functionCode",fn.name AS "functionName", + assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil", + assignment.change_reason AS reason,assignment.changed_by AS "changedBy", + actor.username AS "changedByUsername",assignment.created_at AS "createdAt" + FROM inventory_function_assignments assignment + JOIN inventory_functions fn ON fn.id=assignment.function_id + LEFT JOIN users actor ON actor.id=assignment.changed_by + WHERE assignment.asset_id=$1 AND assignment.valid_until IS NULL + ${lock ? 'FOR UPDATE OF assignment' : ''} + `, [assetId]) as FunctionAssignmentRow[]; + return row ?? null; + } + + private async requireFunction(manager: EntityManager, id: string, active: boolean): Promise { + const [row] = await manager.query(` + SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",created_at AS "createdAt",updated_at AS "updatedAt" + FROM inventory_functions WHERE id=$1 ${active ? 'AND is_active=true' : ''} + `, [id]) as InventoryFunctionRow[]; + if (!row) throw new NotFoundException({ code: 'INVENTORY_FUNCTION_NOT_FOUND', message: active ? 'La función seleccionada no existe o está inactiva' : 'Función no encontrada' }); + return row; + } + + private async requireEligibleAsset(manager: EntityManager, assetId: string, lock: boolean): Promise { + const [asset] = await manager.query(` + SELECT asset.id,asset.code,asset.name,asset_type.code AS "typeCode",asset_type.name AS "typeName", + family.code AS "familyCode",family.name AS "familyName" + FROM assets asset JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id + LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id + WHERE asset.id=$1 ${lock ? 'FOR UPDATE OF asset' : ''} + `, [assetId]) as FunctionEligibleAsset[]; + if (!asset) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Inventario no encontrado' }); + const values = [asset.typeCode, asset.typeName, asset.familyCode, asset.familyName].map(normalized); + const eligible = values.some((value) => value === 'estacion' || value === 'subestacion' || value.includes('estacion ') || value.includes('subestacion ') || value.endsWith(' estacion') || value.endsWith(' subestacion')); + if (!eligible) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CHANGE_NOT_ALLOWED', message: 'El cambio de función sólo está habilitado para Inventarios de Estación o Subestación' }); + return asset; + } +} diff --git a/api-v3/src/asset-master/merged-inventory-dossier.service.ts b/api-v3/src/asset-master/merged-inventory-dossier.service.ts index 7a09038..7309767 100644 --- a/api-v3/src/asset-master/merged-inventory-dossier.service.ts +++ b/api-v3/src/asset-master/merged-inventory-dossier.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { AssetsService } from './assets.service'; +import { InventoryFunctionService } from './inventory-function.service'; import { InventoryMergeService } from './inventory-merge.service'; type LooseRecord = Record; @@ -29,6 +30,7 @@ export class MergedInventoryDossierService { constructor( private readonly assets: AssetsService, private readonly merges: InventoryMergeService, + private readonly functions: InventoryFunctionService, ) {} async dossier(requestedAssetId: string): Promise> { @@ -44,7 +46,8 @@ export class MergedInventoryDossierService { const dossiers = await Promise.all(inventoryIds.map(async (assetId) => { const dossier = await this.assets.dossier(assetId) as LooseRecord; const identity = dossier.asset as LooseRecord; - return { assetId, identity, dossier }; + const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null; + return { assetId, identity, dossier, functionDossier }; })); const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({ @@ -75,6 +78,15 @@ export class MergedInventoryDossierService { const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']); const versions = sortDesc(collect('versions'), ['occurredAt']); + const functionHistory = sortDesc( + dossiers.flatMap(({ identity, functionDossier }) => + (((functionDossier?.history ?? []) as LooseRecord[]).map((entry) => enrich(entry, identity))), + ), + ['validFrom', 'createdAt'], + ); + const canonicalFunctionDossier = dossiers.find((item) => item.assetId === canonical.id)?.functionDossier ?? null; + const currentFunction = canonicalFunctionDossier?.currentFunction ?? null; + const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) => ((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({ ...event, @@ -91,6 +103,25 @@ export class MergedInventoryDossierService { ); const timeline: LooseRecord[] = dedupeById(timelineSource); + for (const assignment of functionHistory) { + timeline.push({ + id: `function:${String(assignment.id)}`, + kind: 'FUNCTION_CHANGED', + occurredAt: assignment.validFrom, + title: `Cambio de función · ${String(assignment.functionName)}`, + description: assignment.reason ?? null, + meta: { + functionId: assignment.functionId, + functionCode: assignment.functionCode, + functionName: assignment.functionName, + validFrom: assignment.validFrom, + validUntil: assignment.validUntil, + changedByUsername: assignment.changedByUsername, + historicalInventory: assignment.historicalInventory, + }, + }); + } + for (const alias of aliases) { timeline.push({ id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`, @@ -121,6 +152,7 @@ export class MergedInventoryDossierService { code: canonical.code, name: canonical.name, commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null, + currentFunction, }, requestedAsset: { id: requested.id, @@ -146,7 +178,10 @@ export class MergedInventoryDossierService { documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length, photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length, reports: reports.length + inspectionReports.length, + functionChanges: functionHistory.length, }, + currentFunction, + functionHistory, visits, acts, findings, diff --git a/api-v3/src/dashboard/dashboard.service.ts b/api-v3/src/dashboard/dashboard.service.ts index 4db8e0a..56e5daa 100644 --- a/api-v3/src/dashboard/dashboard.service.ts +++ b/api-v3/src/dashboard/dashboard.service.ts @@ -19,12 +19,12 @@ interface DashboardCountsRow { inactiveUsers: number | string; activeSessions: number | string; openFindings: number | string; - awaitingCompanyResponse: number | string; - overdueCompanyResponses: number | string; - companyResponsesDueNext7Days: number | string; - awaitingVerificationSchedule: number | string; + findingsWithoutControlDate: number | string; overdueControls: number | string; controlsNext30Days: number | string; + reportsWorking: number | string; + reportsOfficialized: number | string; + sealedActsWithoutReport: number | string; } export interface DashboardUpcomingControl { @@ -65,43 +65,54 @@ export class DashboardService { SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN' ) AS "openFindings", ( - SELECT COUNT(*) FROM inspection_findings - WHERE status = 'OPEN' AND company_response_received_on IS NULL - ) AS "awaitingCompanyResponse", + SELECT COUNT(*) + FROM inspection_findings finding + WHERE finding.status = 'OPEN' + AND finding.next_control_on IS NULL + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + ) AS "findingsWithoutControlDate", ( - SELECT COUNT(*) FROM inspection_findings - WHERE status = 'OPEN' - AND company_response_received_on IS NULL - AND correction_due_on IS NOT NULL - AND correction_due_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date - ) AS "overdueCompanyResponses", - ( - SELECT COUNT(*) FROM inspection_findings - WHERE status = 'OPEN' - AND company_response_received_on IS NULL - AND correction_due_on BETWEEN - (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date - AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 7 - ) AS "companyResponsesDueNext7Days", - ( - SELECT COUNT(*) FROM inspection_findings - WHERE status = 'OPEN' - AND company_response_received_on IS NOT NULL - AND next_control_on IS NULL - ) AS "awaitingVerificationSchedule", - ( - SELECT COUNT(*) FROM inspection_findings - WHERE status = 'OPEN' - AND company_response_received_on IS NOT NULL - AND next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + SELECT COUNT(*) + FROM inspection_findings finding + WHERE finding.status = 'OPEN' + AND finding.next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' ) AS "overdueControls", ( - SELECT COUNT(*) FROM inspection_findings - WHERE status = 'OPEN' - AND next_control_on BETWEEN + SELECT COUNT(*) + FROM inspection_findings finding + WHERE finding.status = 'OPEN' + AND finding.next_control_on BETWEEN (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 30 - ) AS "controlsNext30Days" + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + ) AS "controlsNext30Days", + (SELECT COUNT(*) FROM inspection_reports WHERE status='WORKING') AS "reportsWorking", + (SELECT COUNT(*) FROM inspection_reports WHERE status='OFFICIALIZED') AS "reportsOfficialized", + ( + SELECT COUNT(*) + FROM inspection_acts act + WHERE act.status='SEALED' + AND NOT EXISTS (SELECT 1 FROM inspection_reports report WHERE report.act_id=act.id) + ) AS "sealedActsWithoutReport" `)) as DashboardCountsRow[]; const recentAudit = (await manager.query(` @@ -135,8 +146,14 @@ export class DashboardService { INNER JOIN inspection_visits visit ON visit.id = act.visit_id INNER JOIN assets asset ON asset.id = finding.asset_id WHERE finding.status = 'OPEN' - AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NOT NULL + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' ORDER BY finding.next_control_on, finding.code LIMIT 8 `)) as DashboardUpcomingControl[]; @@ -151,12 +168,12 @@ export class DashboardService { inactiveUsers: Number(counts?.inactiveUsers ?? 0), activeSessions: Number(counts?.activeSessions ?? 0), openFindings: Number(counts?.openFindings ?? 0), - awaitingCompanyResponse: Number(counts?.awaitingCompanyResponse ?? 0), - overdueCompanyResponses: Number(counts?.overdueCompanyResponses ?? 0), - companyResponsesDueNext7Days: Number(counts?.companyResponsesDueNext7Days ?? 0), - awaitingVerificationSchedule: Number(counts?.awaitingVerificationSchedule ?? 0), + findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0), overdueControls: Number(counts?.overdueControls ?? 0), controlsNext30Days: Number(counts?.controlsNext30Days ?? 0), + reportsWorking: Number(counts?.reportsWorking ?? 0), + reportsOfficialized: Number(counts?.reportsOfficialized ?? 0), + sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0), }, recentAudit, upcomingControls, diff --git a/api-v3/src/database/entities/asset-version.entity.ts b/api-v3/src/database/entities/asset-version.entity.ts index 6c9e241..0f041be 100644 --- a/api-v3/src/database/entities/asset-version.entity.ts +++ b/api-v3/src/database/entities/asset-version.entity.ts @@ -5,6 +5,7 @@ export enum AssetVersionChangeType { CREATED = 'CREATED', UPDATED = 'UPDATED', CONTEXT_CHANGED = 'CONTEXT_CHANGED', + FUNCTION_CHANGED = 'FUNCTION_CHANGED', STATUS_CHANGED = 'STATUS_CHANGED', OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED', REGISTRY_UPDATED = 'REGISTRY_UPDATED', diff --git a/api-v3/src/database/entities/audit-event.entity.ts b/api-v3/src/database/entities/audit-event.entity.ts index f77d9b2..42a616e 100644 --- a/api-v3/src/database/entities/audit-event.entity.ts +++ b/api-v3/src/database/entities/audit-event.entity.ts @@ -35,6 +35,9 @@ export enum AuditAction { ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED', ASSET_UPDATED = 'ASSET_UPDATED', ASSET_CONTEXT_CHANGED = 'ASSET_CONTEXT_CHANGED', + ASSET_FUNCTION_CHANGED = 'ASSET_FUNCTION_CHANGED', + INVENTORY_FUNCTION_CREATED = 'INVENTORY_FUNCTION_CREATED', + INVENTORY_FUNCTION_UPDATED = 'INVENTORY_FUNCTION_UPDATED', ASSET_INFORMATION_STATUS_CHANGED = 'ASSET_INFORMATION_STATUS_CHANGED', ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED', ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED', @@ -82,15 +85,24 @@ export enum AuditAction { INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED', INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED', INSPECTION_ACT_RESPONSIBLE_UPDATED = 'INSPECTION_ACT_RESPONSIBLE_UPDATED', + INSPECTION_ACT_LOCKED = 'INSPECTION_ACT_LOCKED', + INSPECTION_ACT_SEALED = 'INSPECTION_ACT_SEALED', INSPECTION_ACT_READY = 'INSPECTION_ACT_READY', INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED', INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED', INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED', INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED', INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED', + INSPECTION_REPORT_UPDATED = 'INSPECTION_REPORT_UPDATED', INSPECTION_REPORT_REVISION_ADDED = 'INSPECTION_REPORT_REVISION_ADDED', + INSPECTION_REPORT_OFFICIALIZED = 'INSPECTION_REPORT_OFFICIALIZED', + INSPECTION_REPORT_FOLLOW_UP_ADDED = 'INSPECTION_REPORT_FOLLOW_UP_ADDED', INSPECTION_REPORT_APPROVED = 'INSPECTION_REPORT_APPROVED', INSPECTION_REPORT_SIGNED = 'INSPECTION_REPORT_SIGNED', + INSPECTION_DEADLINE_POLICY_UPDATED = 'INSPECTION_DEADLINE_POLICY_UPDATED', + INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED', + SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED', + SMTP_TEST_SENT = 'SMTP_TEST_SENT', DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED', DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED', DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT', diff --git a/api-v3/src/database/entities/index.ts b/api-v3/src/database/entities/index.ts index 4b9d2e4..bbc688a 100644 --- a/api-v3/src/database/entities/index.ts +++ b/api-v3/src/database/entities/index.ts @@ -6,14 +6,8 @@ export { Role } from './role.entity'; export { UserRole } from './user-role.entity'; export { User, UserStatus } from './user.entity'; export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity'; -export { - AssetVersion, - AssetVersionChangeType, -} from './asset-version.entity'; -export { - AssetAttributeDataType, - AssetAttributeDefinition, -} from './asset-attribute-definition.entity'; +export { AssetVersion, AssetVersionChangeType } from './asset-version.entity'; +export { AssetAttributeDataType, AssetAttributeDefinition } from './asset-attribute-definition.entity'; export { AssetAttributeValue } from './asset-attribute-value.entity'; export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity'; export { OrganizationProfile, OrganizationKind } from './organization-profile.entity'; @@ -25,69 +19,33 @@ export { AreaLegalRight, AreaLegalRightType, AreaLegalRightStatus } from './area export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity'; export { AssetTypeParentRule } from './asset-type-parent-rule.entity'; export { AssetType, AssetTypeOperationalRole } from './asset-type.entity'; -export { - AssetGeometry, - AssetGeometrySource, - AssetGeometryType, -} from './asset-geometry.entity'; -export { - AssetMedia, - AssetMediaKind, - AssetMediaSource, -} from './asset-media.entity'; -export { - SurveyCampaign, - SurveyCampaignStatus, -} from './survey-campaign.entity'; -export { - SurveyCampaignTarget, - SurveyTargetStatus, -} from './survey-campaign-target.entity'; -export { - SurveyReportOutcome, - SurveyReportStatus, - SurveyTargetReport, -} from './survey-target-report.entity'; -export { SurveyTargetReportMedia } from './survey-target-report-media.entity'; -export { - SurveyReportVersionEvent, - SurveyTargetReportVersion, -} from './survey-target-report-version.entity'; -export { - InspectionVisit, - InspectionVisitStatus, -} from './inspection-visit.entity'; +export { AssetGeometry, AssetGeometrySource, AssetGeometryType } from './asset-geometry.entity'; +export { AssetMedia, AssetMediaKind, AssetMediaSource } from './asset-media.entity'; +export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entity'; export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity'; export { InspectionVisitMember } from './inspection-visit-member.entity'; -export { - DocumentAnnualSequence, - DocumentSequenceType, -} from './document-annual-sequence.entity'; +export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity'; export { InspectionAct, InspectionActStatus, + InspectionActUrgency, + InspectionDeadlineBasis, + InspectionDeadlineDayType, } from './inspection-act.entity'; export { InspectionActAsset } from './inspection-act-asset.entity'; export { InspectionReport, InspectionReportPdfStatus, - InspectionReportReviewStatus, InspectionReportStatus, InspectionReportWordStatus, } from './inspection-report.entity'; -export { - InspectionActVersion, - InspectionActVersionEvent, -} from './inspection-act-version.entity'; +export { InspectionActVersion, InspectionActVersionEvent } from './inspection-act-version.entity'; export { InspectionActResponsible, InspectionResponsibleAttendanceStatus, InspectionResponsibleDocumentType, } from './inspection-act-responsible.entity'; -export { - InspectionActClosure, - InspectionActUploadMode, -} from './inspection-act-closure.entity'; +export { InspectionActClosure, InspectionActUploadMode } from './inspection-act-closure.entity'; export { InspectionActSignature, InspectionActSignatureSource, @@ -95,21 +53,18 @@ export { InspectionActSignerType, InspectionCompanySignatureManifestation, } from './inspection-act-signature.entity'; +export { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity'; +export { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity'; +export { InspectionReportFollowUp, InspectionReportFollowUpType } from './inspection-report-follow-up.entity'; +export { SystemSmtpSettings, SmtpSecurityMode } from './system-smtp-settings.entity'; export { FindingCategory } from './finding-category.entity'; export { FindingCatalogItem } from './finding-catalog-item.entity'; export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity'; export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity'; export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity'; export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity'; -export { - InspectionFinding, - InspectionFindingResponseDueBasis, - InspectionFindingStatus, -} from './inspection-finding.entity'; -export { - InspectionFindingVersion, - InspectionFindingVersionEvent, -} from './inspection-finding-version.entity'; +export { InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus } from './inspection-finding.entity'; +export { InspectionFindingVersion, InspectionFindingVersionEvent } from './inspection-finding-version.entity'; export { InspectionCommunicationChannel, InspectionCommunicationDirection, @@ -122,14 +77,8 @@ export { InspectionEvidenceSource, InspectionFindingEvidence, } from './inspection-finding-evidence.entity'; -export { - InspectionFindingVerificationVisit, - InspectionVerificationOutcome, -} from './inspection-finding-verification-visit.entity'; -export { - InspectionFindingVerificationEvent, - InspectionFindingVerificationEventType, -} from './inspection-finding-verification-event.entity'; +export { InspectionFindingVerificationVisit, InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity'; +export { InspectionFindingVerificationEvent, InspectionFindingVerificationEventType } from './inspection-finding-verification-event.entity'; import { AuditEvent } from './audit-event.entity'; import { AuthSession } from './auth-session.entity'; @@ -154,11 +103,6 @@ import { AreaLegalRightOrganization } from './area-legal-right-organization.enti import { AssetGeometry } from './asset-geometry.entity'; import { AssetVersion } from './asset-version.entity'; import { AssetMedia } from './asset-media.entity'; -import { SurveyCampaign } from './survey-campaign.entity'; -import { SurveyCampaignTarget } from './survey-campaign-target.entity'; -import { SurveyTargetReport } from './survey-target-report.entity'; -import { SurveyTargetReportMedia } from './survey-target-report-media.entity'; -import { SurveyTargetReportVersion } from './survey-target-report-version.entity'; import { InspectionVisit } from './inspection-visit.entity'; import { InspectionVisitAsset } from './inspection-visit-asset.entity'; import { InspectionVisitMember } from './inspection-visit-member.entity'; @@ -170,6 +114,10 @@ import { InspectionActVersion } from './inspection-act-version.entity'; import { InspectionActResponsible } from './inspection-act-responsible.entity'; import { InspectionActClosure } from './inspection-act-closure.entity'; import { InspectionActSignature } from './inspection-act-signature.entity'; +import { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity'; +import { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity'; +import { InspectionReportFollowUp } from './inspection-report-follow-up.entity'; +import { SystemSmtpSettings } from './system-smtp-settings.entity'; import { FindingCategory } from './finding-category.entity'; import { FindingCatalogItem } from './finding-catalog-item.entity'; import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity'; @@ -207,11 +155,6 @@ export const PHASE_A_ENTITIES = [ AssetGeometry, AssetVersion, AssetMedia, - SurveyCampaign, - SurveyCampaignTarget, - SurveyTargetReport, - SurveyTargetReportMedia, - SurveyTargetReportVersion, InspectionVisit, InspectionVisitAsset, InspectionVisitMember, @@ -223,6 +166,10 @@ export const PHASE_A_ENTITIES = [ InspectionActResponsible, InspectionActClosure, InspectionActSignature, + InspectionDeadlinePolicy, + InspectionBusinessCalendarDay, + InspectionReportFollowUp, + SystemSmtpSettings, FindingCategory, FindingCatalogItem, FindingCatalogItemAssetType, diff --git a/api-v3/src/database/entities/inspection-act-version.entity.ts b/api-v3/src/database/entities/inspection-act-version.entity.ts index ba98320..5c72100 100644 --- a/api-v3/src/database/entities/inspection-act-version.entity.ts +++ b/api-v3/src/database/entities/inspection-act-version.entity.ts @@ -3,6 +3,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; export enum InspectionActVersionEvent { CREATED = 'CREATED', UPDATED = 'UPDATED', + LOCKED = 'LOCKED', + SEALED = 'SEALED', READY = 'READY', REOPENED = 'REOPENED', CLOSED = 'CLOSED', diff --git a/api-v3/src/database/entities/inspection-act.entity.ts b/api-v3/src/database/entities/inspection-act.entity.ts index d7e57be..9f0b0f0 100644 --- a/api-v3/src/database/entities/inspection-act.entity.ts +++ b/api-v3/src/database/entities/inspection-act.entity.ts @@ -3,12 +3,33 @@ import { TimestampedEntity } from './timestamped.entity'; export enum InspectionActStatus { DRAFT = 'DRAFT', + LOCKED = 'LOCKED', + SEALED = 'SEALED', READY = 'READY', CLOSED = 'CLOSED', CANCELLED = 'CANCELLED', RECTIFIED = 'RECTIFIED', } +export enum InspectionActUrgency { + URGENT = 'URGENT', + NON_URGENT = 'NON_URGENT', +} + +export enum InspectionDeadlineDayType { + BUSINESS = 'BUSINESS', + CALENDAR = 'CALENDAR', +} + +export enum InspectionDeadlineBasis { + ACT_DATE = 'ACT_DATE', + // La columna F4 temprana usó GEDO_DATE. Se conserva el valor físico por + // compatibilidad de migración, pero funcionalmente significa "pendiente de + // fecha de notificación" hasta que el procedimiento defina el evento válido. + NOTIFICATION_DATE = 'GEDO_DATE', + GEDO_DATE = 'GEDO_DATE', +} + @Entity({ name: 'inspection_acts' }) @Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" }) @Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true }) @@ -16,6 +37,7 @@ export enum InspectionActStatus { @Index('idx_inspection_acts_visit_status', ['visitId', 'status']) @Index('idx_inspection_acts_occurred_at', ['occurredAt']) @Index('idx_inspection_acts_created_by', ['createdBy']) +@Index('idx_inspection_acts_deadline', ['deadlineAt']) export class InspectionAct extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!: string; @@ -29,7 +51,7 @@ export class InspectionAct extends TimestampedEntity { @Column({ name: 'act_number', type: 'integer' }) actNumber!: number; - @Column({ type: 'varchar', length: 24 }) + @Column({ type: 'varchar', length: 40 }) code!: string; @Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT }) @@ -47,6 +69,39 @@ export class InspectionAct extends TimestampedEntity { @Column({ type: 'text', nullable: true }) observations!: string | null; + @Column({ name: 'urgency', type: 'varchar', length: 24, default: InspectionActUrgency.NON_URGENT }) + urgency!: InspectionActUrgency; + + @Column({ name: 'deadline_days', type: 'integer', nullable: true }) + deadlineDays!: number | null; + + @Column({ name: 'deadline_day_type', type: 'varchar', length: 24, nullable: true }) + deadlineDayType!: InspectionDeadlineDayType | null; + + @Column({ name: 'deadline_basis', type: 'varchar', length: 24, nullable: true }) + deadlineBasis!: InspectionDeadlineBasis | null; + + @Column({ name: 'deadline_base_at', type: 'timestamptz', nullable: true }) + deadlineBaseAt!: Date | null; + + @Column({ name: 'deadline_at', type: 'timestamptz', nullable: true }) + deadlineAt!: Date | null; + + @Column({ name: 'locked_at', type: 'timestamptz', nullable: true }) + lockedAt!: Date | null; + + @Column({ name: 'locked_by', type: 'uuid', nullable: true }) + lockedBy!: string | null; + + @Column({ name: 'locked_sha256', type: 'char', length: 64, nullable: true }) + lockedSha256!: string | null; + + @Column({ name: 'sealed_at', type: 'timestamptz', nullable: true }) + sealedAt!: Date | null; + + @Column({ name: 'sealed_by', type: 'uuid', nullable: true }) + sealedBy!: string | null; + @Column({ name: 'current_version', type: 'integer', default: 0 }) currentVersion!: number; diff --git a/api-v3/src/database/entities/inspection-business-calendar-day.entity.ts b/api-v3/src/database/entities/inspection-business-calendar-day.entity.ts new file mode 100644 index 0000000..0a7752f --- /dev/null +++ b/api-v3/src/database/entities/inspection-business-calendar-day.entity.ts @@ -0,0 +1,21 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'inspection_business_calendar_days' }) +@Index('uq_inspection_business_calendar_day', ['date'], { unique: true }) +export class InspectionBusinessCalendarDay extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'date' }) + date!: string; + + @Column({ name: 'is_business_day', type: 'boolean', default: false }) + isBusinessDay!: boolean; + + @Column({ type: 'varchar', length: 200 }) + label!: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/entities/inspection-deadline-policy.entity.ts b/api-v3/src/database/entities/inspection-deadline-policy.entity.ts new file mode 100644 index 0000000..9072f55 --- /dev/null +++ b/api-v3/src/database/entities/inspection-deadline-policy.entity.ts @@ -0,0 +1,24 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { InspectionDeadlineDayType } from './inspection-act.entity'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'inspection_deadline_policies' }) +export class InspectionDeadlinePolicy extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'urgent_days', type: 'integer', default: 5 }) + urgentDays!: number; + + @Column({ name: 'urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS }) + urgentDayType!: InspectionDeadlineDayType; + + @Column({ name: 'non_urgent_days', type: 'integer', default: 10 }) + nonUrgentDays!: number; + + @Column({ name: 'non_urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS }) + nonUrgentDayType!: InspectionDeadlineDayType; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/entities/inspection-finding.entity.ts b/api-v3/src/database/entities/inspection-finding.entity.ts index e1f5a69..67e3631 100644 --- a/api-v3/src/database/entities/inspection-finding.entity.ts +++ b/api-v3/src/database/entities/inspection-finding.entity.ts @@ -1,6 +1,7 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity'; +/** @deprecated Los plazos de respuesta pasan al nivel Acta/Informe en F4. */ export enum InspectionFindingResponseDueBasis { FINDING_DATE = 'FINDING_DATE', REPORT_NOTIFICATION = 'REPORT_NOTIFICATION', @@ -18,6 +19,7 @@ export enum InspectionFindingStatus { @Index('idx_inspection_findings_status_control', ['status', 'nextControlOn']) @Index('idx_inspection_findings_asset_status', ['assetId', 'status']) @Index('idx_inspection_findings_catalog_item', ['catalogItemId']) +@Index('idx_inspection_findings_recurrence_of', ['recurrenceOfFindingId']) export class InspectionFinding extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!: string; @@ -61,9 +63,16 @@ export class InspectionFinding extends TimestampedEntity { @Column({ type: 'smallint', nullable: true }) severity!: number | null; + @Column({ name: 'is_recurrence', type: 'boolean', default: false }) + isRecurrence!: boolean; + + @Column({ name: 'recurrence_of_finding_id', type: 'uuid', nullable: true }) + recurrenceOfFindingId!: string | null; + @Column({ name: 'correction_due_on', type: 'date', nullable: true }) correctionDueOn!: string | null; + // Campos legacy conservados temporalmente para migrar datos sin pérdida. @Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true }) responseDueBasis!: InspectionFindingResponseDueBasis | null; diff --git a/api-v3/src/database/entities/inspection-report-follow-up.entity.ts b/api-v3/src/database/entities/inspection-report-follow-up.entity.ts new file mode 100644 index 0000000..e7b26e4 --- /dev/null +++ b/api-v3/src/database/entities/inspection-report-follow-up.entity.ts @@ -0,0 +1,50 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum InspectionReportFollowUpType { + COMPANY_NOTE = 'COMPANY_NOTE', + COMPANY_DOCUMENT = 'COMPANY_DOCUMENT', + INTERNAL_NOTE = 'INTERNAL_NOTE', + VERIFICATION = 'VERIFICATION', + OTHER = 'OTHER', +} + +@Entity({ name: 'inspection_report_follow_ups' }) +@Index('idx_inspection_report_follow_ups_report_created', ['reportId', 'createdAt']) +export class InspectionReportFollowUp extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'report_id', type: 'uuid' }) + reportId!: string; + + @Column({ type: 'varchar', length: 40 }) + type!: InspectionReportFollowUpType; + + @Column({ name: 'external_reference', type: 'varchar', length: 255, nullable: true }) + externalReference!: string | null; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true }) + originalName!: string | null; + + @Column({ name: 'stored_name', type: 'varchar', length: 255, nullable: true }) + storedName!: string | null; + + @Column({ name: 'mime_type', type: 'varchar', length: 120, nullable: true }) + mimeType!: string | null; + + @Column({ name: 'size_bytes', type: 'integer', nullable: true }) + sizeBytes!: number | null; + + @Column({ name: 'sha256', type: 'char', length: 64, nullable: true }) + sha256!: string | null; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; +} diff --git a/api-v3/src/database/entities/inspection-report.entity.ts b/api-v3/src/database/entities/inspection-report.entity.ts index 77c4291..4fba6b4 100644 --- a/api-v3/src/database/entities/inspection-report.entity.ts +++ b/api-v3/src/database/entities/inspection-report.entity.ts @@ -2,6 +2,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity'; export enum InspectionReportStatus { + WORKING = 'WORKING', + OFFICIALIZED = 'OFFICIALIZED', FROZEN = 'FROZEN', CANCELLED = 'CANCELLED', } @@ -18,12 +20,6 @@ export enum InspectionReportWordStatus { FAILED = 'FAILED', } -export enum InspectionReportReviewStatus { - PENDING_REVIEW = 'PENDING_REVIEW', - APPROVED = 'APPROVED', - SIGNED = 'SIGNED', -} - @Entity({ name: 'inspection_reports' }) @Index('idx_inspection_reports_visit_id', ['visitId']) @Index('uq_inspection_reports_act', ['actId'], { unique: true }) @@ -31,6 +27,7 @@ export enum InspectionReportReviewStatus { @Index('uq_inspection_reports_code', ['code'], { unique: true }) @Index('idx_inspection_reports_generated_at', ['generatedAt']) @Index('idx_inspection_reports_status', ['status', 'pdfStatus']) +@Index('idx_inspection_reports_gedo_officialized_at', ['gedoOfficializedAt']) export class InspectionReport extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!: string; @@ -47,12 +44,39 @@ export class InspectionReport extends TimestampedEntity { @Column({ name: 'report_number', type: 'integer' }) reportNumber!: number; - @Column({ type: 'varchar', length: 24 }) + @Column({ type: 'varchar', length: 40 }) code!: string; - @Column({ type: 'varchar', length: 24, default: InspectionReportStatus.FROZEN }) + @Column({ type: 'varchar', length: 24, default: InspectionReportStatus.WORKING }) status!: InspectionReportStatus; + @Column({ name: 'executive_summary', type: 'text', nullable: true }) + executiveSummary!: string | null; + + @Column({ name: 'report_description', type: 'text', nullable: true }) + reportDescription!: string | null; + + @Column({ name: 'gedo_if_identifier', type: 'varchar', length: 255, nullable: true }) + gedoIfIdentifier!: string | null; + + @Column({ name: 'gedo_officialized_at', type: 'timestamptz', nullable: true }) + gedoOfficializedAt!: Date | null; + + @Column({ name: 'gedo_pdf_original_name', type: 'varchar', length: 255, nullable: true }) + gedoPdfOriginalName!: string | null; + + @Column({ name: 'gedo_pdf_stored_name', type: 'varchar', length: 255, nullable: true }) + gedoPdfStoredName!: string | null; + + @Column({ name: 'gedo_pdf_mime_type', type: 'varchar', length: 120, nullable: true }) + gedoPdfMimeType!: string | null; + + @Column({ name: 'gedo_pdf_size_bytes', type: 'integer', nullable: true }) + gedoPdfSizeBytes!: number | null; + + @Column({ name: 'gedo_pdf_sha256', type: 'char', length: 64, nullable: true }) + gedoPdfSha256!: string | null; + @Column({ name: 'pdf_status', type: 'varchar', length: 24, default: InspectionReportPdfStatus.PENDING }) pdfStatus!: InspectionReportPdfStatus; @@ -80,27 +104,9 @@ export class InspectionReport extends TimestampedEntity { @Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true }) wordError!: string | null; - @Column({ name: 'review_status', type: 'varchar', length: 32, default: InspectionReportReviewStatus.PENDING_REVIEW }) - reviewStatus!: InspectionReportReviewStatus; - @Column({ name: 'current_revision_number', type: 'integer', default: 0 }) currentRevisionNumber!: number; - @Column({ name: 'approved_revision_id', type: 'uuid', nullable: true }) - approvedRevisionId!: string | null; - - @Column({ name: 'approved_by', type: 'uuid', nullable: true }) - approvedBy!: string | null; - - @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) - approvedAt!: Date | null; - - @Column({ name: 'review_note', type: 'varchar', length: 1000, nullable: true }) - reviewNote!: string | null; - - @Column({ name: 'signed_at', type: 'timestamptz', nullable: true }) - signedAt!: Date | null; - @Column({ type: 'varchar', length: 220 }) title!: string; diff --git a/api-v3/src/database/entities/inspection-visit.entity.ts b/api-v3/src/database/entities/inspection-visit.entity.ts index 23f3b5c..e8eca62 100644 --- a/api-v3/src/database/entities/inspection-visit.entity.ts +++ b/api-v3/src/database/entities/inspection-visit.entity.ts @@ -14,7 +14,7 @@ export enum InspectionVisitStatus { @Index('idx_inspection_visits_status', ['status']) @Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId']) @Index('idx_inspection_visits_lead_inspector_user_id', ['leadInspectorUserId']) -@Index('idx_inspection_visits_planned_dates', ['plannedStartAt', 'plannedEndAt']) +@Index('idx_inspection_visits_planned_start_at', ['plannedStartAt']) @Index('idx_inspection_visits_operational_context', ['operationalAreaId', 'operatorCompanyId']) export class InspectionVisit extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') @@ -23,9 +23,6 @@ export class InspectionVisit extends TimestampedEntity { @Column({ type: 'varchar', length: 80 }) code!: string; - @Column({ type: 'varchar', length: 200 }) - title!: string; - @Column({ type: 'text', nullable: true }) objective!: string | null; @@ -47,9 +44,6 @@ export class InspectionVisit extends TimestampedEntity { @Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true }) plannedStartAt!: Date | null; - @Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true }) - plannedEndAt!: Date | null; - @Column({ name: 'actual_started_at', type: 'timestamptz', nullable: true }) actualStartedAt!: Date | null; diff --git a/api-v3/src/database/entities/survey-campaign-target.entity.ts b/api-v3/src/database/entities/survey-campaign-target.entity.ts deleted file mode 100644 index f944fbb..0000000 --- a/api-v3/src/database/entities/survey-campaign-target.entity.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -import { TimestampedEntity } from './timestamped.entity'; - -export enum SurveyTargetStatus { - PENDING = 'PENDING', - IN_PROGRESS = 'IN_PROGRESS', - SUBMITTED = 'SUBMITTED', - COMPLETED = 'COMPLETED', - SKIPPED = 'SKIPPED', -} - -@Entity({ name: 'survey_campaign_targets' }) -@Index('uq_survey_campaign_targets_campaign_asset', ['campaignId', 'assetId'], { unique: true }) -@Index('idx_survey_campaign_targets_campaign_status', ['campaignId', 'status']) -@Index('idx_survey_campaign_targets_asset_id', ['assetId']) -@Index('idx_survey_campaign_targets_assigned_user_id', ['assignedUserId']) -export class SurveyCampaignTarget extends TimestampedEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ name: 'campaign_id', type: 'uuid' }) - campaignId!: string; - - @Column({ name: 'asset_id', type: 'uuid' }) - assetId!: string; - - @Column({ name: 'assigned_user_id', type: 'uuid', nullable: true }) - assignedUserId!: string | null; - - @Column({ type: 'varchar', length: 24, default: SurveyTargetStatus.PENDING }) - status!: SurveyTargetStatus; - - @Column({ name: 'due_at', type: 'timestamptz', nullable: true }) - dueAt!: Date | null; - - @Column({ type: 'text', nullable: true }) - instructions!: string | null; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy!: string | null; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy!: string | null; -} diff --git a/api-v3/src/database/entities/survey-campaign.entity.ts b/api-v3/src/database/entities/survey-campaign.entity.ts deleted file mode 100644 index a6c536d..0000000 --- a/api-v3/src/database/entities/survey-campaign.entity.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -import { TimestampedEntity } from './timestamped.entity'; - -export enum SurveyCampaignStatus { - DRAFT = 'DRAFT', - PLANNED = 'PLANNED', - IN_PROGRESS = 'IN_PROGRESS', - COMPLETED = 'COMPLETED', - CANCELLED = 'CANCELLED', -} - -@Entity({ name: 'survey_campaigns' }) -@Index('uq_survey_campaigns_code', ['code'], { unique: true }) -@Index('idx_survey_campaigns_status', ['status']) -@Index('idx_survey_campaigns_scope_asset_id', ['scopeAssetId']) -@Index('idx_survey_campaigns_coordinator_user_id', ['coordinatorUserId']) -@Index('idx_survey_campaigns_planned_dates', ['plannedStartAt', 'plannedEndAt']) -export class SurveyCampaign extends TimestampedEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ type: 'varchar', length: 80 }) - code!: string; - - @Column({ type: 'varchar', length: 200 }) - name!: string; - - @Column({ type: 'text', nullable: true }) - description!: string | null; - - @Column({ type: 'varchar', length: 24, default: SurveyCampaignStatus.DRAFT }) - status!: SurveyCampaignStatus; - - @Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true }) - plannedStartAt!: Date | null; - - @Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true }) - plannedEndAt!: Date | null; - - @Column({ name: 'scope_asset_id', type: 'uuid', nullable: true }) - scopeAssetId!: string | null; - - @Column({ name: 'coordinator_user_id', type: 'uuid', nullable: true }) - coordinatorUserId!: string | null; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy!: string | null; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy!: string | null; -} diff --git a/api-v3/src/database/entities/survey-target-report-media.entity.ts b/api-v3/src/database/entities/survey-target-report-media.entity.ts deleted file mode 100644 index 66dfadb..0000000 --- a/api-v3/src/database/entities/survey-target-report-media.entity.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; -import { TimestampedEntity } from './timestamped.entity'; - -@Entity({ name: 'survey_target_report_media' }) -@Index('idx_survey_target_report_media_media_id', ['mediaId']) -@Index('idx_survey_target_report_media_included', ['reportId', 'included']) -export class SurveyTargetReportMedia extends TimestampedEntity { - @PrimaryColumn({ name: 'report_id', type: 'uuid' }) - reportId!: string; - - @PrimaryColumn({ name: 'media_id', type: 'uuid' }) - mediaId!: string; - - @Column({ type: 'boolean', default: true }) - included!: boolean; - - @Column({ name: 'added_by', type: 'uuid', nullable: true }) - addedBy!: string | null; -} diff --git a/api-v3/src/database/entities/survey-target-report-version.entity.ts b/api-v3/src/database/entities/survey-target-report-version.entity.ts deleted file mode 100644 index b32a790..0000000 --- a/api-v3/src/database/entities/survey-target-report-version.entity.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; - -export enum SurveyReportVersionEvent { - SUBMITTED = 'SUBMITTED', - APPROVED = 'APPROVED', - REJECTED = 'REJECTED', -} - -@Entity({ name: 'survey_target_report_versions' }) -@Index('uq_survey_target_report_versions_number', ['reportId', 'versionNumber'], { unique: true }) -@Index('idx_survey_target_report_versions_created_at', ['createdAt']) -export class SurveyTargetReportVersion { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ name: 'report_id', type: 'uuid' }) - reportId!: string; - - @Column({ name: 'version_number', type: 'integer' }) - versionNumber!: number; - - @Column({ type: 'varchar', length: 24 }) - event!: SurveyReportVersionEvent; - - @Column({ type: 'jsonb' }) - snapshot!: Record; - - @Column({ name: 'actor_user_id', type: 'uuid', nullable: true }) - actorUserId!: string | null; - - @Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true }) - actorUsername!: string | null; - - @Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) - createdAt!: Date; -} diff --git a/api-v3/src/database/entities/survey-target-report.entity.ts b/api-v3/src/database/entities/survey-target-report.entity.ts deleted file mode 100644 index b835063..0000000 --- a/api-v3/src/database/entities/survey-target-report.entity.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -import { TimestampedEntity } from './timestamped.entity'; - -export enum SurveyReportOutcome { - CONFIRMED = 'CONFIRMED', - CHANGES_RECORDED = 'CHANGES_RECORDED', - NOT_LOCATED = 'NOT_LOCATED', -} - -export enum SurveyReportStatus { - DRAFT = 'DRAFT', - SUBMITTED = 'SUBMITTED', - APPROVED = 'APPROVED', - REJECTED = 'REJECTED', -} - -@Entity({ name: 'survey_target_reports' }) -@Index('uq_survey_target_reports_target_id', ['targetId'], { unique: true }) -@Index('idx_survey_target_reports_status', ['status']) -@Index('idx_survey_target_reports_submitted_by', ['submittedBy']) -@Index('idx_survey_target_reports_reviewed_by', ['reviewedBy']) -export class SurveyTargetReport extends TimestampedEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ name: 'target_id', type: 'uuid' }) - targetId!: string; - - @Column({ type: 'varchar', length: 32, nullable: true }) - outcome!: SurveyReportOutcome | null; - - @Column({ type: 'varchar', length: 24, default: SurveyReportStatus.DRAFT }) - status!: SurveyReportStatus; - - @Column({ name: 'observed_at', type: 'timestamptz', nullable: true }) - observedAt!: Date | null; - - @Column({ type: 'numeric', precision: 9, scale: 6, nullable: true }) - latitude!: string | null; - - @Column({ type: 'numeric', precision: 9, scale: 6, nullable: true }) - longitude!: string | null; - - @Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true }) - accuracyM!: string | null; - - @Column({ type: 'text', nullable: true }) - notes!: string | null; - - @Column({ name: 'asset_version_at_submission', type: 'integer', nullable: true }) - assetVersionAtSubmission!: number | null; - - @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) - submittedAt!: Date | null; - - @Column({ name: 'submitted_by', type: 'uuid', nullable: true }) - submittedBy!: string | null; - - @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) - reviewedAt!: Date | null; - - @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) - reviewedBy!: string | null; - - @Column({ name: 'review_notes', type: 'text', nullable: true }) - reviewNotes!: string | null; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy!: string | null; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy!: string | null; -} diff --git a/api-v3/src/database/entities/system-smtp-settings.entity.ts b/api-v3/src/database/entities/system-smtp-settings.entity.ts new file mode 100644 index 0000000..06ae268 --- /dev/null +++ b/api-v3/src/database/entities/system-smtp-settings.entity.ts @@ -0,0 +1,44 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum SmtpSecurityMode { + NONE = 'NONE', + STARTTLS = 'STARTTLS', + TLS = 'TLS', +} + +@Entity({ name: 'system_smtp_settings' }) +export class SystemSmtpSettings extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 255 }) + host!: string; + + @Column({ type: 'integer' }) + port!: number; + + @Column({ name: 'security_mode', type: 'varchar', length: 24, default: SmtpSecurityMode.STARTTLS }) + securityMode!: SmtpSecurityMode; + + @Column({ type: 'varchar', length: 255, nullable: true }) + username!: string | null; + + @Column({ name: 'password_enc', type: 'text', nullable: true }) + passwordEnc!: string | null; + + @Column({ name: 'from_name', type: 'varchar', length: 200 }) + fromName!: string; + + @Column({ name: 'from_email', type: 'varchar', length: 320 }) + fromEmail!: string; + + @Column({ name: 'reply_to', type: 'varchar', length: 320, nullable: true }) + replyTo!: string | null; + + @Column({ type: 'boolean', default: true }) + enabled!: boolean; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} diff --git a/api-v3/src/database/migrations/1790000000000-f4-document-workflow-foundation.ts b/api-v3/src/database/migrations/1790000000000-f4-document-workflow-foundation.ts new file mode 100644 index 0000000..6eec43d --- /dev/null +++ b/api-v3/src/database/migrations/1790000000000-f4-document-workflow-foundation.ts @@ -0,0 +1,161 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F4DocumentWorkflowFoundation1790000000000 implements MigrationInterface { + name = 'F4DocumentWorkflowFoundation1790000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE inspection_acts ALTER COLUMN code TYPE varchar(40)`); + await queryRunner.query(`ALTER TABLE inspection_reports ALTER COLUMN code TYPE varchar(40)`); + + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS urgency varchar(24) NOT NULL DEFAULT 'NON_URGENT'`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_days integer`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_day_type varchar(24)`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_basis varchar(24)`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_base_at timestamptz`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_at timestamptz`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_at timestamptz`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_by uuid`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_sha256 char(64)`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_at timestamptz`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_by uuid`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_acts_deadline ON inspection_acts(deadline_at)`); + + await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS is_recurrence boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS recurrence_of_finding_id uuid`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_findings_recurrence_of ON inspection_findings(recurrence_of_finding_id)`); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE inspection_findings + ADD CONSTRAINT fk_inspection_findings_recurrence_of + FOREIGN KEY (recurrence_of_finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT; + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `); + + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS executive_summary text`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS report_description text`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_if_identifier varchar(255)`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_officialized_at timestamptz`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_original_name varchar(255)`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_stored_name varchar(255)`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_mime_type varchar(120)`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_size_bytes integer`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_sha256 char(64)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_reports_gedo_officialized_at ON inspection_reports(gedo_officialized_at)`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS inspection_deadline_policies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + urgent_days integer NOT NULL DEFAULT 5 CHECK (urgent_days > 0), + urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS', + non_urgent_days integer NOT NULL DEFAULT 10 CHECK (non_urgent_days > 0), + non_urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS', + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_inspection_deadline_policy_urgent_type CHECK (urgent_day_type IN ('BUSINESS','CALENDAR')), + CONSTRAINT ck_inspection_deadline_policy_non_urgent_type CHECK (non_urgent_day_type IN ('BUSINESS','CALENDAR')) + ) + `); + await queryRunner.query(` + INSERT INTO inspection_deadline_policies (urgent_days, urgent_day_type, non_urgent_days, non_urgent_day_type) + SELECT 5, 'BUSINESS', 10, 'BUSINESS' + WHERE NOT EXISTS (SELECT 1 FROM inspection_deadline_policies) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS inspection_business_calendar_days ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + date date NOT NULL, + is_business_day boolean NOT NULL DEFAULT false, + label varchar(200) NOT NULL, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_inspection_business_calendar_day UNIQUE(date) + ) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS inspection_report_follow_ups ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + report_id uuid NOT NULL, + type varchar(40) NOT NULL, + external_reference varchar(255), + occurred_at timestamptz NOT NULL, + description text, + original_name varchar(255), + stored_name varchar(255), + mime_type varchar(120), + size_bytes integer, + sha256 char(64), + created_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT fk_inspection_report_follow_ups_report FOREIGN KEY(report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE, + CONSTRAINT ck_inspection_report_follow_up_type CHECK (type IN ('COMPANY_NOTE','COMPANY_DOCUMENT','INTERNAL_NOTE','VERIFICATION','OTHER')) + ) + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_report_follow_ups_report_created ON inspection_report_follow_ups(report_id, created_at)`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS system_smtp_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + host varchar(255) NOT NULL, + port integer NOT NULL CHECK (port > 0 AND port <= 65535), + security_mode varchar(24) NOT NULL DEFAULT 'STARTTLS', + username varchar(255), + password_enc text, + from_name varchar(200) NOT NULL, + from_email varchar(320) NOT NULL, + reply_to varchar(320), + enabled boolean NOT NULL DEFAULT true, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_system_smtp_security_mode CHECK (security_mode IN ('NONE','STARTTLS','TLS')) + ) + `); + + await queryRunner.query(` + UPDATE inspection_acts + SET + deadline_days = COALESCE(deadline_days, 10), + deadline_day_type = COALESCE(deadline_day_type, 'BUSINESS'), + deadline_basis = COALESCE(deadline_basis, 'GEDO_DATE') + WHERE deadline_days IS NULL OR deadline_day_type IS NULL OR deadline_basis IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS system_smtp_settings`); + await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_ups`); + await queryRunner.query(`DROP TABLE IF EXISTS inspection_business_calendar_days`); + await queryRunner.query(`DROP TABLE IF EXISTS inspection_deadline_policies`); + + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_sha256`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_size_bytes`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_mime_type`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_stored_name`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_original_name`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_officialized_at`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_if_identifier`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS report_description`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS executive_summary`); + + await queryRunner.query(`ALTER TABLE inspection_findings DROP CONSTRAINT IF EXISTS fk_inspection_findings_recurrence_of`); + await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS recurrence_of_finding_id`); + await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS is_recurrence`); + + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_by`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_at`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_sha256`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_by`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_at`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_at`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_base_at`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_basis`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_day_type`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_days`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS urgency`); + } +} diff --git a/api-v3/src/database/migrations/1790000100000-f4-inventory-function-history.ts b/api-v3/src/database/migrations/1790000100000-f4-inventory-function-history.ts new file mode 100644 index 0000000..c87b657 --- /dev/null +++ b/api-v3/src/database/migrations/1790000100000-f4-inventory-function-history.ts @@ -0,0 +1,80 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F4InventoryFunctionHistory1790000100000 implements MigrationInterface { + name = 'F4InventoryFunctionHistory1790000100000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE inventory_functions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(120) NOT NULL UNIQUE, + name varchar(240) NOT NULL, + description text, + is_active boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_by uuid, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_inventory_functions_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_inventory_functions_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inventory_functions_active_sort + ON inventory_functions(is_active,sort_order,name) + `); + + await queryRunner.query(` + CREATE TABLE inventory_function_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id uuid NOT NULL, + function_id uuid NOT NULL, + valid_from timestamptz NOT NULL, + valid_until timestamptz, + change_reason text, + changed_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_inventory_function_assignment_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE CASCADE, + CONSTRAINT fk_inventory_function_assignment_function FOREIGN KEY (function_id) + REFERENCES inventory_functions(id) ON DELETE RESTRICT, + CONSTRAINT fk_inventory_function_assignment_user FOREIGN KEY (changed_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT chk_inventory_function_assignment_dates CHECK ( + valid_until IS NULL OR valid_until > valid_from + ) + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX uq_inventory_function_assignment_current + ON inventory_function_assignments(asset_id) + WHERE valid_until IS NULL + `); + await queryRunner.query(` + CREATE INDEX idx_inventory_function_assignment_history + ON inventory_function_assignments(asset_id,valid_from DESC,created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_inventory_function_assignment_function + ON inventory_function_assignments(function_id,valid_from DESC) + `); + + await queryRunner.query(` + INSERT INTO inventory_functions(code,name,description,sort_order) + VALUES + ('BOMBEO_MECANICO_AIB','Bombeo mecánico AIB', + 'Función técnica de bombeo mecánico mediante aparato individual de bombeo (AIB).',10), + ('BOMBEO_MECANICO_ROTAFLEX','Bombeo mecánico Rotaflex', + 'Función técnica de bombeo mecánico mediante sistema Rotaflex.',20) + ON CONFLICT (code) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE IF EXISTS inventory_function_assignments'); + await queryRunner.query('DROP TABLE IF EXISTS inventory_functions'); + } +} diff --git a/api-v3/src/database/migrations/1790000200000-f4-company-signature-invites.ts b/api-v3/src/database/migrations/1790000200000-f4-company-signature-invites.ts new file mode 100644 index 0000000..99c2c4e --- /dev/null +++ b/api-v3/src/database/migrations/1790000200000-f4-company-signature-invites.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F4CompanySignatureInvites1790000200000 implements MigrationInterface { + name = 'F4CompanySignatureInvites1790000200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE inspection_act_company_signature_invites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + act_id uuid NOT NULL, + token_sha256 char(64) NOT NULL UNIQUE, + recipient_email varchar(320) NOT NULL, + recipient_name varchar(200), + recipient_document_type varchar(20), + recipient_document_number varchar(40), + recipient_position varchar(200), + status varchar(20) NOT NULL DEFAULT 'PENDING', + expires_at timestamptz NOT NULL, + sent_at timestamptz, + used_at timestamptz, + revoked_at timestamptz, + created_by uuid NOT NULL, + revoked_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_company_signature_invite_act FOREIGN KEY (act_id) + REFERENCES inspection_acts(id) ON DELETE CASCADE, + CONSTRAINT fk_company_signature_invite_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT fk_company_signature_invite_revoked_by FOREIGN KEY (revoked_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT chk_company_signature_invite_status CHECK ( + status IN ('PENDING','USED','REVOKED','EXPIRED') + ) + ) + `); + await queryRunner.query(` + CREATE INDEX idx_company_signature_invites_act_created + ON inspection_act_company_signature_invites(act_id,created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_company_signature_invites_pending_expiry + ON inspection_act_company_signature_invites(status,expires_at) + WHERE status='PENDING' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE IF EXISTS inspection_act_company_signature_invites'); + } +} diff --git a/api-v3/src/database/migrations/1790000300000-f4-remove-survey-subsystem.ts b/api-v3/src/database/migrations/1790000300000-f4-remove-survey-subsystem.ts new file mode 100644 index 0000000..dad8d8e --- /dev/null +++ b/api-v3/src/database/migrations/1790000300000-f4-remove-survey-subsystem.ts @@ -0,0 +1,220 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const surveyPermissions = [ + 'surveys.read', + 'surveys.manage', + 'surveys.assign', + 'surveys.execute', + 'surveys.read_reports', + 'surveys.capture', + 'surveys.review', +] as const; + +const rolePermissionValues = ` + ('admin', 'surveys.read'), + ('admin', 'surveys.manage'), + ('admin', 'surveys.assign'), + ('admin', 'surveys.execute'), + ('admin', 'surveys.read_reports'), + ('admin', 'surveys.capture'), + ('admin', 'surveys.review'), + ('director', 'surveys.read'), + ('director', 'surveys.read_reports'), + ('director', 'surveys.review'), + ('supervisor', 'surveys.read'), + ('supervisor', 'surveys.manage'), + ('supervisor', 'surveys.assign'), + ('supervisor', 'surveys.execute'), + ('supervisor', 'surveys.read_reports'), + ('supervisor', 'surveys.capture'), + ('supervisor', 'surveys.review'), + ('inspector', 'surveys.read'), + ('inspector', 'surveys.execute'), + ('inspector', 'surveys.read_reports'), + ('inspector', 'surveys.capture'), + ('auditor', 'surveys.read'), + ('auditor', 'surveys.read_reports') +`; + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +export class F4RemoveSurveySubsystem1790000300000 implements MigrationInterface { + name = 'F4RemoveSurveySubsystem1790000300000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE code = ANY($1::varchar[]) + ) + `, [surveyPermissions]); + await queryRunner.query( + `DELETE FROM permissions WHERE code = ANY($1::varchar[])`, + [surveyPermissions], + ); + + // Deliberadamente sin CASCADE: una dependencia externa debe detener la migración. + await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_versions`); + await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_media`); + await queryRunner.query(`DROP TABLE IF EXISTS survey_target_reports`); + await queryRunner.query(`DROP TABLE IF EXISTS survey_campaign_targets`); + await queryRunner.query(`DROP TABLE IF EXISTS survey_campaigns`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS survey_campaigns ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(80) NOT NULL UNIQUE, + name varchar(200) NOT NULL, + description text, + status varchar(24) NOT NULL DEFAULT 'DRAFT', + planned_start_at timestamptz, + planned_end_at timestamptz, + scope_asset_id uuid REFERENCES assets(id) ON DELETE RESTRICT, + coordinator_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + updated_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_survey_campaigns_status CHECK ( + status IN ('DRAFT','PLANNED','IN_PROGRESS','COMPLETED','CANCELLED') + ), + CONSTRAINT chk_survey_campaigns_dates CHECK ( + planned_start_at IS NULL OR planned_end_at IS NULL OR planned_end_at >= planned_start_at + ) + ) + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_status ON survey_campaigns(status)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_scope_asset_id ON survey_campaigns(scope_asset_id)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_coordinator_user_id ON survey_campaigns(coordinator_user_id)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_planned_dates ON survey_campaigns(planned_start_at,planned_end_at)`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS survey_campaign_targets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id uuid NOT NULL REFERENCES survey_campaigns(id) ON DELETE RESTRICT, + asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT, + assigned_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + status varchar(24) NOT NULL DEFAULT 'PENDING', + due_at timestamptz, + instructions text, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + updated_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_survey_campaign_targets_campaign_asset UNIQUE(campaign_id,asset_id), + CONSTRAINT chk_survey_campaign_targets_status CHECK ( + status IN ('PENDING','IN_PROGRESS','SUBMITTED','COMPLETED','SKIPPED') + ) + ) + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_campaign_status ON survey_campaign_targets(campaign_id,status)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_asset_id ON survey_campaign_targets(asset_id)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_assigned_user_id ON survey_campaign_targets(assigned_user_id)`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS survey_target_reports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + target_id uuid NOT NULL UNIQUE REFERENCES survey_campaign_targets(id) ON DELETE RESTRICT, + outcome varchar(32), + status varchar(24) NOT NULL DEFAULT 'DRAFT', + observed_at timestamptz, + latitude numeric(9,6), + longitude numeric(9,6), + accuracy_m numeric(12,3), + notes text, + asset_version_at_submission integer, + submitted_at timestamptz, + submitted_by uuid REFERENCES users(id) ON DELETE SET NULL, + reviewed_at timestamptz, + reviewed_by uuid REFERENCES users(id) ON DELETE SET NULL, + review_notes text, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + updated_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_survey_target_reports_outcome CHECK ( + outcome IS NULL OR outcome IN ('CONFIRMED','CHANGES_RECORDED','NOT_LOCATED') + ), + CONSTRAINT chk_survey_target_reports_status CHECK ( + status IN ('DRAFT','SUBMITTED','APPROVED','REJECTED') + ), + CONSTRAINT chk_survey_target_reports_coordinates CHECK ( + (latitude IS NULL AND longitude IS NULL) + OR (latitude IS NOT NULL AND longitude IS NOT NULL + AND latitude BETWEEN -90 AND 90 AND longitude BETWEEN -180 AND 180) + ), + CONSTRAINT chk_survey_target_reports_accuracy CHECK ( + accuracy_m IS NULL OR (accuracy_m >= 0 AND latitude IS NOT NULL AND longitude IS NOT NULL) + ) + ) + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_status ON survey_target_reports(status)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_submitted_by ON survey_target_reports(submitted_by)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_reviewed_by ON survey_target_reports(reviewed_by)`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS survey_target_report_media ( + report_id uuid NOT NULL REFERENCES survey_target_reports(id) ON DELETE RESTRICT, + media_id uuid NOT NULL REFERENCES asset_media(id) ON DELETE RESTRICT, + included boolean NOT NULL DEFAULT true, + added_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(report_id,media_id) + ) + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_media_media_id ON survey_target_report_media(media_id)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_media_included ON survey_target_report_media(report_id,included)`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS survey_target_report_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + report_id uuid NOT NULL REFERENCES survey_target_reports(id) ON DELETE RESTRICT, + version_number integer NOT NULL, + event varchar(24) NOT NULL, + snapshot jsonb NOT NULL, + actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + actor_username varchar(80), + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_survey_target_report_versions_number UNIQUE(report_id,version_number), + CONSTRAINT chk_survey_target_report_versions_event CHECK ( + event IN ('SUBMITTED','APPROVED','REJECTED') + ) + ) + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_versions_created_at ON survey_target_report_versions(created_at)`); + + await queryRunner.query(` + INSERT INTO permissions(code,description) VALUES + ('surveys.read','Consultar planificación de relevamientos'), + ('surveys.manage','Crear y actualizar campañas de relevamiento'), + ('surveys.assign','Asignar objetivos de relevamiento'), + ('surveys.execute','Actualizar el avance de objetivos asignados'), + ('surveys.read_reports','Consultar capturas y versiones de relevamientos'), + ('surveys.capture','Capturar y enviar relevamientos de campo'), + ('surveys.review','Aprobar o rechazar relevamientos enviados') + ON CONFLICT(code) DO UPDATE SET description=EXCLUDED.description + `); + await queryRunner.query(` + WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues}) + INSERT INTO role_permissions(role_id,permission_id) + SELECT role.id,permission.id + FROM mapping + JOIN roles role ON role.code=mapping.role_code + JOIN permissions permission ON permission.code=mapping.permission_code + ON CONFLICT(role_id,permission_id) DO NOTHING + `); + + 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 ON TABLE survey_campaigns,survey_campaign_targets,survey_target_reports,survey_target_report_media TO ${applicationRole}`); + await queryRunner.query(`GRANT SELECT,INSERT ON TABLE survey_target_report_versions TO ${applicationRole}`); + await queryRunner.query(`REVOKE DELETE ON TABLE survey_campaigns,survey_campaign_targets,survey_target_reports,survey_target_report_media,survey_target_report_versions FROM ${applicationRole}`); + await queryRunner.query(`REVOKE UPDATE ON TABLE survey_target_report_versions FROM ${applicationRole}`); + } +} diff --git a/api-v3/src/database/migrations/1790000400000-f4-remove-director-report-legacy.ts b/api-v3/src/database/migrations/1790000400000-f4-remove-director-report-legacy.ts new file mode 100644 index 0000000..96b7ff8 --- /dev/null +++ b/api-v3/src/database/migrations/1790000400000-f4-remove-director-report-legacy.ts @@ -0,0 +1,113 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const legacyDirectorPermissions = [ + ['inspection_reports.revise', 'Cargar versiones corregidas del informe para revisión directiva'], + ['inspection_reports.review', 'Aprobar la versión vigente del informe'], + ['inspection_reports.sign_final', 'Firmar electrónicamente el informe final aprobado'], +] as const; + +/** + * Retira definitivamente el circuito activo legacy Director -> revisión -> aprobación -> firma. + * + * current_revision_number e inspection_report_revisions se conservan porque forman parte del + * versionado útil del INF. inspection_report_signatures también se conserva como archivo + * histórico: F4 quita sus endpoints/permisos pero no destruye evidencia documental ya emitida. + */ +export class F4RemoveDirectorReportLegacy1790000400000 implements MigrationInterface { + name = 'F4RemoveDirectorReportLegacy1790000400000'; + + public async up(queryRunner: QueryRunner): Promise { + // Las entregas al Director pertenecían al flujo retirado y no deben seguir + // apareciendo como pendientes/reintentables en F4. + await queryRunner.query(` + DELETE FROM inspection_document_deliveries + WHERE recipient_kind = 'DIRECTOR' + `); + + // Retira permisos huérfanos del circuito anterior para que tampoco aparezcan + // como capacidades administrables en RBAC una vez eliminados sus endpoints. + await queryRunner.query(` + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions + WHERE code IN ( + 'inspection_reports.revise', + 'inspection_reports.review', + 'inspection_reports.sign_final' + ) + ) + `); + await queryRunner.query(` + DELETE FROM permissions + WHERE code IN ( + 'inspection_reports.revise', + 'inspection_reports.review', + 'inspection_reports.sign_final' + ) + `); + + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_revision_id`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_by`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_at`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS review_note`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS signed_at`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS review_status`); + } + + public async down(queryRunner: QueryRunner): Promise { + // El rollback reconstruye el contrato técnico legacy, pero no recrea entregas Director + // ni valores de aprobación eliminados. El backup PRE-F4 sigue siendo la fuente de + // recuperación de esos datos históricos si alguna vez fuera necesario volver al circuito. + await queryRunner.query(` + ALTER TABLE inspection_reports + ADD COLUMN IF NOT EXISTS review_status varchar(32) NOT NULL DEFAULT 'PENDING_REVIEW' + `); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_revision_id uuid`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_by uuid`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_at timestamptz`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS review_note varchar(1000)`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS signed_at timestamptz`); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE inspection_reports + ADD CONSTRAINT chk_inspection_reports_review_status + CHECK (review_status IN ('PENDING_REVIEW','APPROVED','SIGNED')); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE inspection_reports + ADD CONSTRAINT fk_inspection_reports_approved_by + FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE inspection_reports + ADD CONSTRAINT fk_inspection_reports_approved_revision + FOREIGN KEY (approved_revision_id) REFERENCES inspection_report_revisions(id) ON DELETE RESTRICT; + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `); + + for (const [code, description] of legacyDirectorPermissions) { + await queryRunner.query( + `INSERT INTO permissions (code, description) VALUES ($1, $2) ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description`, + [code, description], + ); + } + await queryRunner.query(` + WITH mapping(role_code, permission_code) AS (VALUES + ('director', 'inspection_reports.revise'), + ('director', 'inspection_reports.review'), + ('director', 'inspection_reports.sign_final') + ) + INSERT INTO role_permissions (role_id, permission_id) + SELECT role.id, permission.id + FROM mapping + JOIN roles role ON role.code = mapping.role_code + JOIN permissions permission ON permission.code = mapping.permission_code + ON CONFLICT (role_id, permission_id) DO NOTHING + `); + } +} diff --git a/api-v3/src/database/migrations/1790000500000-f4-remove-inspection-legacy-fields.ts b/api-v3/src/database/migrations/1790000500000-f4-remove-inspection-legacy-fields.ts new file mode 100644 index 0000000..55f62fb --- /dev/null +++ b/api-v3/src/database/migrations/1790000500000-f4-remove-inspection-legacy-fields.ts @@ -0,0 +1,118 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * F4 elimina dos campos heredados de la planificación original de Inspecciones: + * - title: la Inspección se identifica por su código autogenerado. + * - planned_end_at: la planificación sólo define la fecha/hora prevista de inicio. + * + * La migración conserva una copia técnica de los valores eliminados para que un + * rollback restaure los datos exactos existentes antes del cambio. Inspecciones + * creadas después del up() reciben code como title y planned_end_at NULL al bajar. + * + * Se conserva un índice sobre planned_start_at porque sigue siendo un criterio de + * orden y filtro operativo. Ninguna migración histórica se modifica. + */ +export class F4RemoveInspectionLegacyFields1790000500000 implements MigrationInterface { + name = 'F4RemoveInspectionLegacyFields1790000500000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS f4_inspection_visit_legacy_fields_backup ( + visit_id uuid PRIMARY KEY, + title varchar(200) NOT NULL, + planned_end_at timestamptz NULL, + backed_up_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + await queryRunner.query(` + INSERT INTO f4_inspection_visit_legacy_fields_backup ( + visit_id, + title, + planned_end_at, + backed_up_at + ) + SELECT + id, + title, + planned_end_at, + CURRENT_TIMESTAMP + FROM inspection_visits + ON CONFLICT (visit_id) DO UPDATE SET + title = EXCLUDED.title, + planned_end_at = EXCLUDED.planned_end_at, + backed_up_at = EXCLUDED.backed_up_at + `); + + await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_visits_planned_dates`); + await queryRunner.query(` + ALTER TABLE inspection_visits + DROP CONSTRAINT IF EXISTS chk_inspection_visits_planned_dates + `); + + await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN IF EXISTS planned_end_at`); + await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN IF EXISTS title`); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_inspection_visits_planned_start_at + ON inspection_visits (planned_start_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_visits_planned_start_at`); + + await queryRunner.query(` + ALTER TABLE inspection_visits + ADD COLUMN IF NOT EXISTS title varchar(200) + `); + await queryRunner.query(` + ALTER TABLE inspection_visits + ADD COLUMN IF NOT EXISTS planned_end_at timestamptz + `); + + await queryRunner.query(` + UPDATE inspection_visits visit + SET + title = backup.title, + planned_end_at = backup.planned_end_at + FROM f4_inspection_visit_legacy_fields_backup backup + WHERE backup.visit_id = visit.id + `); + await queryRunner.query(` + UPDATE inspection_visits + SET title = code + WHERE title IS NULL + `); + await queryRunner.query(` + ALTER TABLE inspection_visits + ALTER COLUMN title SET NOT NULL + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_inspection_visits_planned_dates' + AND conrelid = 'inspection_visits'::regclass + ) THEN + ALTER TABLE inspection_visits + ADD CONSTRAINT chk_inspection_visits_planned_dates CHECK ( + planned_start_at IS NULL + OR planned_end_at IS NULL + OR planned_end_at >= planned_start_at + ); + END IF; + END + $$ + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_inspection_visits_planned_dates + ON inspection_visits (planned_start_at, planned_end_at) + `); + + await queryRunner.query(`DROP TABLE IF EXISTS f4_inspection_visit_legacy_fields_backup`); + } +} diff --git a/api-v3/src/database/migrations/1790000600000-f4-align-document-lifecycle-constraints.ts b/api-v3/src/database/migrations/1790000600000-f4-align-document-lifecycle-constraints.ts new file mode 100644 index 0000000..2a6a81b --- /dev/null +++ b/api-v3/src/database/migrations/1790000600000-f4-align-document-lifecycle-constraints.ts @@ -0,0 +1,274 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Alinea las restricciones físicas heredadas de D5 con el ciclo documental F4. + * + * F4 incorpora estados LOCKED/SEALED para Actas y WORKING/OFFICIALIZED para INF. + * También usa el código visible ACT-NNNNN-DD-MM-YY y entrega copia al INSPECTOR. + * Las migraciones históricas conservaban CHECKs, triggers y contratos de D5, por lo + * que el servicio podía compilar pero PostgreSQL rechazaba las transiciones nuevas. + */ +export class F4AlignDocumentLifecycleConstraints1790000600000 implements MigrationInterface { + name = 'F4AlignDocumentLifecycleConstraints1790000600000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_reports + DROP CONSTRAINT IF EXISTS chk_inspection_reports_status + `); + await queryRunner.query(` + ALTER TABLE inspection_reports + ADD CONSTRAINT chk_inspection_reports_status CHECK ( + status IN ('WORKING','OFFICIALIZED','FROZEN','CANCELLED') + ) + `); + await queryRunner.query(` + ALTER TABLE inspection_reports + ALTER COLUMN status SET DEFAULT 'WORKING' + `); + + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT IF EXISTS chk_inspection_acts_status + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT chk_inspection_acts_status CHECK ( + status IN ('DRAFT','LOCKED','SEALED','READY','CLOSED','CANCELLED','RECTIFIED') + ) + `); + + // Conservamos los códigos históricos ACTA-* ya emitidos y habilitamos el + // formato F4 realmente generado por InspectionActsService. + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT IF EXISTS chk_inspection_acts_code + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT chk_inspection_acts_code CHECK ( + code ~ '^(ACTA-[0-9]{4}-[0-9]{6}|ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2})$' + ) + `); + + await queryRunner.query(` + ALTER TABLE inspection_act_versions + DROP CONSTRAINT IF EXISTS chk_inspection_act_versions_event + `); + await queryRunner.query(` + ALTER TABLE inspection_act_versions + ADD CONSTRAINT chk_inspection_act_versions_event CHECK ( + event IN ('CREATED','UPDATED','LOCKED','SEALED','READY','REOPENED','CLOSED','CANCELLED') + ) + `); + + // El cierre físico F4 se representa como SEALED. Conservamos CLOSED como + // compatibilidad histórica y exigimos la misma integridad en ambos estados. + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT IF EXISTS chk_inspection_acts_closure + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT chk_inspection_acts_closure CHECK ( + status NOT IN ('SEALED','CLOSED') + OR ( + closed_at IS NOT NULL + AND closed_by IS NOT NULL + AND closure_sha256 ~ '^[0-9a-f]{64}$' + ) + ) + `); + + // F4 retira Director como destinatario activo y agrega copia al inspector. + // La migración 040 ya eliminó previamente las filas DIRECTOR existentes. + await queryRunner.query(` + ALTER TABLE inspection_document_deliveries + DROP CONSTRAINT IF EXISTS chk_inspection_document_deliveries_recipient_kind + `); + await queryRunner.query(` + ALTER TABLE inspection_document_deliveries + ADD CONSTRAINT chk_inspection_document_deliveries_recipient_kind CHECK ( + recipient_kind IN ('COMPANY','OFFICE','INSPECTOR') + ) + `); + + // Las firmas F4 pertenecen al snapshot LOCKED. El trigger D5 exigía READY. + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE current_status varchar(24); + DECLARE current_sha char(64); + BEGIN + SELECT act.status, closure.prepared_sha256 + INTO current_status, current_sha + FROM inspection_acts act + INNER JOIN inspection_act_closures closure ON closure.act_id = act.id + WHERE act.id = NEW.act_id; + IF current_status <> 'LOCKED' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN + RAISE EXCEPTION 'La firma no corresponde a un acta bloqueada vigente'; + END IF; + RETURN NEW; + END + $$ + `); + + // SEALED es tan inmutable como CLOSED/RECTIFIED en el contrato histórico. + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF OLD.status IN ('SEALED','CLOSED','RECTIFIED') THEN + RAISE EXCEPTION 'El acta sellada es inmutable'; + END IF; + RETURN NEW; + END + $$ + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Primero restauramos la guarda D5. De otro modo el propio trigger F4 impediría + // traducir una fila SEALED a CLOSED durante el rollback. + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF OLD.status IN ('CLOSED','RECTIFIED') THEN + RAISE EXCEPTION 'El acta cerrada es inmutable'; + END IF; + RETURN NEW; + END + $$ + `); + + // Los códigos F4 se traducen antes de convertir SEALED a CLOSED, porque una vez + // restaurada la guarda D5 una fila CLOSED ya no puede modificarse. + await queryRunner.query(` + UPDATE inspection_acts + SET code='ACTA-' || act_year::text || '-' || LPAD(act_number::text, 6, '0') + WHERE code ~ '^ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}$' + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT IF EXISTS chk_inspection_acts_code + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT chk_inspection_acts_code CHECK ( + code ~ '^ACTA-[0-9]{4}-[0-9]{6}$' + ) + `); + + // El contrato D5 no conoce los estados F4. Reducimos las representaciones + // nuevas a sus equivalentes históricos antes de restaurar sus CHECKs. + await queryRunner.query(` + UPDATE inspection_reports + SET status='FROZEN' + WHERE status IN ('WORKING','OFFICIALIZED') + `); + await queryRunner.query(` + ALTER TABLE inspection_reports + ALTER COLUMN status SET DEFAULT 'FROZEN' + `); + await queryRunner.query(` + ALTER TABLE inspection_reports + DROP CONSTRAINT IF EXISTS chk_inspection_reports_status + `); + await queryRunner.query(` + ALTER TABLE inspection_reports + ADD CONSTRAINT chk_inspection_reports_status CHECK ( + status IN ('FROZEN','CANCELLED') + ) + `); + + await queryRunner.query(` + UPDATE inspection_acts + SET status=CASE + WHEN status='LOCKED' THEN 'READY' + WHEN status='SEALED' THEN 'CLOSED' + ELSE status + END + WHERE status IN ('LOCKED','SEALED') + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT IF EXISTS chk_inspection_acts_status + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT chk_inspection_acts_status CHECK ( + status IN ('DRAFT','READY','CLOSED','CANCELLED','RECTIFIED') + ) + `); + + await queryRunner.query(` + UPDATE inspection_act_versions + SET event=CASE + WHEN event='LOCKED' THEN 'READY' + WHEN event='SEALED' THEN 'CLOSED' + ELSE event + END + WHERE event IN ('LOCKED','SEALED') + `); + await queryRunner.query(` + ALTER TABLE inspection_act_versions + DROP CONSTRAINT IF EXISTS chk_inspection_act_versions_event + `); + await queryRunner.query(` + ALTER TABLE inspection_act_versions + ADD CONSTRAINT chk_inspection_act_versions_event CHECK ( + event IN ('CREATED','UPDATED','READY','REOPENED','CLOSED','CANCELLED') + ) + `); + + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT IF EXISTS chk_inspection_acts_closure + `); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT chk_inspection_acts_closure CHECK ( + status <> 'CLOSED' + OR ( + closed_at IS NOT NULL + AND closed_by IS NOT NULL + AND closure_sha256 ~ '^[0-9a-f]{64}$' + ) + ) + `); + + // Conservamos INSPECTOR en el CHECK de rollback para no destruir ni falsificar + // entregas F4 ya auditadas. D5 puede seguir operando con sus tres valores y el + // backup PRE-F4 sigue siendo la fuente de una restauración histórica exacta. + await queryRunner.query(` + ALTER TABLE inspection_document_deliveries + DROP CONSTRAINT IF EXISTS chk_inspection_document_deliveries_recipient_kind + `); + await queryRunner.query(` + ALTER TABLE inspection_document_deliveries + ADD CONSTRAINT chk_inspection_document_deliveries_recipient_kind CHECK ( + recipient_kind IN ('COMPANY','OFFICE','DIRECTOR','INSPECTOR') + ) + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE current_status varchar(24); + DECLARE current_sha char(64); + BEGIN + SELECT act.status, closure.prepared_sha256 + INTO current_status, current_sha + FROM inspection_acts act + INNER JOIN inspection_act_closures closure ON closure.act_id = act.id + WHERE act.id = NEW.act_id; + IF current_status <> 'READY' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN + RAISE EXCEPTION 'La firma no corresponde a un acta preparada vigente'; + END IF; + RETURN NEW; + END + $$ + `); + } +} diff --git a/api-v3/src/database/migrations/1790000700000-f4-align-asset-version-function-change.ts b/api-v3/src/database/migrations/1790000700000-f4-align-asset-version-function-change.ts new file mode 100644 index 0000000..7ff4bfb --- /dev/null +++ b/api-v3/src/database/migrations/1790000700000-f4-align-asset-version-function-change.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F4AlignAssetVersionFunctionChange1790000700000 implements MigrationInterface { + name = 'F4AlignAssetVersionFunctionChange1790000700000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE asset_versions + DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type + `); + await queryRunner.query(` + ALTER TABLE asset_versions + ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN ( + 'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED', 'FUNCTION_CHANGED', + 'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED', + 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED', + 'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED', + 'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED' + )) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE asset_versions + SET change_type='UPDATED' + WHERE change_type='FUNCTION_CHANGED' + `); + await queryRunner.query(` + ALTER TABLE asset_versions + DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type + `); + await queryRunner.query(` + ALTER TABLE asset_versions + ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN ( + 'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED', + 'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED', + 'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED', + 'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED', + 'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED' + )) + `); + } +} diff --git a/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts b/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts index 5ba1ee7..62c9566 100644 --- a/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts +++ b/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts @@ -4,6 +4,7 @@ import { ArrayMinSize, ArrayUnique, IsArray, + IsEnum, IsISO8601, IsOptional, IsString, @@ -11,11 +12,15 @@ import { MaxLength, MinLength, } from 'class-validator'; +import { InspectionActUrgency } from '../../database/entities'; export class CreateInspectionActDto { @IsISO8601({ strict: true }) occurredAt!: string; + @IsEnum(InspectionActUrgency) + urgency!: InspectionActUrgency; + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(1) diff --git a/api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts b/api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts index 7a780aa..af2be32 100644 --- a/api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts +++ b/api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts @@ -4,6 +4,7 @@ import { ArrayMinSize, ArrayUnique, IsArray, + IsEnum, IsISO8601, IsOptional, IsString, @@ -11,12 +12,17 @@ import { MaxLength, MinLength, } from 'class-validator'; +import { InspectionActUrgency } from '../../database/entities'; export class UpdateInspectionActDto { @IsOptional() @IsISO8601({ strict: true }) occurredAt?: string; + @IsOptional() + @IsEnum(InspectionActUrgency) + urgency?: InspectionActUrgency; + @IsOptional() @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() diff --git a/api-v3/src/inspection-acts/inspection-acts.service.ts b/api-v3/src/inspection-acts/inspection-acts.service.ts index 5d89a31..262c44d 100644 --- a/api-v3/src/inspection-acts/inspection-acts.service.ts +++ b/api-v3/src/inspection-acts/inspection-acts.service.ts @@ -14,6 +14,9 @@ import { DocumentSequenceType, InspectionAct, InspectionActStatus, + InspectionActUrgency, + InspectionDeadlineBasis, + InspectionDeadlineDayType, InspectionActVersionEvent, InspectionVisit, InspectionVisitStatus, @@ -41,7 +44,6 @@ interface ActAsset { interface ActVisitSummary { id: string; code: string; - title: string; status: InspectionVisitStatus; actualStartedAt: Date | null; } @@ -72,6 +74,17 @@ export interface InspectionActListItem { title: string; summary: string; observations: string | null; + urgency: InspectionActUrgency; + deadlineDays: number | null; + deadlineDayType: InspectionDeadlineDayType | null; + deadlineBasis: InspectionDeadlineBasis | null; + deadlineBaseAt: Date | null; + deadlineAt: Date | null; + lockedAt: Date | null; + lockedBy: string | null; + lockedSha256: string | null; + sealedAt: Date | null; + sealedBy: string | null; currentVersion: number; cancellationReason: string | null; closedAt: Date | null; @@ -137,7 +150,6 @@ export class InspectionActsService { act.code ILIKE ${search} OR act.title ILIKE ${search} OR visit.code ILIKE ${search} - OR visit.title ILIKE ${search} OR EXISTS ( SELECT 1 FROM inspection_act_assets search_link @@ -313,13 +325,16 @@ export class InspectionActsService { const occurredAt = new Date(dto.occurredAt); const actYear = await this.yearAtProjectTimezone(manager, occurredAt); const actNumber = await this.allocateNumber(manager, actYear); - if (actNumber > 999999) { + if (actNumber > 99999) { throw new ConflictException({ code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED', message: 'La numeración anual de actas agotó su rango disponible', }); } - const code = `ACTA-${actYear}-${String(actNumber).padStart(6, '0')}`; + const [dateRow] = (await manager.query(` + SELECT TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS date_part + `, [occurredAt])) as Array<{ date_part: string }>; + const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`; const act = manager.getRepository(InspectionAct).create({ visitId, actYear, @@ -330,6 +345,17 @@ export class InspectionActsService { title: dto.title, summary: dto.summary, observations: dto.observations ?? null, + urgency: dto.urgency, + deadlineDays: null, + deadlineDayType: null, + deadlineBasis: null, + deadlineBaseAt: null, + deadlineAt: null, + lockedAt: null, + lockedBy: null, + lockedSha256: null, + sealedAt: null, + sealedBy: null, currentVersion: 0, cancellationReason: null, createdBy: principal.userId, @@ -384,6 +410,7 @@ export class InspectionActsService { await this.assertVisitAssets(manager, visit.id, nextAssetIds); const before = await this.loadView(manager, id); if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt; + if (dto.urgency !== undefined) act.urgency = dto.urgency; if (dto.title !== undefined) act.title = dto.title; if (dto.summary !== undefined) act.summary = dto.summary; if (dto.observations !== undefined) act.observations = dto.observations; @@ -462,7 +489,6 @@ export class InspectionActsService { JSONB_BUILD_OBJECT( 'id', visit.id, 'code', visit.code, - 'title', visit.title, 'status', visit.status, 'actualStartedAt', visit.actual_started_at ) AS visit, @@ -474,6 +500,17 @@ export class InspectionActsService { act.title, act.summary, act.observations, + act.urgency, + act.deadline_days AS "deadlineDays", + act.deadline_day_type AS "deadlineDayType", + act.deadline_basis AS "deadlineBasis", + act.deadline_base_at AS "deadlineBaseAt", + act.deadline_at AS "deadlineAt", + act.locked_at AS "lockedAt", + act.locked_by AS "lockedBy", + act.locked_sha256 AS "lockedSha256", + act.sealed_at AS "sealedAt", + act.sealed_by AS "sealedBy", act.current_version AS "currentVersion", act.cancellation_reason AS "cancellationReason", act.closed_at AS "closedAt", @@ -645,7 +682,7 @@ export class InspectionActsService { if (rows.length > 0) { throw new ConflictException({ code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS', - message: 'La inspección ya tiene un acta en borrador; preparala o cancelala antes de crear otra', + message: 'La inspección ya tiene un acta en borrador; finalizala o cancelala antes de crear otra', }); } } @@ -665,7 +702,7 @@ export class InspectionActsService { if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) { throw new BadRequestException({ code: 'INSPECTION_ACT_ASSET_INVALID', - message: 'Cada activo del acta debe estar incluido en la visita', + message: 'Cada inventario del acta debe estar incluido en la inspección', }); } } @@ -758,6 +795,15 @@ export class InspectionActsService { 'title', act.title, 'summary', act.summary, 'observations', act.observations, + 'urgency', act.urgency, + 'deadlineDays', act.deadline_days, + 'deadlineDayType', act.deadline_day_type, + 'deadlineBasis', act.deadline_basis, + 'deadlineBaseAt', act.deadline_base_at, + 'deadlineAt', act.deadline_at, + 'lockedAt', act.locked_at, + 'lockedSha256', act.locked_sha256, + 'sealedAt', act.sealed_at, 'currentVersion', act.current_version, 'cancellationReason', act.cancellation_reason, 'createdBy', act.created_by, @@ -765,7 +811,6 @@ export class InspectionActsService { 'visit', JSONB_BUILD_OBJECT( 'id', visit.id, 'code', visit.code, - 'title', visit.title, 'status', visit.status, 'scopeAssetId', visit.scope_asset_id, 'actualStartedAt', visit.actual_started_at @@ -804,6 +849,15 @@ export class InspectionActsService { title: act.title, summary: act.summary, observations: act.observations, + urgency: act.urgency, + deadlineDays: act.deadlineDays, + deadlineDayType: act.deadlineDayType, + deadlineBasis: act.deadlineBasis, + deadlineBaseAt: act.deadlineBaseAt, + deadlineAt: act.deadlineAt, + lockedAt: act.lockedAt, + lockedSha256: act.lockedSha256, + sealedAt: act.sealedAt, currentVersion: act.currentVersion, cancellationReason: act.cancellationReason, closedAt: act.closedAt, diff --git a/api-v3/src/inspection-closing/company-signature-invite.controller.ts b/api-v3/src/inspection-closing/company-signature-invite.controller.ts new file mode 100644 index 0000000..d1a4b93 --- /dev/null +++ b/api-v3/src/inspection-closing/company-signature-invite.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Req, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { SkipCsrf } from '../auth/decorators/skip-csrf.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { CompanySignatureInviteService } from './company-signature-invite.service'; +import { + CreateCompanySignatureInviteDto, + PublicCompanyRefusalDto, + PublicCompanySignatureDto, +} from './dto/company-signature-invite.dto'; +import { + MAX_INSPECTION_SIGNATURE_BYTES, + type UploadedInspectionSignatureFile, +} from './inspection-signature-file'; + +@Controller('inspection-acts/:actId/company-signature-invitations') +export class CompanySignatureInviteController { + constructor(private readonly invites: CompanySignatureInviteService) {} + + @Post() + @RequirePermissions('inspection_closure.sign') + create( + @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, + @Body() dto: CreateCompanySignatureInviteDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.invites.create(actId, dto, principal, request); + } +} + +@Public() +@SkipCsrf() +@Controller('public/company-signatures') +export class PublicCompanySignatureController { + constructor(private readonly invites: CompanySignatureInviteService) {} + + @Get(':token') + view(@Param('token') token: string) { + return this.invites.view(token); + } + + @Post(':token/sign') + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 }, + })) + sign( + @Param('token') token: string, + @Body() dto: PublicCompanySignatureDto, + @UploadedFile() file: UploadedInspectionSignatureFile | undefined, + @Req() request: RequestWithContext, + ) { + return this.invites.sign(token, dto, file, request); + } + + @Post(':token/refuse') + refuse( + @Param('token') token: string, + @Body() dto: PublicCompanyRefusalDto, + @Req() request: RequestWithContext, + ) { + return this.invites.refuse(token, dto, request); + } +} diff --git a/api-v3/src/inspection-closing/company-signature-invite.service.ts b/api-v3/src/inspection-closing/company-signature-invite.service.ts new file mode 100644 index 0000000..16bc4ab --- /dev/null +++ b/api-v3/src/inspection-closing/company-signature-invite.service.ts @@ -0,0 +1,624 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { mkdir, unlink, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { + ConflictException, + GoneException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + AuditSource, + InspectionActSignatureSource, + InspectionActSignatureStatus, + InspectionActSignerType, + InspectionCompanySignatureManifestation, +} from '../database/entities'; +import { SmtpDeliveryService } from '../inspection-reports/smtp-delivery.service'; +import { sha256CanonicalJson } from './canonical-json'; +import type { + CreateCompanySignatureInviteDto, + PublicCompanyRefusalDto, + PublicCompanySignatureDto, +} from './dto/company-signature-invite.dto'; +import { + inspectInspectionSignatureFile, + type UploadedInspectionSignatureFile, +} from './inspection-signature-file'; + +const REMOTE_CONSENT_VERSION = 'F4-1'; +const REMOTE_COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del Acta y que esta firma se incorpora como constancia de recepción, sin implicar aceptación de los Hallazgos.'; + +interface InviteContext { + id: string; + actId: string; + tokenSha256: string; + recipientEmail: string; + recipientName: string | null; + recipientDocumentType: string | null; + recipientDocumentNumber: string | null; + recipientPosition: string | null; + status: 'PENDING' | 'USED' | 'REVOKED' | 'EXPIRED'; + expiresAt: Date; + sentAt: Date | null; + usedAt: Date | null; + createdBy: string; + actCode: string; + actStatus: string; + lockedSha256: string | null; + lockedAt: Date | null; + inspectionCode: string; + lockedSnapshot: Record | null; +} + +@Injectable() +export class CompanySignatureInviteService { + private readonly signatureRoot: string; + + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly smtp: SmtpDeliveryService, + private readonly config: ConfigService, + ) { + const configured = config.get('INSPECTION_SIGNATURE_ROOT') + ?? '/app/storage/asset-media/inspection-signatures'; + if (!isAbsolute(configured)) throw new Error('INSPECTION_SIGNATURE_ROOT must be an absolute path'); + this.signatureRoot = resolve(configured); + if (this.signatureRoot === parse(this.signatureRoot).root) { + throw new Error('INSPECTION_SIGNATURE_ROOT cannot be the filesystem root'); + } + } + + async create( + actId: string, + dto: CreateCompanySignatureInviteDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const token = randomBytes(32).toString('base64url'); + const tokenSha256 = this.hashToken(token); + const expiresAt = new Date(Date.now() + (dto.expiresInDays ?? 7) * 24 * 60 * 60 * 1000); + + const created = await this.dataSource.transaction(async (manager) => { + await this.assertActorAssigned(manager, actId, principal); + const [context] = await manager.query(` + SELECT + act.id, + act.code, + act.status, + act.locked_sha256 AS "lockedSha256", + responsible.full_name AS "fullName", + responsible.document_type AS "documentType", + responsible.document_number AS "documentNumber", + responsible.position, + responsible.email AS "responsibleEmail", + visit.code AS "inspectionCode", + ( + SELECT profile.notification_email + FROM inspection_act_assets link + JOIN assets inventory ON inventory.id=link.asset_id + JOIN asset_types inventory_type ON inventory_type.id=inventory.asset_type_id + JOIN assets company ON company.id=COALESCE( + inventory.operator_company_id, + CASE WHEN inventory_type.operational_role='COMPANY' THEN inventory.id END + ) + LEFT JOIN organization_profiles profile ON profile.asset_id=company.id + WHERE link.act_id=act.id AND link.included=true + AND profile.notification_email IS NOT NULL + ORDER BY company.id + LIMIT 1 + ) AS "companyEmail" + FROM inspection_acts act + JOIN inspection_visits visit ON visit.id=act.visit_id + LEFT JOIN inspection_act_responsibles responsible ON responsible.act_id=act.id + WHERE act.id=$1 + FOR UPDATE OF act + `, [actId]) as Array<{ + id: string; + code: string; + status: string; + lockedSha256: string | null; + fullName: string | null; + documentType: string | null; + documentNumber: string | null; + position: string | null; + responsibleEmail: string | null; + inspectionCode: string; + companyEmail: string | null; + }>; + if (!context) { + throw new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta no encontrada' }); + } + if (context.status !== 'LOCKED' || !context.lockedSha256) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_INVITE_ACT_NOT_LOCKED', + message: 'La invitación de firma sólo puede emitirse para un Acta BLOQUEADA', + }); + } + if (await this.hasCompanyOutcome(manager, actId)) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED', + message: 'La manifestación de la empresa ya fue registrada', + }); + } + const recipientEmail = dto.recipientEmail + ?? context.responsibleEmail?.toLowerCase() + ?? context.companyEmail?.toLowerCase() + ?? null; + if (!recipientEmail) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_EMAIL_REQUIRED', + message: 'No hay email de responsable ni email institucional de empresa; indicá un destinatario', + }); + } + + await manager.query(` + UPDATE inspection_act_company_signature_invites + SET status='REVOKED',revoked_at=CURRENT_TIMESTAMP,revoked_by=$2,updated_at=CURRENT_TIMESTAMP + WHERE act_id=$1 AND status='PENDING' + `, [actId, principal.userId]); + + const [invite] = await manager.query(` + INSERT INTO inspection_act_company_signature_invites( + act_id,token_sha256,recipient_email,recipient_name,recipient_document_type, + recipient_document_number,recipient_position,expires_at,created_by + ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING id,act_id AS "actId",recipient_email AS "recipientEmail", + expires_at AS "expiresAt",created_at AS "createdAt" + `, [ + actId, + tokenSha256, + recipientEmail, + context.fullName, + context.documentType, + context.documentNumber, + context.position, + expiresAt, + principal.userId, + ]) as Array<{ + id: string; + actId: string; + recipientEmail: string; + expiresAt: Date; + createdAt: Date; + }>; + + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: 'INSPECTION_ACT_COMPANY_SIGNATURE_INVITE_CREATED', + entityType: 'inspection_act_company_signature_invite', + entityId: invite.id, + afterData: { + actId, + actCode: context.code, + recipientEmail, + expiresAt, + }, + metadata: { tokenStoredAsHashOnly: true }, + }, manager); + return { ...invite, actCode: context.code, inspectionCode: context.inspectionCode }; + }); + + const publicBase = this.config.get('COMPANY_SIGNATURE_PUBLIC_BASE_URL')?.trim().replace(/\/$/, '') ?? null; + const publicUrl = publicBase ? `${publicBase}?token=${encodeURIComponent(token)}` : null; + let emailSent = false; + let deliveryError: string | null = null; + + if (!publicUrl) { + deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada'; + } else if (!(await this.smtp.configured())) { + deliveryError = 'SMTP no configurado'; + } else { + const body = [ + `Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`, + `Inspección: ${created.inspectionCode}.`, + '', + 'El enlace permite firmar en conformidad, firmar en disidencia o registrar una negativa a firmar.', + 'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.', + '', + `Enlace seguro: ${publicUrl}`, + `Válido hasta: ${new Date(created.expiresAt).toLocaleString('es-AR')}`, + ].join('\n'); + try { + await this.smtp.send({ + to: created.recipientEmail, + subject: `DH Inspección · Firma de ${created.actCode}`, + text: body, + attachment: { + filename: `${created.actCode}-instrucciones.txt`, + mimeType: 'text/plain', + content: Buffer.from(body, 'utf8'), + }, + }); + emailSent = true; + await this.dataSource.query(` + UPDATE inspection_act_company_signature_invites + SET sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [created.id]); + } catch (error) { + deliveryError = error instanceof Error ? error.message.slice(0, 500) : 'No se pudo enviar el email'; + } + } + + return { + id: created.id, + actId: created.actId, + actCode: created.actCode, + recipientEmail: created.recipientEmail, + expiresAt: created.expiresAt, + emailSent, + deliveryError, + publicUrl, + }; + } + + async view(token: string) { + const invite = await this.resolveToken(this.dataSource.manager, token, false); + const locked = invite.lockedSnapshot ?? {}; + const act = this.record(locked.act); + const inventories = this.records(locked.inventories); + const findings = this.records(locked.findings).map((finding) => ({ + id: finding.id, + code: finding.code, + title: finding.title, + description: finding.description, + legalBasis: finding.legalBasis ?? null, + severity: finding.severity ?? null, + isRecurrence: finding.isRecurrence === true, + recurrenceOfFindingId: finding.recurrenceOfFindingId ?? null, + })); + return { + invitation: { + id: invite.id, + recipientEmail: invite.recipientEmail, + expiresAt: invite.expiresAt, + }, + act: { + code: invite.actCode, + inspectionCode: invite.inspectionCode, + lockedAt: invite.lockedAt, + lockedSha256: invite.lockedSha256, + urgency: act.urgency ?? null, + summary: act.summary ?? null, + observations: act.observations ?? null, + }, + responsibleDefaults: { + fullName: invite.recipientName, + documentType: invite.recipientDocumentType, + documentNumber: invite.recipientDocumentNumber, + position: invite.recipientPosition, + }, + inventories: inventories.map((inventory) => ({ + id: inventory.id, + code: inventory.code, + name: inventory.name, + typeName: inventory.typeName ?? inventory.typeCode ?? null, + })), + findings, + consent: REMOTE_COMPANY_CONSENT, + allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'], + }; + } + + async sign( + token: string, + dto: PublicCompanySignatureDto, + file: UploadedInspectionSignatureFile | undefined, + request: RequestWithContext, + ) { + if (!dto.consentAccepted) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_CONSENT_REQUIRED', + message: 'Debe aceptarse la constancia antes de firmar', + }); + } + const inspected = inspectInspectionSignatureFile(file); + const id = randomUUID(); + const storedName = `${id}.png`; + const filePath = resolve(this.signatureRoot, storedName); + const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex'); + await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); + + try { + const result = await this.dataSource.transaction(async (manager) => { + const invite = await this.resolveToken(manager, token, true); + await this.assertNoCompanyOutcome(manager, invite.actId); + const signedAt = new Date(); + const manifestation = dto.manifestation === 'DISSENT' + ? InspectionCompanySignatureManifestation.DISSENT + : InspectionCompanySignatureManifestation.CONFORMITY; + const statement = manifestation === InspectionCompanySignatureManifestation.DISSENT + ? dto.statement?.trim() ?? null + : null; + const payload = { + invitationId: invite.id, + actId: invite.actId, + lockedSha256: invite.lockedSha256, + signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, + signerName: dto.fullName, + documentType: dto.documentType, + documentNumber: dto.documentNumber, + position: dto.position, + status: InspectionActSignatureStatus.SIGNED, + companyManifestation: manifestation, + companyStatement: statement, + imageSha256, + consentText: REMOTE_COMPANY_CONSENT, + consentVersion: REMOTE_CONSENT_VERSION, + signedAt: signedAt.toISOString(), + source: InspectionActSignatureSource.WEB, + }; + const signaturePayloadSha256 = sha256CanonicalJson(payload); + await manager.query(` + INSERT INTO inspection_act_signatures( + id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number, + position,status,company_manifestation,company_statement,original_name,stored_name, + mime_type,size_bytes,image_sha256,consent_text,consent_version,consent_accepted_at, + signed_at,device_label,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at + ) VALUES( + $1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'SIGNED',$7,$8,$9,$10,$11,$12,$13, + $14,$15,$16,$16,'Firma remota por enlace seguro','WEB',$17,$18,$19,$16 + ) + `, [ + id, + invite.actId, + dto.fullName, + dto.documentType, + dto.documentNumber, + dto.position, + manifestation, + statement, + inspected.originalName, + storedName, + inspected.mimeType, + file!.buffer.length, + imageSha256, + REMOTE_COMPANY_CONSENT, + REMOTE_CONSENT_VERSION, + signedAt, + invite.lockedSha256, + signaturePayloadSha256, + invite.createdBy, + ]); + await manager.query(` + UPDATE inspection_act_company_signature_invites + SET status='USED',used_at=$2,updated_at=$2 + WHERE id=$1 + `, [invite.id, signedAt]); + await this.audit.record({ + action: 'INSPECTION_ACT_COMPANY_REMOTE_SIGNATURE_RECORDED', + entityType: 'inspection_act_signature', + entityId: id, + source: AuditSource.WEB, + actorUserId: null, + actorUsername: null, + requestId: request.requestId, + ip: request.ip ?? null, + userAgent: request.get('user-agent') ?? null, + afterData: payload, + metadata: { + invitationId: invite.id, + recipientEmail: invite.recipientEmail, + immutable: true, + signaturePayloadSha256, + }, + }, manager); + return { actCode: invite.actCode, signatureId: id, manifestation }; + }); + return { ok: true, ...result }; + } catch (error) { + await unlink(filePath).catch(() => undefined); + throw error; + } + } + + async refuse( + token: string, + dto: PublicCompanyRefusalDto, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const invite = await this.resolveToken(manager, token, true); + await this.assertNoCompanyOutcome(manager, invite.actId); + const createdAt = new Date(); + const id = randomUUID(); + const payload = { + invitationId: invite.id, + actId: invite.actId, + lockedSha256: invite.lockedSha256, + signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, + signerName: dto.fullName, + documentType: dto.documentType, + documentNumber: dto.documentNumber, + position: dto.position, + status: InspectionActSignatureStatus.REFUSED, + reason: dto.reason, + source: InspectionActSignatureSource.WEB, + createdAt: createdAt.toISOString(), + }; + const signaturePayloadSha256 = sha256CanonicalJson(payload); + await manager.query(` + INSERT INTO inspection_act_signatures( + id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number, + position,status,reason,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at + ) VALUES($1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'REFUSED',$7,'WEB',$8,$9,$10,$11) + `, [ + id, + invite.actId, + dto.fullName, + dto.documentType, + dto.documentNumber, + dto.position, + dto.reason, + invite.lockedSha256, + signaturePayloadSha256, + invite.createdBy, + createdAt, + ]); + await manager.query(` + UPDATE inspection_act_company_signature_invites + SET status='USED',used_at=$2,updated_at=$2 + WHERE id=$1 + `, [invite.id, createdAt]); + await this.audit.record({ + action: 'INSPECTION_ACT_COMPANY_REMOTE_REFUSAL_RECORDED', + entityType: 'inspection_act_signature', + entityId: id, + source: AuditSource.WEB, + actorUserId: null, + actorUsername: null, + requestId: request.requestId, + ip: request.ip ?? null, + userAgent: request.get('user-agent') ?? null, + afterData: payload, + metadata: { + invitationId: invite.id, + recipientEmail: invite.recipientEmail, + immutable: true, + signaturePayloadSha256, + }, + }, manager); + return { ok: true, actCode: invite.actCode, refusalId: id }; + }); + } + + private async resolveToken( + manager: EntityManager, + token: string, + lock: boolean, + ): Promise { + if (!/^[A-Za-z0-9_-]{40,120}$/.test(token)) throw this.invalidInvite(); + const tokenSha256 = this.hashToken(token); + const [invite] = await manager.query(` + SELECT + invite.id, + invite.act_id AS "actId", + invite.token_sha256 AS "tokenSha256", + invite.recipient_email AS "recipientEmail", + invite.recipient_name AS "recipientName", + invite.recipient_document_type AS "recipientDocumentType", + invite.recipient_document_number AS "recipientDocumentNumber", + invite.recipient_position AS "recipientPosition", + invite.status, + invite.expires_at AS "expiresAt", + invite.sent_at AS "sentAt", + invite.used_at AS "usedAt", + invite.created_by AS "createdBy", + act.code AS "actCode", + act.status AS "actStatus", + act.locked_sha256 AS "lockedSha256", + act.locked_at AS "lockedAt", + visit.code AS "inspectionCode", + closure.prepared_snapshot AS "lockedSnapshot" + FROM inspection_act_company_signature_invites invite + JOIN inspection_acts act ON act.id=invite.act_id + JOIN inspection_visits visit ON visit.id=act.visit_id + JOIN inspection_act_closures closure ON closure.act_id=act.id + WHERE invite.token_sha256=$1 + ${lock ? 'FOR UPDATE OF invite,act' : ''} + `, [tokenSha256]) as InviteContext[]; + if (!invite) throw this.invalidInvite(); + if (invite.status !== 'PENDING') { + throw new GoneException({ + code: 'COMPANY_SIGNATURE_INVITE_NOT_ACTIVE', + message: invite.status === 'USED' + ? 'Este enlace ya fue utilizado' + : 'Este enlace ya no está vigente', + }); + } + if (new Date(invite.expiresAt).getTime() <= Date.now()) { + await manager.query(` + UPDATE inspection_act_company_signature_invites + SET status='EXPIRED',updated_at=CURRENT_TIMESTAMP + WHERE id=$1 AND status='PENDING' + `, [invite.id]); + throw new GoneException({ + code: 'COMPANY_SIGNATURE_INVITE_EXPIRED', + message: 'El enlace de firma venció. Solicitá una nueva invitación.', + }); + } + if (invite.actStatus !== 'LOCKED' || !invite.lockedSha256 || !invite.lockedSnapshot) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_ACT_NOT_AVAILABLE', + message: 'El Acta ya no está disponible para manifestación remota', + }); + } + return invite; + } + + private async assertActorAssigned( + manager: EntityManager, + actId: string, + principal: AuthPrincipal, + ): Promise { + if (principal.permissions.includes('inspections.manage')) return; + const [row] = await manager.query(` + SELECT 1 + FROM inspection_acts act + JOIN inspection_visit_members member ON member.visit_id=act.visit_id + WHERE act.id=$1 AND member.user_id=$2 AND member.included=true + LIMIT 1 + `, [actId, principal.userId]) as unknown[]; + if (!row) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_INVITE_NOT_ASSIGNED', + message: 'Sólo un Inspector asignado puede emitir la invitación', + }); + } + } + + private async assertNoCompanyOutcome(manager: EntityManager, actId: string): Promise { + if (await this.hasCompanyOutcome(manager, actId)) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED', + message: 'La manifestación de la empresa ya fue registrada', + }); + } + } + + private async hasCompanyOutcome(manager: EntityManager, actId: string): Promise { + const rows = await manager.query(` + SELECT 1 FROM inspection_act_signatures + WHERE act_id=$1 AND signer_type='COMPANY_RESPONSIBLE' + LIMIT 1 + `, [actId]) as unknown[]; + return rows.length > 0; + } + + private hashToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); + } + + private invalidInvite(): NotFoundException { + return new NotFoundException({ + code: 'COMPANY_SIGNATURE_INVITE_NOT_FOUND', + message: 'El enlace de firma no es válido', + }); + } + + private record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; + } + + private records(value: unknown): Array> { + return Array.isArray(value) ? value.map((item) => this.record(item)) : []; + } + + private storageError(): InternalServerErrorException { + return new InternalServerErrorException({ + code: 'COMPANY_SIGNATURE_STORAGE_ERROR', + message: 'No se pudo almacenar la firma remota', + }); + } +} diff --git a/api-v3/src/inspection-closing/dto/company-signature-invite.dto.ts b/api-v3/src/inspection-closing/dto/company-signature-invite.dto.ts new file mode 100644 index 0000000..110b4e1 --- /dev/null +++ b/api-v3/src/inspection-closing/dto/company-signature-invite.dto.ts @@ -0,0 +1,101 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsEmail, + IsIn, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; + +export class CreateCompanySignatureInviteDto { + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined) + @IsEmail() + @MaxLength(320) + recipientEmail?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(30) + expiresInDays?: number; +} + +export class PublicCompanySignatureDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(200) + fullName!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value) + @IsString() + @IsIn(['DNI', 'CUIL', 'PASSPORT', 'OTHER']) + documentType!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(3) + @MaxLength(40) + documentNumber!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(200) + position!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value) + @IsString() + @IsIn(['CONFORMITY', 'DISSENT']) + manifestation!: 'CONFORMITY' | 'DISSENT'; + + @ValidateIf((value: PublicCompanySignatureDto) => value.manifestation === 'DISSENT') + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(10) + @MaxLength(8000) + statement?: string; + + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + consentAccepted!: boolean; +} + +export class PublicCompanyRefusalDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(200) + fullName!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value) + @IsString() + @IsIn(['DNI', 'CUIL', 'PASSPORT', 'OTHER']) + documentType!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(3) + @MaxLength(40) + documentNumber!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(200) + position!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(10) + @MaxLength(8000) + reason!: string; +} diff --git a/api-v3/src/inspection-closing/inspection-closing.controller.ts b/api-v3/src/inspection-closing/inspection-closing.controller.ts index 14e0663..ef36886 100644 --- a/api-v3/src/inspection-closing/inspection-closing.controller.ts +++ b/api-v3/src/inspection-closing/inspection-closing.controller.ts @@ -48,9 +48,9 @@ export class InspectionClosingController { return this.closing.upsertResponsible(actId, dto, principal, request); } - @Post('ready') + @Post('lock') @RequirePermissions('inspection_closure.prepare') - ready( + lock( @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, @@ -58,16 +58,6 @@ export class InspectionClosingController { return this.closing.prepare(actId, principal, request); } - @Post('reopen') - @RequirePermissions('inspection_closure.prepare') - reopen( - @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.closing.reopen(actId, principal, request); - } - @Post('signatures/inspector') @RequirePermissions('inspection_closure.sign') @UseInterceptors(FileInterceptor('file', { @@ -109,9 +99,9 @@ export class InspectionClosingController { return this.closing.recordCompanyOutcome(actId, dto, principal, request); } - @Post('close') + @Post('seal') @RequirePermissions('inspection_closure.close') - close( + seal( @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CloseInspectionActDto, @CurrentAuth() principal: AuthPrincipal, diff --git a/api-v3/src/inspection-closing/inspection-closing.module.ts b/api-v3/src/inspection-closing/inspection-closing.module.ts index 4ea68b5..8a264e6 100644 --- a/api-v3/src/inspection-closing/inspection-closing.module.ts +++ b/api-v3/src/inspection-closing/inspection-closing.module.ts @@ -1,6 +1,11 @@ import { Module } from '@nestjs/common'; import { AuditModule } from '../audit/audit.module'; import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module'; +import { + CompanySignatureInviteController, + PublicCompanySignatureController, +} from './company-signature-invite.controller'; +import { CompanySignatureInviteService } from './company-signature-invite.service'; import { InspectionClosingController, InspectionSignatureContentController, @@ -9,7 +14,12 @@ import { InspectionClosingService } from './inspection-closing.service'; @Module({ imports: [AuditModule, InspectionReportsModule], - controllers: [InspectionClosingController, InspectionSignatureContentController], - providers: [InspectionClosingService], + controllers: [ + InspectionClosingController, + InspectionSignatureContentController, + CompanySignatureInviteController, + PublicCompanySignatureController, + ], + providers: [InspectionClosingService, CompanySignatureInviteService], }) export class InspectionClosingModule {} diff --git a/api-v3/src/inspection-closing/inspection-closing.service.ts b/api-v3/src/inspection-closing/inspection-closing.service.ts index be3c131..be1c906 100644 --- a/api-v3/src/inspection-closing/inspection-closing.service.ts +++ b/api-v3/src/inspection-closing/inspection-closing.service.ts @@ -14,7 +14,6 @@ import { DataSource, EntityManager } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import { AuditAction, InspectionAct, @@ -23,28 +22,32 @@ import { InspectionActSignerType, InspectionCompanySignatureManifestation, InspectionActStatus, + InspectionActUrgency, + InspectionDeadlineBasis, + InspectionDeadlineDayType, InspectionActUploadMode, InspectionActVersionEvent, InspectionResponsibleAttendanceStatus, InspectionVisit, InspectionVisitStatus, } from '../database/entities'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import { sha256CanonicalJson } from './canonical-json'; import type { CloseInspectionActDto } from './dto/close-inspection-act.dto'; -import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; -import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; +import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import type { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto'; import { inspectInspectionSignatureFile, type UploadedInspectionSignatureFile, } from './inspection-signature-file'; -const CLOSURE_SCHEMA_VERSION = 'DH-ACT-CLOSURE-V2'; -const CONSENT_VERSION = 'D5-1'; -const INSPECTOR_CONSENT = 'Declaro que esta firma manuscrita fue realizada por mí y se incorpora al acta como constancia de mi intervención y conformidad con su contenido.'; -const COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del acta y que esta firma manuscrita se incorpora como constancia de recepción, sin implicar aceptación de los hallazgos.'; +const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V4'; +const CONSENT_VERSION = 'F4-1'; +const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.'; +const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.'; interface ResponsibleView { actId: string; @@ -114,12 +117,28 @@ interface ClosureRecord { closedBy: string | null; } +interface DeadlinePolicy { + urgentDays: number; + urgentDayType: InspectionDeadlineDayType; + nonUrgentDays: number; + nonUrgentDayType: InspectionDeadlineDayType; +} + export interface InspectionClosureView { act: { id: string; code: string; status: InspectionActStatus; visitId: string; + urgency: InspectionActUrgency; + deadlineDays: number | null; + deadlineDayType: InspectionDeadlineDayType | null; + deadlineBasis: InspectionDeadlineBasis | null; + deadlineBaseAt: Date | null; + deadlineAt: Date | null; + lockedAt: Date | null; + lockedSha256: string | null; + sealedAt: Date | null; currentVersion: number; closedAt: Date | null; closedBy: string | null; @@ -208,17 +227,18 @@ export class InspectionClosingService { INSERT INTO inspection_act_responsibles ( act_id, attendance_status, full_name, document_type, document_number, position, email, phone, absence_reason, updated_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (act_id) DO UPDATE SET - attendance_status = EXCLUDED.attendance_status, - full_name = EXCLUDED.full_name, - document_type = EXCLUDED.document_type, - document_number = EXCLUDED.document_number, - position = EXCLUDED.position, - email = EXCLUDED.email, - phone = EXCLUDED.phone, - absence_reason = EXCLUDED.absence_reason, - updated_by = EXCLUDED.updated_by + attendance_status=EXCLUDED.attendance_status, + full_name=EXCLUDED.full_name, + document_type=EXCLUDED.document_type, + document_number=EXCLUDED.document_number, + position=EXCLUDED.position, + email=EXCLUDED.email, + phone=EXCLUDED.phone, + absence_reason=EXCLUDED.absence_reason, + updated_by=EXCLUDED.updated_by, + updated_at=CURRENT_TIMESTAMP `, [ actId, dto.attendanceStatus, @@ -239,12 +259,13 @@ export class InspectionClosingService { entityId: actId, beforeData: before as unknown as Record | null, afterData: after as unknown as Record, - metadata: { actId, visitId: visit.id }, + metadata: { actId, inspectionId: visit.id }, }, manager); return this.loadView(manager, actId); }); } + /** Finaliza el contenido: desde este punto el Acta queda inmutable. */ async prepare( actId: string, principal: AuthPrincipal, @@ -256,157 +277,126 @@ export class InspectionClosingService { this.assertDraftInProgress(act, visit); await this.assertActorAssigned(manager, visit.id, principal, true); await this.requireResponsible(manager, actId); - const [count] = (await manager.query(` - SELECT COUNT(*)::integer AS total - FROM inspection_findings - WHERE act_id = $1 AND status <> 'VOIDED' - `, [actId])) as Array<{ total: number }>; - const [verification] = (await manager.query(` - SELECT - COUNT(*)::integer AS total, - COUNT(*) FILTER (WHERE verification_link.outcome IS NOT NULL)::integer AS completed - FROM inspection_finding_verification_visits verification_link - WHERE verification_link.visit_id = $1 - `, [visit.id])) as Array<{ total: number; completed: number }>; - const verificationTotal = Number(verification?.total ?? 0); - const verificationCompleted = Number(verification?.completed ?? 0); - if (verificationTotal > 0 && verificationCompleted !== verificationTotal) { - throw new ConflictException({ - code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED', - message: 'Registrá el resultado de todos los hallazgos a verificar antes de preparar el acta', - }); - } - if (Number(count?.total ?? 0) < 1 && verificationTotal < 1) { - throw new ConflictException({ - code: 'INSPECTION_ACT_FINDING_REQUIRED', - message: 'El acta debe contener al menos un hallazgo o una verificación registrada antes de prepararse', - }); - } - const signatures = await this.signatureCount(manager, actId); - if (signatures > 0) { + if (await this.signatureCount(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_ALREADY_SIGNED', - message: 'El acta ya tiene firmas y no puede volver a prepararse', + message: 'El acta ya tiene constancias de firma y no puede volver a finalizarse', }); } + + const policy = await this.deadlinePolicy(manager); + const deadlineDays = act.urgency === InspectionActUrgency.URGENT + ? policy.urgentDays + : policy.nonUrgentDays; + const deadlineDayType = act.urgency === InspectionActUrgency.URGENT + ? policy.urgentDayType + : policy.nonUrgentDayType; + const deadlineBasis = act.urgency === InspectionActUrgency.URGENT + ? InspectionDeadlineBasis.ACT_DATE + : InspectionDeadlineBasis.GEDO_DATE; + const deadlineBaseAt = act.urgency === InspectionActUrgency.URGENT + ? act.occurredAt + : null; + const deadlineAt = deadlineBaseAt + ? await this.calculateDeadline(manager, deadlineBaseAt, deadlineDays, deadlineDayType) + : null; + const lockedAt = new Date(); + const [updated] = (await manager.query(` UPDATE inspection_acts - SET status = 'READY', - current_version = current_version + 1, - updated_by = $2, - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 + SET status='LOCKED', + deadline_days=$2, + deadline_day_type=$3, + deadline_basis=$4, + deadline_base_at=$5, + deadline_at=$6, + locked_at=$7, + locked_by=$8, + current_version=current_version+1, + updated_by=$8, + updated_at=$7 + WHERE id=$1 RETURNING current_version AS "versionNumber" - `, [actId, principal.userId])) as Array<{ versionNumber: number }>; - const preparedAt = new Date(); - const preparedSnapshot = await this.buildPreparedSnapshot(manager, actId, preparedAt); - const preparedSha256 = sha256CanonicalJson(preparedSnapshot); - await manager.query(` - INSERT INTO inspection_act_closures ( - act_id, schema_version, prepared_snapshot, prepared_sha256, - prepared_at, prepared_by - ) VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (act_id) DO UPDATE SET - schema_version = EXCLUDED.schema_version, - prepared_snapshot = EXCLUDED.prepared_snapshot, - prepared_sha256 = EXCLUDED.prepared_sha256, - prepared_at = EXCLUDED.prepared_at, - prepared_by = EXCLUDED.prepared_by `, [ actId, - CLOSURE_SCHEMA_VERSION, - preparedSnapshot, - preparedSha256, - preparedAt, + deadlineDays, + deadlineDayType, + deadlineBasis, + deadlineBaseAt, + deadlineAt, + lockedAt, principal.userId, - ]); + ])) as Array<{ versionNumber: number }>; + + const preparedSnapshot = await this.buildLockedSnapshot(manager, actId, lockedAt); + const preparedSha256 = sha256CanonicalJson(preparedSnapshot); + await manager.query(` + UPDATE inspection_acts + SET locked_sha256=$2 + WHERE id=$1 + `, [actId, preparedSha256]); + await manager.query(` + INSERT INTO inspection_act_closures ( + act_id,schema_version,prepared_snapshot,prepared_sha256,prepared_at,prepared_by + ) VALUES ($1,$2,$3,$4,$5,$6) + ON CONFLICT (act_id) DO UPDATE SET + schema_version=EXCLUDED.schema_version, + prepared_snapshot=EXCLUDED.prepared_snapshot, + prepared_sha256=EXCLUDED.prepared_sha256, + prepared_at=EXCLUDED.prepared_at, + prepared_by=EXCLUDED.prepared_by, + final_snapshot=NULL, + final_sha256=NULL, + device_closed_at=NULL, + server_closed_at=NULL, + upload_mode=NULL, + closed_by=NULL + `, [actId, CLOSURE_SCHEMA_VERSION, preparedSnapshot, preparedSha256, lockedAt, principal.userId]); await manager.query(` INSERT INTO inspection_act_versions ( - act_id, version_number, event, snapshot, actor_user_id, actor_username - ) VALUES ($1, $2, $3, $4, $5, $6) + act_id,version_number,event,snapshot,actor_user_id,actor_username + ) VALUES ($1,$2,$3,$4,$5,$6) `, [ actId, Number(updated.versionNumber), - InspectionActVersionEvent.READY, + InspectionActVersionEvent.LOCKED, preparedSnapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_ACT_READY, + action: AuditAction.INSPECTION_ACT_LOCKED, entityType: 'inspection_act', entityId: actId, afterData: { - status: InspectionActStatus.READY, - preparedSha256, - schemaVersion: CLOSURE_SCHEMA_VERSION, + status: InspectionActStatus.LOCKED, + lockedSha256: preparedSha256, + urgency: act.urgency, + deadlineDays, + deadlineDayType, + deadlineBasis, + deadlineBaseAt, + deadlineAt, }, - metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) }, + metadata: { actId, inspectionId: visit.id, versionNumber: Number(updated.versionNumber) }, }, manager); return this.loadView(manager, actId); }); } + /** + * F4 elimina la reapertura normal: Finalizar Acta significa inmovilizar su contenido. + * Una eventual rectificación administrativa debe ser otro acto auditado, no volver a DRAFT. + */ async reopen( - actId: string, - principal: AuthPrincipal, - request: RequestWithContext, + _actId: string, + _principal: AuthPrincipal, + _request: RequestWithContext, ): Promise { - assertMobileInspector(principal); - return this.dataSource.transaction(async (manager) => { - const { act, visit } = await this.lockContext(manager, actId); - if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) { - throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_READY', - message: 'Sólo puede reabrirse un acta preparada de una visita en curso', - }); - } - await this.assertActorAssigned(manager, visit.id, principal, true); - if (await this.signatureCount(manager, actId)) { - throw new ConflictException({ - code: 'INSPECTION_ACT_SIGNED_CANNOT_REOPEN', - message: 'El acta ya tiene firmas y no puede volver a borrador', - }); - } - const closure = await this.requireClosure(manager, actId); - const [updated] = (await manager.query(` - UPDATE inspection_acts - SET status = 'DRAFT', - current_version = current_version + 1, - updated_by = $2, - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - RETURNING current_version AS "versionNumber" - `, [actId, principal.userId])) as Array<{ versionNumber: number }>; - const snapshot = { - actId, - actCode: act.code, - status: InspectionActStatus.DRAFT, - reopenedAt: new Date().toISOString(), - previousPreparedSha256: closure.preparedSha256, - }; - await manager.query(` - INSERT INTO inspection_act_versions ( - act_id, version_number, event, snapshot, actor_user_id, actor_username - ) VALUES ($1, $2, $3, $4, $5, $6) - `, [ - actId, - Number(updated.versionNumber), - InspectionActVersionEvent.REOPENED, - snapshot, - principal.userId, - principal.username, - ]); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_ACT_REOPENED, - entityType: 'inspection_act', - entityId: actId, - afterData: snapshot, - metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) }, - }, manager); - return this.loadView(manager, actId); + throw new ConflictException({ + code: 'INSPECTION_ACT_LOCK_IS_IMMUTABLE', + message: 'Un acta finalizada es inmutable y no puede volver a borrador', }); } @@ -418,17 +408,22 @@ export class InspectionClosingService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - const identity = { - signerType: InspectionActSignerType.INSPECTOR, - signerUserId: principal.userId, - signerName: `${principal.firstName} ${principal.lastName}`.trim(), - documentType: null, - documentNumber: null, - position: 'Inspector/a', - consentText: INSPECTOR_CONSENT, - auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, - } as const; - return this.createSignedSignature(actId, dto, file, identity, principal, request, true); + return this.createSignedSignature( + actId, + dto, + file, + { + signerType: InspectionActSignerType.INSPECTOR, + signerUserId: principal.userId, + signerName: `${principal.firstName} ${principal.lastName}`.trim(), + documentType: null, + documentNumber: null, + position: 'Inspector/a', + consentText: INSPECTOR_CONSENT, + }, + principal, + request, + ); } async signCompany( @@ -439,7 +434,7 @@ export class InspectionClosingService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - return this.createSignedSignature(actId, dto, file, null, principal, request, false); + return this.createSignedSignature(actId, dto, file, null, principal, request); } async recordCompanyOutcome( @@ -451,40 +446,29 @@ export class InspectionClosingService { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - this.assertReadyForCompanyOutcome(act, visit); + this.assertLockedForManifestation(act); await this.assertActorAssigned(manager, visit.id, principal, true); const responsible = await this.requireResponsible(manager, actId); - if ( - dto.status === InspectionActSignatureStatus.REFUSED - && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT - ) { + if (dto.status === InspectionActSignatureStatus.REFUSED + && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT) { throw new BadRequestException({ code: 'INSPECTION_COMPANY_REFUSAL_REQUIRES_PRESENCE', - message: 'La negativa a firmar requiere que el responsable esté presente', + 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_MISMATCH', - message: 'El resultado ausente requiere que el responsable se haya registrado como ausente', - }); - } - const closure = await this.requireClosure(manager, actId); if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', - message: 'La recepción de la empresa ya fue registrada y es inmutable', + message: 'La manifestación de la empresa ya fue registrada y es inmutable', }); } - const source = this.signatureSource(principal); + const closure = await this.requireClosure(manager, actId); const createdAt = new Date(); - const signerName = responsible.fullName ?? 'Responsable de la empresa no presente'; + const source = this.signatureSource(principal); + const signerName = responsible.fullName ?? 'Responsable no presente'; const payload = { actId, - preparedSha256: closure.preparedSha256, + lockedSha256: closure.preparedSha256, signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, signerName, documentType: responsible.documentType, @@ -493,20 +477,17 @@ export class InspectionClosingService { status: dto.status, reason: dto.reason, source, - uploadedBy: principal.userId, + recordedBy: principal.userId, createdAt: createdAt.toISOString(), }; const signaturePayloadSha256 = sha256CanonicalJson(payload); const id = randomUUID(); await manager.query(` INSERT INTO inspection_act_signatures ( - id, act_id, signer_type, signer_user_id, signer_name, - document_type, document_number, position, status, reason, - source, prepared_sha256, signature_payload_sha256, uploaded_by, created_at - ) VALUES ( - $1, $2, $3, NULL, $4, $5, $6, $7, $8, $9, - $10, $11, $12, $13, $14 - ) + id,act_id,signer_type,signer_user_id,signer_name, + document_type,document_number,position,status,reason, + source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at + ) VALUES ($1,$2,$3,NULL,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) `, [ id, actId, @@ -529,12 +510,16 @@ export class InspectionClosingService { entityType: 'inspection_act_signature', entityId: id, afterData: payload, - metadata: { actId, visitId: visit.id, immutable: true, signaturePayloadSha256 }, + metadata: { actId, inspectionId: visit.id, immutable: true }, }, manager); return this.loadView(manager, actId); }); } + /** + * Endpoint legacy /close conservado durante la transición de contratos. + * En F4 su semántica es SELLAR, no volver editable ni cerrar la Inspección. + */ async close( actId: string, dto: CloseInspectionActDto, @@ -542,30 +527,35 @@ export class InspectionClosingService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - const closed = await this.dataSource.transaction(async (manager) => { + const sealed = await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - this.assertReadyForFinalClosure(act, visit); + 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); - if (!signatures.some((item) => ( + const inspectorSigned = signatures.some((item) => item.signerType === InspectionActSignerType.INSPECTOR - && item.status === InspectionActSignatureStatus.SIGNED - ))) { + && item.status === InspectionActSignatureStatus.SIGNED, + ); + if (!inspectorSigned) { throw new ConflictException({ code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED', - message: 'Se requiere al menos una firma de inspector para cerrar el acta', + message: 'Se requiere la firma del inspector para sellar el acta', }); } - if (signatures.filter((item) => item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE).length !== 1) { + const companyOutcomes = signatures.filter((item) => + item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE, + ); + if (companyOutcomes.length !== 1) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED', - message: 'Debe registrarse la firma, negativa o ausencia del responsable de la empresa', + message: 'Debe existir exactamente una firma, disidencia, negativa o ausencia documentada del responsable de la empresa', }); } - const serverClosedAt = new Date(); - const deviceClosedAt = new Date(dto.clientClosedAt); - if (deviceClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { + + const serverSealedAt = new Date(); + const deviceSealedAt = new Date(dto.clientClosedAt); + if (deviceSealedAt.getTime() > serverSealedAt.getTime() + 24 * 60 * 60 * 1000) { throw new BadRequestException({ code: 'INSPECTION_ACT_INVALID_DEVICE_TIME', message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro', @@ -601,84 +591,77 @@ export class InspectionClosingService { })); const finalSnapshot = { schemaVersion: CLOSURE_SCHEMA_VERSION, - preparedSha256: closure.preparedSha256, - preparedSnapshot: closure.preparedSnapshot, + lockedSha256: closure.preparedSha256, + lockedSnapshot: closure.preparedSnapshot, signatures: signatureSnapshot, - closure: { - deviceClosedAt: deviceClosedAt.toISOString(), - serverClosedAt: serverClosedAt.toISOString(), + seal: { + deviceSealedAt: deviceSealedAt.toISOString(), + serverSealedAt: serverSealedAt.toISOString(), uploadMode: dto.uploadMode, - closedBy: principal.userId, - closedByUsername: principal.username, + sealedBy: principal.userId, + sealedByUsername: principal.username, }, }; const finalSha256 = sha256CanonicalJson(finalSnapshot); await manager.query(` UPDATE inspection_act_closures - SET final_snapshot = $2, - final_sha256 = $3, - device_closed_at = $4, - server_closed_at = $5, - upload_mode = $6, - closed_by = $7 - WHERE act_id = $1 - `, [ - actId, - finalSnapshot, - finalSha256, - deviceClosedAt, - serverClosedAt, - dto.uploadMode, - principal.userId, - ]); + SET final_snapshot=$2, + final_sha256=$3, + device_closed_at=$4, + server_closed_at=$5, + upload_mode=$6, + closed_by=$7 + WHERE act_id=$1 + `, [actId, finalSnapshot, finalSha256, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]); const [updated] = (await manager.query(` UPDATE inspection_acts - SET status = 'CLOSED', - closed_at = $2, - closed_by = $3, - closure_sha256 = $4, - current_version = current_version + 1, - updated_by = $3, - updated_at = $2 - WHERE id = $1 + 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" - `, [actId, serverClosedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; + `, [actId, serverSealedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; await manager.query(` INSERT INTO inspection_act_versions ( - act_id, version_number, event, snapshot, actor_user_id, actor_username - ) VALUES ($1, $2, $3, $4, $5, $6) + act_id,version_number,event,snapshot,actor_user_id,actor_username + ) VALUES ($1,$2,$3,$4,$5,$6) `, [ actId, Number(updated.versionNumber), - InspectionActVersionEvent.CLOSED, + InspectionActVersionEvent.SEALED, finalSnapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_ACT_CLOSED, + action: AuditAction.INSPECTION_ACT_SEALED, entityType: 'inspection_act', entityId: actId, afterData: { - status: InspectionActStatus.CLOSED, + status: InspectionActStatus.SEALED, closureSha256: finalSha256, - serverClosedAt, - uploadMode: dto.uploadMode, + sealedAt: serverSealedAt, }, metadata: { actId, - visitId: visit.id, + inspectionId: visit.id, versionNumber: Number(updated.versionNumber), - findingsRemainOpen: true, - visitRemainsIndependent: true, + findingsRemainIndependent: true, + inspectionRemainsIndependent: true, }, }, manager); await this.reports.ensureFrozenReport(manager, actId, principal, request); return this.loadView(manager, actId); }); await this.reports.ensureWordForAct(actId); - return closed; + return sealed; } async signatureContent(signatureId: string): Promise<{ @@ -720,11 +703,9 @@ export class InspectionClosingService { documentNumber: null; position: string; consentText: string; - auditAction: AuditAction; }, principal: AuthPrincipal, request: RequestWithContext, - requireSelfAssignment: boolean, ): Promise { this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM); const inspected = inspectInspectionSignatureFile(file); @@ -737,15 +718,14 @@ export class InspectionClosingService { try { return await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - if (fixedIdentity) this.assertReadyInProgress(act, visit); - else this.assertReadyForCompanyOutcome(act, visit); - await this.assertActorAssigned(manager, visit.id, principal, !requireSelfAssignment); + this.assertLockedForManifestation(act); + await this.assertActorAssigned(manager, visit.id, principal, true); const closure = await this.requireClosure(manager, actId); const responsible = fixedIdentity ? null : await this.requireResponsible(manager, actId); if (responsible && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT) { throw new ConflictException({ code: 'INSPECTION_COMPANY_RESPONSIBLE_ABSENT', - message: 'No puede registrarse firma de empresa cuando el responsable está ausente', + message: 'No puede registrarse firma de empresa si no se identificó al responsable presente', }); } const identity = fixedIdentity ?? { @@ -756,15 +736,14 @@ export class InspectionClosingService { documentNumber: responsible!.documentNumber, position: responsible!.position, consentText: COMPANY_CONSENT, - auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, }; if (identity.signerType === InspectionActSignerType.INSPECTOR) { - const [existing] = (await manager.query(` - SELECT 1 - FROM inspection_act_signatures - WHERE act_id = $1 AND signer_type = 'INSPECTOR' AND signer_user_id = $2 - `, [actId, identity.signerUserId])) as unknown[]; - if (existing) { + const existing = await manager.query(` + SELECT 1 FROM inspection_act_signatures + WHERE act_id=$1 AND signer_type='INSPECTOR' AND signer_user_id=$2 + LIMIT 1 + `, [actId, identity.signerUserId]) as unknown[]; + if (existing.length) { throw new ConflictException({ code: 'INSPECTION_ACT_INSPECTOR_ALREADY_SIGNED', message: 'El inspector actual ya firmó esta acta', @@ -773,9 +752,10 @@ export class InspectionClosingService { } else if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', - message: 'La recepción de la empresa ya fue registrada y es inmutable', + message: 'La manifestación de la empresa ya fue registrada y es inmutable', }); } + const source = this.signatureSource(principal); const signedAt = new Date(); const clientSignedAt = dto.clientSignedAt ? new Date(dto.clientSignedAt) : null; @@ -788,7 +768,7 @@ export class InspectionClosingService { : null; const payload = { actId, - preparedSha256: closure.preparedSha256, + lockedSha256: closure.preparedSha256, signerType: identity.signerType, signerUserId: identity.signerUserId, signerName: identity.signerName, @@ -814,23 +794,17 @@ export class InspectionClosingService { const signaturePayloadSha256 = sha256CanonicalJson(payload); await manager.query(` INSERT INTO inspection_act_signatures ( - id, act_id, signer_type, signer_user_id, signer_name, - document_type, document_number, position, status, - company_manifestation, company_statement, - 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 + id,act_id,signer_type,signer_user_id,signer_name, + document_type,document_number,position,status, + company_manifestation,company_statement, + 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, $3, $4, $5, - $6, $7, $8, 'SIGNED', - $9, $10, - $11, $12, $13, $14, $15, - $16, $17, $18, - $19, $20, $21, $22, $23, - $24, $25, $26, $27, - $28, $20 + $1,$2,$3,$4,$5,$6,$7,$8,'SIGNED',$9,$10,$11,$12,$13,$14,$15, + $16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$20 ) `, [ id, @@ -864,16 +838,11 @@ export class InspectionClosingService { ]); await this.audit.record({ ...administrationAuditContext(principal, request), - action: identity.auditAction, + action: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, entityType: 'inspection_act_signature', entityId: id, afterData: payload, - metadata: { - actId, - visitId: visit.id, - immutable: true, - signaturePayloadSha256, - }, + metadata: { actId, inspectionId: visit.id, immutable: true }, }, manager); return this.loadView(manager, actId); }); @@ -883,116 +852,50 @@ export class InspectionClosingService { } } - private async buildPreparedSnapshot( + private async buildLockedSnapshot( manager: EntityManager, actId: string, - preparedAt: Date, + lockedAt: Date, ): Promise> { - const [act] = (await manager.query(` - SELECT - act.id, - act.code, - act.act_year AS "actYear", - act.act_number AS "actNumber", - act.status, - act.occurred_at AS "occurredAt", - act.title, - act.summary, - act.observations, - act.current_version AS "currentVersion", - act.created_at AS "createdAt", - act.updated_at AS "updatedAt", - JSONB_BUILD_OBJECT( - 'id', visit.id, - 'code', visit.code, - 'title', visit.title, - 'objective', visit.objective, - 'status', visit.status, - 'scopeAssetId', visit.scope_asset_id, - 'leadInspectorUserId', visit.lead_inspector_user_id, - 'plannedStartAt', visit.planned_start_at, - 'plannedEndAt', visit.planned_end_at, - 'actualStartedAt', visit.actual_started_at, - 'instructions', visit.instructions - ) AS visit + const [act] = await manager.query(` + SELECT JSONB_BUILD_OBJECT( + 'id',act.id, + 'code',act.code, + 'status',act.status, + 'occurredAt',act.occurred_at, + 'title',act.title, + 'summary',act.summary, + 'observations',act.observations, + 'urgency',act.urgency, + 'deadlineDays',act.deadline_days, + 'deadlineDayType',act.deadline_day_type, + 'deadlineBasis',act.deadline_basis, + 'deadlineBaseAt',act.deadline_base_at, + 'deadlineAt',act.deadline_at, + 'inspection',JSONB_BUILD_OBJECT( + 'id',visit.id, + 'code',visit.code, + 'status',visit.status, + 'operationalAreaId',visit.operational_area_id, + 'operatorCompanyId',visit.operator_company_id, + 'leadInspectorUserId',visit.lead_inspector_user_id, + 'actualStartedAt',visit.actual_started_at + ) + ) AS act FROM inspection_acts act - INNER JOIN inspection_visits visit ON visit.id = act.visit_id - WHERE act.id = $1 - `, [actId])) as Array>; + JOIN inspection_visits visit ON visit.id=act.visit_id + WHERE act.id=$1 + `, [actId]) as Array<{ act: Record }>; if (!act) throw actNotFound(); const responsible = await this.requireResponsible(manager, actId); - const team = await manager.query(` - SELECT - member.user_id AS "userId", - user_account.username, - user_account.first_name AS "firstName", - user_account.last_name AS "lastName", - user_account.email, - (visit.lead_inspector_user_id = member.user_id) AS "isLead" - FROM inspection_visit_members member - INNER JOIN inspection_visits visit ON visit.id = member.visit_id - INNER JOIN users user_account ON user_account.id = member.user_id - WHERE member.visit_id = $1 AND member.included = true - ORDER BY "isLead" DESC, user_account.username, member.user_id - `, [(act.visit as { id: string }).id]) as Array>; - const assets = await manager.query(` - SELECT - asset.id, - asset.code, - asset.name, - asset.common_name AS "commonName", - asset.description, - asset.parent_id AS "parentId", - CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', company.id, - 'code', company.code, - 'name', company.name, - 'commonName', company.common_name - ) END AS "operatorCompany", - CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', area.id, - 'code', area.code, - 'name', area.name, - 'commonName', area.common_name - ) END AS "operationalArea", - asset.information_status AS "informationStatus", - asset.current_version AS "currentVersion", - asset.data_origin AS "dataOrigin", - asset.source_name AS "sourceName", - asset.source_reference AS "sourceReference", - asset.source_observed_at AS "sourceObservedAt", - asset_type.id AS "typeId", - asset_type.code AS "typeCode", - asset_type.name AS "typeName", - COALESCE(( - SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'definitionId', definition.id, - 'code', definition.code, - 'name', definition.name, - 'dataType', definition.data_type, - 'value', attribute_value.value - ) ORDER BY definition.sort_order, definition.code, definition.id) - FROM asset_attribute_values attribute_value - INNER JOIN asset_attribute_definitions definition - ON definition.id = attribute_value.definition_id - WHERE attribute_value.asset_id = asset.id - ), '[]'::jsonb) AS attributes, - CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'type', geometry.geometry_type, - 'geojson', ST_AsGeoJSON(geometry.geometry)::jsonb, - 'source', geometry.source, - 'accuracyM', geometry.accuracy_m, - 'capturedAt', geometry.captured_at, - 'deviceLabel', geometry.device_label - ) END AS geometry + const inventories = await manager.query(` + SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion", + type.code AS "typeCode",type.name AS "typeName" FROM inspection_act_assets link - INNER JOIN assets asset ON asset.id = link.asset_id - INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id - LEFT JOIN assets company ON company.id = asset.operator_company_id - LEFT JOIN assets area ON area.id = asset.operational_area_id - LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id - WHERE link.act_id = $1 AND link.included = true - ORDER BY asset.code, asset.id + JOIN assets asset ON asset.id=link.asset_id + JOIN asset_types type ON type.id=asset.asset_type_id + WHERE link.act_id=$1 AND link.included=true + ORDER BY asset.code,asset.id `, [actId]) as Array>; const findings = await manager.query(` SELECT @@ -1005,159 +908,113 @@ export class InspectionClosingService { finding.title, finding.description, finding.legal_basis AS "legalBasis", - finding.glossary, - finding.catalog_revision AS "catalogRevision", - finding.suggested_severity AS "suggestedSeverity", finding.severity, + finding.is_recurrence AS "isRecurrence", + finding.recurrence_of_finding_id AS "recurrenceOfFindingId", finding.correction_due_on AS "correctionDueOn", - finding.company_response AS "companyResponse", - finding.company_response_received_on AS "companyResponseReceivedOn", - finding.company_committed_correction_on AS "companyCommittedCorrectionOn", - finding.next_control_on AS "nextControlOn", - finding.current_version AS "currentVersion", - JSONB_BUILD_OBJECT( - 'code', catalog.code, - 'sourceNumber', catalog.source_number, - 'title', catalog.title, - 'revision', catalog.revision, - 'suggestedSeverity', finding.suggested_severity, - 'categoryCode', category.code, - 'categoryName', category.name - ) AS catalog, - COALESCE(( - SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'id', evidence.id, - 'communicationId', evidence.communication_id, - 'kind', evidence.kind, - 'purpose', evidence.purpose, - 'originalName', evidence.original_name, - 'mimeType', evidence.mime_type, - 'sizeBytes', evidence.size_bytes, - 'sha256', evidence.sha256, - 'title', evidence.title, - 'description', evidence.description, - 'capturedAt', evidence.captured_at, - 'latitude', evidence.latitude, - 'longitude', evidence.longitude, - 'accuracyM', evidence.accuracy_m, - 'deviceLabel', evidence.device_label, - 'source', evidence.source, - 'uploadedBy', evidence.uploaded_by, - 'createdAt', evidence.created_at - ) ORDER BY evidence.created_at, evidence.id) - FROM inspection_finding_evidence evidence - WHERE evidence.finding_id = finding.id - ), '[]'::jsonb) AS evidence, - COALESCE(( - SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'id', communication.id, - 'direction', communication.direction, - 'channel', communication.channel, - 'type', communication.type, - 'occurredAt', communication.occurred_at, - 'subject', communication.subject, - 'details', communication.details, - 'contactName', communication.contact_name, - 'contactEmail', communication.contact_email, - 'createdBy', communication.created_by, - 'createdAt', communication.created_at - ) ORDER BY communication.occurred_at, communication.id) - FROM inspection_finding_communications communication - WHERE communication.finding_id = finding.id - ), '[]'::jsonb) AS communications + finding.current_version AS "currentVersion" FROM inspection_findings finding - LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id - LEFT JOIN finding_categories category ON category.id = catalog.category_id - WHERE finding.act_id = $1 AND finding.status <> 'VOIDED' - ORDER BY finding.finding_number, finding.id + WHERE finding.act_id=$1 AND finding.status<>'VOIDED' + ORDER BY finding.finding_number,finding.id `, [actId]) as Array>; - const verificationResults = await manager.query(` - SELECT - verification_link.finding_id AS "findingId", - finding.code AS "findingCode", - finding.title AS "findingTitle", - finding.description AS "findingDescription", - finding.asset_id AS "assetId", - asset.code AS "assetCode", - asset.name AS "assetName", - verification_link.target_control_on AS "targetControlOn", - verification_link.outcome, - verification_link.result_notes AS "resultNotes", - verification_link.verified_at AS "verifiedAt", - verification_link.result_recorded_at AS "resultRecordedAt", - verification_link.rescheduled_control_on AS "rescheduledControlOn", - COALESCE(( - SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'id', evidence.id, - 'kind', evidence.kind, - 'purpose', evidence.purpose, - 'originalName', evidence.original_name, - 'mimeType', evidence.mime_type, - 'sizeBytes', evidence.size_bytes, - 'sha256', evidence.sha256, - 'title', evidence.title, - 'description', evidence.description, - 'capturedAt', evidence.captured_at, - 'latitude', evidence.latitude, - 'longitude', evidence.longitude, - 'accuracyM', evidence.accuracy_m, - 'deviceLabel', evidence.device_label, - 'source', evidence.source, - 'uploadedBy', evidence.uploaded_by, - 'createdAt', evidence.created_at - ) ORDER BY evidence.created_at, evidence.id) - FROM inspection_finding_evidence evidence - WHERE evidence.finding_id = finding.id - AND evidence.verification_visit_id = $1 - AND evidence.purpose = 'VERIFICATION' - ), '[]'::jsonb) AS evidence - FROM inspection_finding_verification_visits verification_link - INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id - INNER JOIN assets asset ON asset.id = finding.asset_id - WHERE verification_link.visit_id = $1 - ORDER BY finding.code - `, [(act.visit as { id: string }).id]) as Array>; return { schemaVersion: CLOSURE_SCHEMA_VERSION, - preparedAt: preparedAt.toISOString(), - act, + lockedAt: lockedAt.toISOString(), + act: act.act, responsible, - team, - assets, + inventories, findings, - verificationResults, }; } - private async loadView(manager: EntityManager, actId: string): Promise { - const [context] = (await manager.query(` + private async deadlinePolicy(manager: EntityManager): Promise { + const [row] = await manager.query(` SELECT - act.id, - act.code, - act.status, - act.visit_id AS "visitId", + urgent_days AS "urgentDays", + urgent_day_type AS "urgentDayType", + non_urgent_days AS "nonUrgentDays", + non_urgent_day_type AS "nonUrgentDayType" + FROM inspection_deadline_policies + ORDER BY created_at + LIMIT 1 + `) as DeadlinePolicy[]; + return row ?? { + urgentDays: 5, + urgentDayType: InspectionDeadlineDayType.BUSINESS, + nonUrgentDays: 10, + nonUrgentDayType: InspectionDeadlineDayType.BUSINESS, + }; + } + + private async calculateDeadline( + manager: EntityManager, + baseAt: Date, + days: number, + dayType: InspectionDeadlineDayType, + ): Promise { + if (dayType === InspectionDeadlineDayType.CALENDAR) { + const [row] = await manager.query(` + SELECT ( + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + $2::integer)::timestamp + + time '23:59:59' + ) AT TIME ZONE 'America/Argentina/Mendoza' AS due_at + `, [baseAt, days]) as Array<{ due_at: Date }>; + return row.due_at; + } + const [row] = await manager.query(` + WITH candidates AS ( + SELECT + day::date AS day, + COALESCE(override.is_business_day, EXTRACT(ISODOW FROM day)::integer BETWEEN 1 AND 5) AS business + FROM generate_series( + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 1)::timestamp, + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 730)::timestamp, + interval '1 day' + ) day + LEFT JOIN inspection_business_calendar_days override ON override.date=day::date + ), ranked AS ( + SELECT day,ROW_NUMBER() OVER (ORDER BY day) AS position + FROM candidates + WHERE business=true + ) + SELECT ((day::timestamp + time '23:59:59') AT TIME ZONE 'America/Argentina/Mendoza') AS due_at + FROM ranked + WHERE position=$2 + LIMIT 1 + `, [baseAt, days]) as Array<{ due_at: Date }>; + if (!row?.due_at) throw new InternalServerErrorException('No se pudo calcular el vencimiento'); + return row.due_at; + } + + private async loadView(manager: EntityManager, actId: string): Promise { + const [context] = await manager.query(` + SELECT + act.id,act.code,act.status,act.visit_id AS "visitId", + act.urgency, + act.deadline_days AS "deadlineDays", + act.deadline_day_type AS "deadlineDayType", + act.deadline_basis AS "deadlineBasis", + act.deadline_base_at AS "deadlineBaseAt", + act.deadline_at AS "deadlineAt", + act.locked_at AS "lockedAt", + act.locked_sha256 AS "lockedSha256", + act.sealed_at AS "sealedAt", act.current_version AS "currentVersion", - act.closed_at AS "closedAt", - act.closed_by AS "closedBy", + act.closed_at AS "closedAt",act.closed_by AS "closedBy", act.closure_sha256 AS "closureSha256", - visit.code AS "visitCode", - visit.status AS "visitStatus", + visit.code AS "visitCode",visit.status AS "visitStatus", visit.actual_closed_at AS "visitActualClosedAt" FROM inspection_acts act - INNER JOIN inspection_visits visit ON visit.id = act.visit_id - WHERE act.id = $1 - `, [actId])) as Array<{ - id: string; - code: string; - status: InspectionActStatus; - visitId: string; - currentVersion: number; - closedAt: Date | null; - closedBy: string | null; - closureSha256: string | null; - visitCode: string; - visitStatus: InspectionVisitStatus; - visitActualClosedAt: Date | null; + JOIN inspection_visits visit ON visit.id=act.visit_id + WHERE act.id=$1 + `, [actId]) as Array<{ + id: string; code: string; status: InspectionActStatus; visitId: string; + urgency: InspectionActUrgency; deadlineDays: number | null; + deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null; + deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null; + lockedSha256: string | null; sealedAt: Date | null; currentVersion: number; + closedAt: Date | null; closedBy: string | null; closureSha256: string | null; + visitCode: string; visitStatus: InspectionVisitStatus; visitActualClosedAt: Date | null; }>; if (!context) throw actNotFound(); const responsible = await this.loadResponsible(manager, actId); @@ -1169,6 +1026,15 @@ export class InspectionClosingService { code: context.code, status: context.status, visitId: context.visitId, + urgency: context.urgency, + deadlineDays: context.deadlineDays, + deadlineDayType: context.deadlineDayType, + deadlineBasis: context.deadlineBasis, + deadlineBaseAt: context.deadlineBaseAt, + deadlineAt: context.deadlineAt, + lockedAt: context.lockedAt, + lockedSha256: context.lockedSha256, + sealedAt: context.sealedAt, currentVersion: Number(context.currentVersion), closedAt: context.closedAt, closedBy: context.closedBy, @@ -1191,8 +1057,7 @@ export class InspectionClosingService { serverClosedAt: closure.serverClosedAt, uploadMode: closure.uploadMode, closedBy: closure.closedBy, - isCurrent: context.status === InspectionActStatus.READY - || context.status === InspectionActStatus.CLOSED, + isCurrent: [InspectionActStatus.LOCKED, InspectionActStatus.SEALED].includes(context.status), } : null, signatures, consents: { @@ -1204,23 +1069,13 @@ export class InspectionClosingService { } private async loadResponsible(manager: EntityManager, actId: string): Promise { - const [row] = (await manager.query(` - SELECT - act_id AS "actId", - attendance_status AS "attendanceStatus", - full_name AS "fullName", - document_type AS "documentType", - document_number AS "documentNumber", - position, - email, - phone, - absence_reason AS "absenceReason", - updated_by AS "updatedBy", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM inspection_act_responsibles - WHERE act_id = $1 - `, [actId])) as ResponsibleView[]; + const [row] = await manager.query(` + SELECT act_id AS "actId",attendance_status AS "attendanceStatus", + full_name AS "fullName",document_type AS "documentType",document_number AS "documentNumber", + position,email,phone,absence_reason AS "absenceReason",updated_by AS "updatedBy", + created_at AS "createdAt",updated_at AS "updatedAt" + FROM inspection_act_responsibles WHERE act_id=$1 + `, [actId]) as ResponsibleView[]; return row ?? null; } @@ -1229,30 +1084,22 @@ export class InspectionClosingService { if (!responsible) { throw new ConflictException({ code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED', - message: 'Debe identificarse al responsable presente o documentar su ausencia', + message: 'Debe identificarse al responsable o documentar su ausencia antes de finalizar el acta', }); } return responsible; } private async loadClosure(manager: EntityManager, actId: string): Promise { - const [row] = (await manager.query(` - SELECT - act_id AS "actId", - schema_version AS "schemaVersion", - prepared_snapshot AS "preparedSnapshot", - prepared_sha256 AS "preparedSha256", - prepared_at AS "preparedAt", - prepared_by AS "preparedBy", - final_snapshot AS "finalSnapshot", - final_sha256 AS "finalSha256", - device_closed_at AS "deviceClosedAt", - server_closed_at AS "serverClosedAt", - upload_mode AS "uploadMode", - closed_by AS "closedBy" - FROM inspection_act_closures - WHERE act_id = $1 - `, [actId])) as ClosureRecord[]; + const [row] = await manager.query(` + SELECT act_id AS "actId",schema_version AS "schemaVersion", + prepared_snapshot AS "preparedSnapshot",prepared_sha256 AS "preparedSha256", + prepared_at AS "preparedAt",prepared_by AS "preparedBy", + final_snapshot AS "finalSnapshot",final_sha256 AS "finalSha256", + device_closed_at AS "deviceClosedAt",server_closed_at AS "serverClosedAt", + upload_mode AS "uploadMode",closed_by AS "closedBy" + FROM inspection_act_closures WHERE act_id=$1 + `, [actId]) as ClosureRecord[]; return row ?? null; } @@ -1260,8 +1107,8 @@ export class InspectionClosingService { const closure = await this.loadClosure(manager, actId); if (!closure) { throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_PREPARED', - message: 'El acta todavía no fue preparada para firmas', + code: 'INSPECTION_ACT_NOT_LOCKED', + message: 'El acta todavía no fue finalizada y bloqueada', }); } return closure; @@ -1269,96 +1116,61 @@ export class InspectionClosingService { private signatureSelect(): string { return ` - SELECT - signature.id, - signature.act_id AS "actId", - signature.signer_type AS "signerType", - signature.signer_user_id AS "signerUserId", - signature.signer_name AS "signerName", - signature.document_type AS "documentType", - signature.document_number AS "documentNumber", - signature.position, - signature.status, - signature.reason, + SELECT signature.id,signature.act_id AS "actId",signature.signer_type AS "signerType", + signature.signer_user_id AS "signerUserId",signature.signer_name AS "signerName", + signature.document_type AS "documentType",signature.document_number AS "documentNumber", + signature.position,signature.status,signature.reason, signature.company_manifestation AS "companyManifestation", signature.company_statement AS "companyStatement", - signature.original_name AS "originalName", - signature.stored_name AS "storedName", - signature.mime_type AS "mimeType", - signature.size_bytes::integer AS "sizeBytes", - signature.image_sha256 AS "imageSha256", - signature.consent_text AS "consentText", - signature.consent_version AS "consentVersion", - signature.consent_accepted_at AS "consentAcceptedAt", - signature.client_signed_at AS "clientSignedAt", - signature.signed_at AS "signedAt", - signature.latitude::double precision AS latitude, - signature.longitude::double precision AS longitude, - signature.accuracy_m::double precision AS "accuracyM", - signature.device_label AS "deviceLabel", - signature.source, - signature.prepared_sha256 AS "preparedSha256", + signature.original_name AS "originalName",signature.stored_name AS "storedName", + signature.mime_type AS "mimeType",signature.size_bytes::integer AS "sizeBytes", + signature.image_sha256 AS "imageSha256",signature.consent_text AS "consentText", + signature.consent_version AS "consentVersion",signature.consent_accepted_at AS "consentAcceptedAt", + signature.client_signed_at AS "clientSignedAt",signature.signed_at AS "signedAt", + signature.latitude::double precision AS latitude,signature.longitude::double precision AS longitude, + signature.accuracy_m::double precision AS "accuracyM",signature.device_label AS "deviceLabel", + signature.source,signature.prepared_sha256 AS "preparedSha256", signature.signature_payload_sha256 AS "signaturePayloadSha256", - signature.uploaded_by AS "uploadedBy", - uploader.username AS "uploadedByUsername", + signature.uploaded_by AS "uploadedBy",uploader.username AS "uploadedByUsername", signature.created_at AS "createdAt" FROM inspection_act_signatures signature - INNER JOIN users uploader ON uploader.id = signature.uploaded_by + JOIN users uploader ON uploader.id=signature.uploaded_by `; } private async loadSignatures(manager: EntityManager, actId: string): Promise { const rows = await manager.query(` - ${this.signatureSelect()} - WHERE signature.act_id = $1 - ORDER BY signature.signer_type, signature.created_at, signature.id + ${this.signatureSelect()} WHERE signature.act_id=$1 + ORDER BY signature.signer_type,signature.created_at,signature.id `, [actId]) as StoredSignature[]; return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row); } private async loadStoredSignature(manager: EntityManager, id: string): Promise { - const [row] = await manager.query(` - ${this.signatureSelect()} - WHERE signature.id = $1 - `, [id]) as StoredSignature[]; + const [row] = await manager.query(`${this.signatureSelect()} WHERE signature.id=$1`, [id]) as StoredSignature[]; if (!row) throw signatureNotFound(); return row; } private async signatureCount(manager: EntityManager, actId: string): Promise { - const [row] = (await manager.query(` - SELECT COUNT(*)::integer AS total - FROM inspection_act_signatures - WHERE act_id = $1 - `, [actId])) as Array<{ total: number }>; + const [row] = await manager.query(`SELECT COUNT(*)::integer AS total FROM inspection_act_signatures WHERE act_id=$1`, [actId]) as Array<{ total: number }>; return Number(row?.total ?? 0); } private async hasCompanyOutcome(manager: EntityManager, actId: string): Promise { const rows = await manager.query(` - SELECT 1 - FROM inspection_act_signatures - WHERE act_id = $1 AND signer_type = 'COMPANY_RESPONSIBLE' - LIMIT 1 + SELECT 1 FROM inspection_act_signatures + WHERE act_id=$1 AND signer_type='COMPANY_RESPONSIBLE' LIMIT 1 `, [actId]) as unknown[]; return rows.length > 0; } - private async lockContext( - manager: EntityManager, - actId: string, - ): Promise<{ act: InspectionAct; visit: InspectionVisit }> { + private async lockContext(manager: EntityManager, actId: string): Promise<{ act: InspectionAct; visit: InspectionVisit }> { const act = await manager.getRepository(InspectionAct) - .createQueryBuilder('act') - .where('act.id = :actId', { actId }) - .setLock('pessimistic_write') - .getOne(); + .createQueryBuilder('act').where('act.id=:actId', { actId }).setLock('pessimistic_write').getOne(); if (!act) throw actNotFound(); const visit = await manager.getRepository(InspectionVisit) - .createQueryBuilder('visit') - .where('visit.id = :visitId', { visitId: act.visitId }) - .setLock('pessimistic_write') - .getOne(); + .createQueryBuilder('visit').where('visit.id=:visitId', { visitId: act.visitId }).setLock('pessimistic_write').getOne(); if (!visit) throw actNotFound(); return { act, visit }; } @@ -1367,56 +1179,35 @@ export class InspectionClosingService { if (act.status !== InspectionActStatus.DRAFT || visit.status !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_ACT_NOT_EDITABLE', - message: 'El responsable sólo puede modificarse con el acta en borrador y la visita en curso', + message: 'El acta sólo puede editarse mientras está en borrador y la inspección está en curso', }); } } - private assertReadyInProgress(act: InspectionAct, visit: InspectionVisit): void { - if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) { + private assertLockedForManifestation(act: InspectionAct): void { + if (act.status !== InspectionActStatus.LOCKED) { throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_READY', - message: 'El acta debe estar preparada y la visita en curso', - }); - } - } - - private assertReadyForCompanyOutcome(act: InspectionAct, visit: InspectionVisit): void { - if (act.status !== InspectionActStatus.READY - || ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) { - throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_READY_FOR_COMPANY', - message: 'El acta debe estar preparada; la firma de empresa puede completarse durante la inspección o después de su cierre', - }); - } - } - - private assertReadyForFinalClosure(act: InspectionAct, visit: InspectionVisit): void { - if (act.status !== InspectionActStatus.READY - || ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) { - throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_READY', - message: 'El acta debe estar preparada para completar su cierre definitivo', + code: 'INSPECTION_ACT_NOT_LOCKED', + message: 'El acta debe estar finalizada y bloqueada antes de firmarse o sellarse', }); } } private async assertActorAssigned( manager: EntityManager, - visitId: string, + inspectionId: string, principal: AuthPrincipal, allowManager: boolean, ): Promise { if (allowManager && principal.permissions.includes('inspections.manage')) return; const rows = await manager.query(` - SELECT 1 - FROM inspection_visit_members - WHERE visit_id = $1 AND user_id = $2 AND included = true - `, [visitId, principal.userId]) as unknown[]; - if (rows.length === 0) { + SELECT 1 FROM inspection_visit_members + WHERE visit_id=$1 AND user_id=$2 AND included=true + `, [inspectionId, principal.userId]) as unknown[]; + if (!rows.length) { throw new ForbiddenException({ code: 'INSPECTION_VISIT_NOT_ASSIGNED', - message: 'Sólo un integrante asignado a la visita puede realizar esta firma', + message: 'Sólo un inspector asignado puede realizar esta operación', }); } } diff --git a/api-v3/src/inspection-findings/dto/set-finding-recurrence.dto.ts b/api-v3/src/inspection-findings/dto/set-finding-recurrence.dto.ts new file mode 100644 index 0000000..7801e67 --- /dev/null +++ b/api-v3/src/inspection-findings/dto/set-finding-recurrence.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class SetFindingRecurrenceDto { + @IsUUID('4') + recurrenceOfFindingId!: string; +} diff --git a/api-v3/src/inspection-findings/finding-recurrence.controller.ts b/api-v3/src/inspection-findings/finding-recurrence.controller.ts new file mode 100644 index 0000000..369ce91 --- /dev/null +++ b/api-v3/src/inspection-findings/finding-recurrence.controller.ts @@ -0,0 +1,53 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + Req, +} from '@nestjs/common'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto'; +import { FindingRecurrenceService } from './finding-recurrence.service'; + +@Controller('inspection-findings') +export class FindingRecurrenceController { + constructor(private readonly recurrence: FindingRecurrenceService) {} + + @Get(':id/recurrence') + @RequirePermissions('inspection_findings.read') + state(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.recurrence.state(id); + } + + @Get(':id/recurrence-candidates') + @RequirePermissions('inspection_findings.read') + candidates(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.recurrence.candidates(id); + } + + @Post(':id/recurrence') + @RequirePermissions('inspection_findings.update') + link( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: SetFindingRecurrenceDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.recurrence.link(id, dto, principal, request); + } + + @Delete(':id/recurrence') + @RequirePermissions('inspection_findings.update') + clear( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.recurrence.clear(id, principal, request); + } +} diff --git a/api-v3/src/inspection-findings/finding-recurrence.service.ts b/api-v3/src/inspection-findings/finding-recurrence.service.ts new file mode 100644 index 0000000..ffa4cc9 --- /dev/null +++ b/api-v3/src/inspection-findings/finding-recurrence.service.ts @@ -0,0 +1,268 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AuditAction } from '../database/entities'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import type { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto'; +import { InspectionFindingsService } from './inspection-findings.service'; +import type { InspectionFindingView } from './inspection-findings.service'; + +interface FindingRecurrenceContext { + id: string; + assetId: string; + catalogItemId: string | null; + title: string; + createdAt: Date; + actId: string; + actStatus: string; + visitId: string; + visitStatus: string; +} + +export interface FindingRecurrenceState { + isRecurrence: boolean; + recurrenceOfFindingId: string | null; + recurrenceOfFindingCode: string | null; +} + +export type FindingWithRecurrenceView = InspectionFindingView & FindingRecurrenceState; + +function comparable(value: string): string { + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +@Injectable() +export class FindingRecurrenceService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly findings: InspectionFindingsService, + ) {} + + async state(id: string): Promise { + const [row] = await this.dataSource.query(` + SELECT + finding.is_recurrence AS "isRecurrence", + finding.recurrence_of_finding_id AS "recurrenceOfFindingId", + antecedent.code AS "recurrenceOfFindingCode" + FROM inspection_findings finding + LEFT JOIN inspection_findings antecedent ON antecedent.id = finding.recurrence_of_finding_id + WHERE finding.id = $1 + `, [id]) as FindingRecurrenceState[]; + if (!row) { + throw new NotFoundException({ + code: 'INSPECTION_FINDING_NOT_FOUND', + message: 'Hallazgo no encontrado', + }); + } + return row; + } + + async candidates(id: string) { + const current = await this.requireFinding(this.dataSource.manager, id, false); + const rows = await this.dataSource.query(` + SELECT + finding.id,finding.code,finding.title,finding.description,finding.status, + finding.catalog_item_id AS "catalogItemId",finding.created_at AS "createdAt", + act.id AS "actId",act.code AS "actCode",act.occurred_at AS "actOccurredAt", + visit.id AS "visitId",visit.code AS "visitCode",visit.status AS "visitStatus" + FROM inspection_findings finding + JOIN inspection_acts act ON act.id=finding.act_id + JOIN inspection_visits visit ON visit.id=act.visit_id + WHERE finding.asset_id=$1 + AND finding.id<>$2 + AND finding.status<>'VOIDED' + AND finding.created_at<$3 + AND ( + ($4::uuid IS NOT NULL AND finding.catalog_item_id=$4::uuid) + OR ($4::uuid IS NULL AND lower(btrim(finding.title))=lower(btrim($5))) + ) + ORDER BY finding.created_at DESC,finding.id DESC + LIMIT 20 + `, [current.assetId, current.id, current.createdAt, current.catalogItemId, current.title]); + return { + findingId: current.id, + assetId: current.assetId, + hasCandidates: rows.length > 0, + data: rows, + }; + } + + async link( + id: string, + dto: SetFindingRecurrenceDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + await this.dataSource.transaction(async (manager) => { + const current = await this.requireFinding(manager, id, true); + this.assertEditable(current); + await this.assertActorAssigned(manager, current.visitId, principal); + if (id === dto.recurrenceOfFindingId) { + throw new BadRequestException({ + code: 'FINDING_CANNOT_RECUR_FROM_SELF', + message: 'Un Hallazgo no puede ser reincidencia de sí mismo', + }); + } + const previous = await this.requireFinding(manager, dto.recurrenceOfFindingId, false); + if (previous.assetId !== current.assetId) { + throw new ConflictException({ + code: 'FINDING_RECURRENCE_DIFFERENT_INVENTORY', + message: 'La reincidencia debe referir a un Hallazgo anterior del mismo Inventario', + }); + } + if (new Date(previous.createdAt).getTime() >= new Date(current.createdAt).getTime()) { + throw new ConflictException({ + code: 'FINDING_RECURRENCE_NOT_PREVIOUS', + message: 'El Hallazgo de referencia debe ser anterior al actual', + }); + } + const sameCatalog = current.catalogItemId && previous.catalogItemId + ? current.catalogItemId === previous.catalogItemId + : current.catalogItemId === previous.catalogItemId; + const sameTitle = comparable(current.title) === comparable(previous.title); + if (!sameCatalog && !sameTitle) { + throw new ConflictException({ + code: 'FINDING_RECURRENCE_NOT_EQUIVALENT', + message: 'El antecedente seleccionado no corresponde al mismo tipo de Hallazgo', + }); + } + + const [before] = await manager.query(` + SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId" + FROM inspection_findings WHERE id=$1 FOR UPDATE + `, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>; + await manager.query(` + UPDATE inspection_findings + SET is_recurrence=true,recurrence_of_finding_id=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [id, previous.id, principal.userId]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_FINDING_UPDATED, + entityType: 'inspection_finding', + entityId: id, + beforeData: before ?? { isRecurrence: false, recurrenceOfFindingId: null }, + afterData: { + isRecurrence: true, + recurrenceOfFindingId: previous.id, + recurrenceOfFindingCode: await this.codeForFinding(manager, previous.id), + }, + metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'LINKED' }, + }, manager); + }); + const [finding, recurrence] = await Promise.all([ + this.findings.getById(id), + this.state(id), + ]); + return { ...finding, ...recurrence }; + } + + async clear( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + await this.dataSource.transaction(async (manager) => { + const current = await this.requireFinding(manager, id, true); + this.assertEditable(current); + await this.assertActorAssigned(manager, current.visitId, principal); + const [before] = await manager.query(` + SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId" + FROM inspection_findings WHERE id=$1 FOR UPDATE + `, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>; + if (!before?.isRecurrence && !before?.recurrenceOfFindingId) return; + await manager.query(` + UPDATE inspection_findings + SET is_recurrence=false,recurrence_of_finding_id=NULL,updated_by=$2,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [id, principal.userId]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_FINDING_UPDATED, + entityType: 'inspection_finding', + entityId: id, + beforeData: before, + afterData: { isRecurrence: false, recurrenceOfFindingId: null }, + metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'CLEARED' }, + }, manager); + }); + const [finding, recurrence] = await Promise.all([ + this.findings.getById(id), + this.state(id), + ]); + return { ...finding, ...recurrence }; + } + + private async requireFinding( + manager: EntityManager, + id: string, + lock: boolean, + ): Promise { + const [row] = await manager.query(` + SELECT finding.id,finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId", + finding.title,finding.created_at AS "createdAt",finding.act_id AS "actId", + act.status AS "actStatus",visit.id AS "visitId",visit.status AS "visitStatus" + FROM inspection_findings finding + JOIN inspection_acts act ON act.id=finding.act_id + JOIN inspection_visits visit ON visit.id=act.visit_id + WHERE finding.id=$1 + ${lock ? 'FOR UPDATE OF finding,act,visit' : ''} + `, [id]) as FindingRecurrenceContext[]; + if (!row) { + throw new NotFoundException({ + code: 'INSPECTION_FINDING_NOT_FOUND', + message: 'Hallazgo no encontrado', + }); + } + return row; + } + + private assertEditable(finding: FindingRecurrenceContext): void { + if (finding.actStatus !== 'DRAFT' || finding.visitStatus !== 'IN_PROGRESS') { + throw new ConflictException({ + code: 'FINDING_RECURRENCE_IMMUTABLE', + message: 'La reincidencia sólo puede definirse mientras el Acta está en BORRADOR y la Inspección en curso', + }); + } + } + + private async assertActorAssigned( + manager: EntityManager, + visitId: string, + principal: AuthPrincipal, + ): Promise { + if (principal.permissions.includes('inspections.manage')) return; + const [row] = await manager.query(` + SELECT 1 FROM inspection_visit_members + WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1 + `, [visitId, principal.userId]) as unknown[]; + if (!row) { + throw new ForbiddenException({ + code: 'INSPECTION_NOT_ASSIGNED', + message: 'La Inspección no está asignada al usuario actual', + }); + } + } + + private async codeForFinding(manager: EntityManager, id: string): Promise { + const [row] = await manager.query('SELECT code FROM inspection_findings WHERE id=$1', [id]) as Array<{ code: string }>; + return row?.code ?? null; + } +} diff --git a/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts b/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts new file mode 100644 index 0000000..42df273 --- /dev/null +++ b/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts @@ -0,0 +1,331 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto'; + +export interface InspectionFindingWorklistCounters { + open: number; + withoutControlDate: number; + toVerify: number; + verificationOverdue: number; + verificationNext30Days: number; + readyToClose: number; + closed: number; +} + +export interface InspectionFindingWorklistItem { + id: string; + actId: string; + assetId: string; + code: string; + status: 'OPEN' | 'CLOSED' | 'VOIDED'; + title: string; + description: string; + severity: number | null; + nextControlOn: string | null; + closedAt: Date | null; + closureNotes: string | null; + asset: { + id: string; + code: string; + name: string; + commonName: string | null; + typeName: string; + operatorCompany: { id: string; code: string; name: string } | null; + operationalArea: { id: string; code: string; name: string } | null; + }; + document: { + actId: string; + actCode: string; + actStatus: string; + visitId: string; + visitCode: string; + visitStatus: string; + }; + verificationVisit: { + id: string; + code: string; + status: string; + plannedStartAt: Date | null; + } | null; + latestVerification: { + visitId: string; + visitCode: string; + visitStatus: string; + targetControlOn: string | null; + outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null; + resultNotes: string | null; + verifiedAt: Date | null; + resultRecordedAt: Date | null; + rescheduledControlOn: string | null; + evidenceCount: number; + } | null; + createdAt: Date; + updatedAt: Date; +} + +export interface InspectionFindingWorklistPage { + data: InspectionFindingWorklistItem[]; + meta: { page: number; pageSize: number; total: number; totalPages: number }; + counters: InspectionFindingWorklistCounters; +} + +@Injectable() +export class InspectionFindingWorklistService { + constructor(private readonly dataSource: DataSource) {} + + async list(query: ListInspectionFindingsQueryDto): Promise { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 25; + const workflow = query.workflow ?? 'OPEN'; + const values: unknown[] = []; + const contextFilters: string[] = []; + const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`; + const latestOutcome = `(SELECT latest_verification.outcome + FROM inspection_finding_verification_visits latest_verification + WHERE latest_verification.finding_id = finding.id + AND latest_verification.outcome IS NOT NULL + ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, + latest_verification.created_at DESC, + latest_verification.id DESC + LIMIT 1)`; + const latestVisitStatus = `(SELECT latest_visit.status + FROM inspection_finding_verification_visits latest_verification + INNER JOIN inspection_visits latest_visit ON latest_visit.id = latest_verification.visit_id + WHERE latest_verification.finding_id = finding.id + AND latest_verification.outcome IS NOT NULL + ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, + latest_verification.created_at DESC, + latest_verification.id DESC + LIMIT 1)`; + + const add = (value: unknown): string => { + values.push(value); + return `$${values.length}`; + }; + + if (query.search) { + const parameter = add(`%${query.search}%`); + contextFilters.push(`( + finding.code ILIKE ${parameter} + OR finding.title ILIKE ${parameter} + OR finding.description ILIKE ${parameter} + OR asset.code ILIKE ${parameter} + OR asset.name ILIKE ${parameter} + OR company.name ILIKE ${parameter} + OR area.name ILIKE ${parameter} + OR act.code ILIKE ${parameter} + )`); + } + if (query.companyId) contextFilters.push(`company.id = ${add(query.companyId)}::uuid`); + if (query.areaId) contextFilters.push(`area.id = ${add(query.areaId)}::uuid`); + if (query.inspectorId) { + const inspector = add(query.inspectorId); + contextFilters.push(`( + visit.lead_inspector_user_id = ${inspector}::uuid + OR EXISTS ( + SELECT 1 FROM inspection_visit_members member_filter + WHERE member_filter.visit_id = visit.id + AND member_filter.included = true + AND member_filter.user_id = ${inspector}::uuid + ) + )`); + } + if (query.dateFrom) contextFilters.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`); + if (query.dateTo) contextFilters.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`); + + const workflowFilters: string[] = []; + if (workflow === 'OPEN') workflowFilters.push(`finding.status = 'OPEN'`); + if (workflow === 'TO_SCHEDULE_VERIFICATION') { + workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`); + } + if (workflow === 'TO_VERIFY') { + workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NOT NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`); + } + if (workflow === 'VERIFICATION_OVERDUE') { + workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NOT NULL AND finding.next_control_on < ${today} AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`); + } + if (workflow === 'READY_TO_CLOSE') { + workflowFilters.push(`finding.status = 'OPEN' AND ${latestOutcome} = 'RESOLVED' AND ${latestVisitStatus} = 'CLOSED'`); + } + if (workflow === 'CLOSED') workflowFilters.push(`finding.status = 'CLOSED'`); + if (workflow === 'WAITING_COMPANY' || workflow === 'COMPANY_OVERDUE') { + // Compatibilidad de URL histórica: estos estados dejaron de existir en F4. + workflowFilters.push(`finding.status = 'OPEN'`); + } + + const joins = ` + INNER JOIN inspection_acts act ON act.id = finding.act_id + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + INNER JOIN assets asset ON asset.id = finding.asset_id + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + LEFT JOIN assets company ON company.id = asset.operator_company_id + LEFT JOIN assets area ON area.id = asset.operational_area_id + `; + const filters = [...contextFilters, ...workflowFilters]; + const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; + const contextWhere = contextFilters.length ? `WHERE ${contextFilters.join(' AND ')}` : ''; + + const [countRow] = await this.dataSource.query(` + SELECT COUNT(*)::integer AS total + FROM inspection_findings finding + ${joins} + ${where} + `, values) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + + const listValues = [...values, pageSize, (page - 1) * pageSize]; + const limitParameter = `$${values.length + 1}`; + const offsetParameter = `$${values.length + 2}`; + const data = await this.dataSource.query(` + SELECT + finding.id, + finding.act_id AS "actId", + finding.asset_id AS "assetId", + finding.code, + finding.status, + finding.title, + finding.description, + finding.severity, + finding.next_control_on AS "nextControlOn", + finding.closed_at AS "closedAt", + finding.closure_notes AS "closureNotes", + JSONB_BUILD_OBJECT( + 'id', asset.id, + 'code', asset.code, + 'name', asset.name, + 'commonName', asset.common_name, + 'typeName', asset_type.name, + 'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', company.id, 'code', company.code, 'name', company.name + ) END, + 'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', area.id, 'code', area.code, 'name', area.name + ) END + ) AS asset, + JSONB_BUILD_OBJECT( + 'actId', act.id, + 'actCode', act.code, + 'actStatus', act.status, + 'visitId', visit.id, + 'visitCode', visit.code, + 'visitStatus', visit.status + ) AS document, + CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id', verification_visit.id, + 'code', verification_visit.code, + 'status', verification_visit.status, + 'plannedStartAt', verification_visit.planned_start_at + ) END AS "verificationVisit", + CASE WHEN latest_verification.visit_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'visitId', latest_verification.visit_id, + 'visitCode', latest_verification.visit_code, + 'visitStatus', latest_verification.visit_status, + 'targetControlOn', latest_verification.target_control_on, + 'outcome', latest_verification.outcome, + 'resultNotes', latest_verification.result_notes, + 'verifiedAt', latest_verification.verified_at, + 'resultRecordedAt', latest_verification.result_recorded_at, + 'rescheduledControlOn', latest_verification.rescheduled_control_on, + 'evidenceCount', latest_verification.evidence_count + ) END AS "latestVerification", + finding.created_at AS "createdAt", + finding.updated_at AS "updatedAt" + FROM inspection_findings finding + ${joins} + LEFT JOIN LATERAL ( + SELECT verification_target.id, verification_target.code, + verification_target.status, verification_target.planned_start_at + FROM inspection_finding_verification_visits verification_link + INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id + WHERE verification_link.finding_id = finding.id + AND verification_target.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS') + ORDER BY verification_link.created_at DESC, verification_link.id DESC + LIMIT 1 + ) verification_visit ON true + LEFT JOIN LATERAL ( + SELECT + verification_link.visit_id, + verification_target.code AS visit_code, + verification_target.status AS visit_status, + verification_link.target_control_on, + verification_link.outcome, + verification_link.result_notes, + verification_link.verified_at, + verification_link.result_recorded_at, + verification_link.rescheduled_control_on, + (SELECT COUNT(*)::integer + FROM inspection_finding_evidence evidence + WHERE evidence.finding_id = finding.id + AND evidence.verification_visit_id = verification_link.visit_id + AND evidence.purpose = 'VERIFICATION') AS evidence_count + FROM inspection_finding_verification_visits verification_link + INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id + WHERE verification_link.finding_id = finding.id + ORDER BY COALESCE(verification_link.result_recorded_at, verification_link.created_at) DESC, + verification_link.id DESC + LIMIT 1 + ) latest_verification ON true + ${where} + ORDER BY + CASE WHEN finding.status = 'OPEN' THEN 0 ELSE 1 END, + COALESCE(finding.next_control_on, '9999-12-31'::date), + finding.updated_at DESC, + finding.code + LIMIT ${limitParameter} OFFSET ${offsetParameter} + `, listValues) as InspectionFindingWorklistItem[]; + + const [counterRow] = await this.dataSource.query(` + SELECT + COUNT(*) FILTER (WHERE finding.status='OPEN')::integer AS "open", + COUNT(*) FILTER ( + WHERE finding.status='OPEN' + AND finding.next_control_on IS NULL + AND COALESCE(${latestOutcome}, '') <> 'RESOLVED' + )::integer AS "withoutControlDate", + COUNT(*) FILTER ( + WHERE finding.status='OPEN' + AND finding.next_control_on IS NOT NULL + AND COALESCE(${latestOutcome}, '') <> 'RESOLVED' + )::integer AS "toVerify", + COUNT(*) FILTER ( + WHERE finding.status='OPEN' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on < ${today} + AND COALESCE(${latestOutcome}, '') <> 'RESOLVED' + )::integer AS "verificationOverdue", + COUNT(*) FILTER ( + WHERE finding.status='OPEN' + AND finding.next_control_on BETWEEN ${today} AND ${today} + 30 + AND COALESCE(${latestOutcome}, '') <> 'RESOLVED' + )::integer AS "verificationNext30Days", + COUNT(*) FILTER ( + WHERE finding.status='OPEN' + AND ${latestOutcome}='RESOLVED' + AND ${latestVisitStatus}='CLOSED' + )::integer AS "readyToClose", + COUNT(*) FILTER (WHERE finding.status='CLOSED')::integer AS "closed" + FROM inspection_findings finding + ${joins} + ${contextWhere} + `, values) as InspectionFindingWorklistCounters[]; + + return { + data, + meta: { + page, + pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / pageSize), + }, + counters: { + open: Number(counterRow?.open ?? 0), + withoutControlDate: Number(counterRow?.withoutControlDate ?? 0), + toVerify: Number(counterRow?.toVerify ?? 0), + verificationOverdue: Number(counterRow?.verificationOverdue ?? 0), + verificationNext30Days: Number(counterRow?.verificationNext30Days ?? 0), + readyToClose: Number(counterRow?.readyToClose ?? 0), + closed: Number(counterRow?.closed ?? 0), + }, + }; + } +} diff --git a/api-v3/src/inspection-findings/inspection-findings.controller.ts b/api-v3/src/inspection-findings/inspection-findings.controller.ts index 1540671..f55e41b 100644 --- a/api-v3/src/inspection-findings/inspection-findings.controller.ts +++ b/api-v3/src/inspection-findings/inspection-findings.controller.ts @@ -15,9 +15,12 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto'; import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto'; import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto'; -import { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto'; import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto'; +import { FindingRecurrenceService } from './finding-recurrence.service'; +import type { FindingRecurrenceState } from './finding-recurrence.service'; +import { InspectionFindingWorklistService } from './inspection-finding-worklist.service'; import { InspectionFindingsService } from './inspection-findings.service'; +import type { InspectionFindingView } from './inspection-findings.service'; @Controller('inspection-acts/:actId/findings') export class InspectionActFindingsController { @@ -43,18 +46,28 @@ export class InspectionActFindingsController { @Controller('inspection-findings') export class InspectionFindingsController { - constructor(private readonly findings: InspectionFindingsService) {} + constructor( + private readonly findings: InspectionFindingsService, + private readonly worklist: InspectionFindingWorklistService, + private readonly recurrence: FindingRecurrenceService, + ) {} @Get() @RequirePermissions('inspection_findings.read') list(@Query() query: ListInspectionFindingsQueryDto) { - return this.findings.listGlobal(query); + return this.worklist.list(query); } @Get(':id') @RequirePermissions('inspection_findings.read') - get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { - return this.findings.getById(id); + async get( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + ): Promise { + const [finding, recurrence] = await Promise.all([ + this.findings.getById(id), + this.recurrence.state(id), + ]); + return { ...finding, ...recurrence }; } @Get(':id/verification-history') @@ -84,15 +97,4 @@ export class InspectionFindingsController { ) { return this.findings.close(id, dto, principal, request); } - - @Patch(':id/follow-up') - @RequirePermissions('inspection_findings.follow_up') - updateFollowUp( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: UpdateInspectionFindingFollowUpDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.findings.updateFollowUp(id, dto, principal, request); - } } diff --git a/api-v3/src/inspection-findings/inspection-findings.module.ts b/api-v3/src/inspection-findings/inspection-findings.module.ts index 6ba1fba..8c47882 100644 --- a/api-v3/src/inspection-findings/inspection-findings.module.ts +++ b/api-v3/src/inspection-findings/inspection-findings.module.ts @@ -4,6 +4,9 @@ import { FindingCatalogController } from './finding-catalog.controller'; import { FindingCatalogMergeService } from './finding-catalog-merge.service'; import { FindingCatalogService } from './finding-catalog.service'; import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service'; +import { FindingRecurrenceController } from './finding-recurrence.controller'; +import { FindingRecurrenceService } from './finding-recurrence.service'; +import { InspectionFindingWorklistService } from './inspection-finding-worklist.service'; import { InspectionActFindingsController, InspectionFindingsController, @@ -22,6 +25,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service'; FindingCatalogController, InspectionActFindingsController, InspectionFindingsController, + FindingRecurrenceController, InspectionFindingEvidenceController, InspectionFindingCommunicationsController, InspectionEvidenceContentController, @@ -31,6 +35,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service'; F3FindingCatalogResolverService, FindingCatalogMergeService, InspectionFindingsService, + InspectionFindingWorklistService, + FindingRecurrenceService, InspectionEvidenceService, ], exports: [ @@ -38,6 +44,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service'; F3FindingCatalogResolverService, FindingCatalogMergeService, InspectionFindingsService, + InspectionFindingWorklistService, + FindingRecurrenceService, ], }) export class InspectionFindingsModule {} diff --git a/api-v3/src/inspection-findings/inspection-findings.service.ts b/api-v3/src/inspection-findings/inspection-findings.service.ts index 9f58330..54bc171 100644 --- a/api-v3/src/inspection-findings/inspection-findings.service.ts +++ b/api-v3/src/inspection-findings/inspection-findings.service.ts @@ -62,7 +62,6 @@ interface FindingDocumentView { actStatus: InspectionActStatus; visitId: string; visitCode: string; - visitTitle: string; visitStatus: InspectionVisitStatus; } @@ -99,7 +98,6 @@ export interface InspectionFindingListItem { verificationVisit: { id: string; code: string; - title: string; status: InspectionVisitStatus; plannedStartAt: Date | null; } | null; @@ -143,7 +141,6 @@ export interface FindingVerificationHistoryEvent { verificationVisit: { id: string; code: string; - title: string; status: InspectionVisitStatus; plannedStartAt: Date | null; } | null; @@ -811,13 +808,11 @@ export class InspectionFindingsService { 'actStatus', act.status, 'visitId', visit.id, 'visitCode', visit.code, - 'visitTitle', visit.title, 'visitStatus', visit.status ) AS document, CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', verification_visit.id, 'code', verification_visit.code, - 'title', verification_visit.title, 'status', verification_visit.status, 'plannedStartAt', verification_visit.planned_start_at ) END AS "verificationVisit", @@ -845,7 +840,7 @@ export class InspectionFindingsService { LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id LEFT JOIN finding_categories category ON category.id = catalog.category_id LEFT JOIN LATERAL ( - SELECT verification_target.id, verification_target.code, verification_target.title, + SELECT verification_target.id, verification_target.code, verification_target.status, verification_target.planned_start_at FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id @@ -923,7 +918,6 @@ export class InspectionFindingsService { CASE WHEN visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', visit.id, 'code', visit.code, - 'title', visit.title, 'status', visit.status, 'plannedStartAt', visit.planned_start_at ) END AS "verificationVisit", diff --git a/api-v3/src/inspection-reports/document-delivery.controller.ts b/api-v3/src/inspection-reports/document-delivery.controller.ts index 54704b4..1724473 100644 --- a/api-v3/src/inspection-reports/document-delivery.controller.ts +++ b/api-v3/src/inspection-reports/document-delivery.controller.ts @@ -1,17 +1,128 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Req } from '@nestjs/common'; +import { Body, Controller, ForbiddenException, Get, Param, ParseUUIDPipe, Patch, Post, Put, Req } from '@nestjs/common'; import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../database/entities'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; import type { DeliveryRow } from './inspection-document-delivery.service'; +import { SmtpDeliveryService } from './smtp-delivery.service'; import { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto'; +import { TestSmtpSettingsDto, UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto'; @Controller('document-delivery') export class DocumentDeliveryController { - constructor(private readonly delivery:InspectionDocumentDeliveryService){} - @Get('settings') @RequirePermissions('document_delivery.read') settings(){return this.delivery.settings();} - @Patch('settings') @RequirePermissions('document_delivery.manage') update(@Body() dto:UpdateDocumentDeliverySettingsDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.updateSettings(dto,principal,request);} - @Get('outbox') @RequirePermissions('document_delivery.read') outbox(){return this.delivery.list();} - @Post('outbox/:id/retry') @RequirePermissions('document_delivery.manage') retry(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext):Promise{return this.delivery.retry(id,principal,request);} - @Post('outbox/retry-pending') @RequirePermissions('document_delivery.manage') retryPending(@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.retryPending(principal,request);} + constructor( + private readonly delivery: InspectionDocumentDeliveryService, + private readonly smtp: SmtpDeliveryService, + private readonly audit: AuditService, + ) {} + + @Get('settings') + @RequirePermissions('document_delivery.read') + settings() { + return this.delivery.settings(); + } + + @Patch('settings') + @RequirePermissions('document_delivery.manage') + update( + @Body() dto: UpdateDocumentDeliverySettingsDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.delivery.updateSettings(dto, principal, request); + } + + @Get('smtp') + @RequirePermissions('document_delivery.manage') + smtpSettings(@CurrentAuth() principal: AuthPrincipal) { + this.assertSystemAdmin(principal); + return this.smtp.publicSettings(); + } + + @Put('smtp') + @RequirePermissions('document_delivery.manage') + async updateSmtp( + @Body() dto: UpdateSmtpSettingsDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + this.assertSystemAdmin(principal); + const before = await this.smtp.publicSettings(); + const after = await this.smtp.saveSettings(dto, principal.userId); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.SMTP_SETTINGS_UPDATED, + entityType: 'system_smtp_settings', + entityId: 'singleton', + beforeData: before as Record, + afterData: after as Record, + metadata: { passwordNeverReturned: true, restrictedToRole: 'admin' }, + }); + return after; + } + + @Post('smtp/test') + @RequirePermissions('document_delivery.manage') + async testSmtp( + @Body() dto: TestSmtpSettingsDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + this.assertSystemAdmin(principal); + const sent = await this.smtp.send({ + to: dto.email, + subject: 'DH Inspección · Prueba SMTP', + text: 'Este correo confirma que la configuración SMTP de DH Inspección funciona correctamente.', + attachment: { + filename: 'dh-inspeccion-smtp-test.txt', + mimeType: 'text/plain', + content: Buffer.from('DH Inspección · Prueba SMTP OK\n', 'utf8'), + }, + }); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.SMTP_TEST_SENT, + entityType: 'system_smtp_settings', + entityId: 'singleton', + afterData: { recipient: dto.email, messageId: sent.messageId }, + metadata: { restrictedToRole: 'admin' }, + }); + return { ok: true, recipient: dto.email, messageId: sent.messageId }; + } + + @Get('outbox') + @RequirePermissions('document_delivery.read') + outbox() { + return this.delivery.list(); + } + + @Post('outbox/:id/retry') + @RequirePermissions('document_delivery.manage') + retry( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ): Promise { + return this.delivery.retry(id, principal, request); + } + + @Post('outbox/retry-pending') + @RequirePermissions('document_delivery.manage') + retryPending( + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.delivery.retryPending(principal, request); + } + + private assertSystemAdmin(principal: AuthPrincipal): void { + if (principal.roles.includes('admin')) return; + throw new ForbiddenException({ + code: 'SMTP_SUPERADMIN_REQUIRED', + message: 'La configuración SMTP está reservada al Superadmin del sistema', + }); + } } diff --git a/api-v3/src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts b/api-v3/src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts new file mode 100644 index 0000000..e777ba7 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts @@ -0,0 +1,23 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator'; +import { InspectionReportFollowUpType } from '../../database/entities'; + +export class CreateInspectionReportFollowUpDto { + @IsEnum(InspectionReportFollowUpType) + type!: InspectionReportFollowUpType; + + @IsISO8601({ strict: true }) + occurredAt!: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value) + @IsString() + @MaxLength(255) + externalReference?: string | null; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value) + @IsString() + @MaxLength(20000) + description?: string | null; +} diff --git a/api-v3/src/inspection-reports/dto/officialize-inspection-report.dto.ts b/api-v3/src/inspection-reports/dto/officialize-inspection-report.dto.ts new file mode 100644 index 0000000..d11e599 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/officialize-inspection-report.dto.ts @@ -0,0 +1,13 @@ +import { Transform } from 'class-transformer'; +import { IsISO8601, IsString, MaxLength, MinLength } from 'class-validator'; + +export class OfficializeInspectionReportDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(255) + gedoIfIdentifier!: string; + + @IsISO8601({ strict: true }) + gedoOfficializedAt!: string; +} diff --git a/api-v3/src/inspection-reports/dto/update-deadline-policy.dto.ts b/api-v3/src/inspection-reports/dto/update-deadline-policy.dto.ts new file mode 100644 index 0000000..4407473 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/update-deadline-policy.dto.ts @@ -0,0 +1,48 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { InspectionDeadlineDayType } from '../../database/entities'; + +export class UpdateDeadlinePolicyDto { + @Type(() => Number) + @IsInt() + @Min(1) + @Max(365) + urgentDays!: number; + + @IsEnum(InspectionDeadlineDayType) + urgentDayType!: InspectionDeadlineDayType; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(365) + nonUrgentDays!: number; + + @IsEnum(InspectionDeadlineDayType) + nonUrgentDayType!: InspectionDeadlineDayType; +} + +export class UpsertBusinessCalendarDayDto { + @IsDateString() + date!: string; + + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + isBusinessDay!: boolean; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(200) + label!: string; +} diff --git a/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts b/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts index 3ed84a0..b5be54d 100644 --- a/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts +++ b/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts @@ -1,2 +1,12 @@ -import { Transform } from 'class-transformer'; import { IsEmail,IsOptional,MaxLength } from 'class-validator'; -export class UpdateDocumentDeliverySettingsDto { @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) officeEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) directorEmail?:string|null; } +import { Transform } from 'class-transformer'; +import { IsEmail, IsOptional, MaxLength } from 'class-validator'; + +export class UpdateDocumentDeliverySettingsDto { + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null, + ) + @IsEmail() + @MaxLength(320) + officeEmail?: string | null; +} diff --git a/api-v3/src/inspection-reports/dto/update-inspection-report.dto.ts b/api-v3/src/inspection-reports/dto/update-inspection-report.dto.ts new file mode 100644 index 0000000..dc73883 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/update-inspection-report.dto.ts @@ -0,0 +1,16 @@ +import { Transform } from 'class-transformer'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateInspectionReportDto { + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value) + @IsString() + @MaxLength(20000) + executiveSummary?: string | null; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value) + @IsString() + @MaxLength(40000) + description?: string | null; +} diff --git a/api-v3/src/inspection-reports/dto/update-smtp-settings.dto.ts b/api-v3/src/inspection-reports/dto/update-smtp-settings.dto.ts new file mode 100644 index 0000000..8f70893 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/update-smtp-settings.dto.ts @@ -0,0 +1,71 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { SmtpSecurityMode } from '../../database/entities'; + +export class UpdateSmtpSettingsDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(255) + host!: string; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(65535) + port!: number; + + @IsEnum(SmtpSecurityMode) + securityMode!: SmtpSecurityMode; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value) + @IsString() + @MaxLength(255) + username?: string | null; + + /** Omitir para conservar la clave actual; enviar null/cadena vacía para quitarla. */ + @IsOptional() + @IsString() + @MaxLength(1000) + password?: string | null; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(200) + fromName!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value) + @IsEmail() + @MaxLength(320) + fromEmail!: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null) + @IsEmail() + @MaxLength(320) + replyTo?: string | null; + + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + enabled!: boolean; +} + +export class TestSmtpSettingsDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value) + @IsEmail() + @MaxLength(320) + email!: string; +} diff --git a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts index 7039abd..e058911 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts @@ -1,88 +1,212 @@ import { createHash } from 'node:crypto'; -function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } -function asArray(value: unknown): Array> { return Array.isArray(value) ? value.map(asRecord) : []; } -function text(value: unknown, fallback='-'): string { const out=String(value ?? '').trim(); return out || fallback; } -function date(value: unknown): string { const d=new Date(String(value ?? '')); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('es-AR') : '-'; } -function clean(value: string): string { return value.normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[–—]/g,'-').replace(/[“”]/g,'"').replace(/[‘’]/g,"'").replace(/[^\x20-\xFF]/g,'?'); } -function escapePdf(value: string): string { return clean(value).replaceAll('\\','\\\\').replaceAll('(','\\(').replaceAll(')','\\)'); } -function wrap(value: string, max=92): string[] { const words=clean(value).split(/\s+/).filter(Boolean); const out:string[]=[]; let line=''; for(const word of words){ const next=line?`${line} ${word}`:word; if(next.length>max&&line){out.push(line);line=word;}else line=next;} if(line)out.push(line); return out.length?out:['-']; } +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function asArray(value: unknown): Array> { + return Array.isArray(value) ? value.map(asRecord) : []; +} + +function text(value: unknown, fallback = '-'): string { + const out = String(value ?? '').trim(); + return out || fallback; +} + +function date(value: unknown): string { + const parsed = new Date(String(value ?? '')); + return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '-'; +} + +function clean(value: string): string { + return value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[–—]/g, '-') + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .replace(/[^\x20-\xFF]/g, '?'); +} + +function escapePdf(value: string): string { + return clean(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)'); +} + +function wrap(value: string, max = 92): string[] { + const words = clean(value).split(/\s+/).filter(Boolean); + const out: string[] = []; + let line = ''; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > max && line) { + out.push(line); + line = word; + } else { + line = next; + } + } + if (line) out.push(line); + return out.length ? out : ['-']; +} + +function lockedSnapshot(snapshot: Record): { + locked: Record; + signatures: Array>; + finalSha256: unknown; +} { + const sealed = asRecord(snapshot); + const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot); + return { + locked, + signatures: asArray(sealed.signatures), + finalSha256: sealed.finalSha256 ?? sealed.lockedSha256 ?? sealed.preparedSha256, + }; +} + +function urgencyLabel(value: unknown): string { + return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente'; +} + +function dayTypeLabel(value: unknown): string { + return text(value, '') === 'CALENDAR' ? 'dias corridos' : 'dias habiles'; +} function lines(snapshot: Record): string[] { - const prepared=asRecord(asRecord(snapshot).preparedSnapshot); - const act=asRecord(prepared.act); - const visit=asRecord(act.visit); - const responsible=asRecord(prepared.responsible); - const team=asArray(prepared.team); - const assets=asArray(prepared.assets); - const findings=asArray(prepared.findings); - const signatures=asArray(snapshot.signatures); - const companySignature=signatures.find(item=>text(item.signerType,'')==='COMPANY_RESPONSIBLE'); - const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))]; - const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))]; - const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean); - const out:string[]=[ + const source = lockedSnapshot(snapshot); + const locked = source.locked; + const act = asRecord(locked.act); + const inspection = asRecord(act.inspection ?? asRecord(act).visit); + const responsible = asRecord(locked.responsible); + const inventories = asArray(locked.inventories ?? locked.assets); + const findings = asArray(locked.findings); + const signatures = source.signatures; + const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE'); + const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR'); + + const deadlineText = act.deadlineAt + ? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})` + : act.deadlineBasis === 'GEDO_DATE' + ? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO` + : '-'; + + const out: string[] = [ 'ACTA DE INSPECCION', - 'Documento automatico - diseno institucional pendiente de definicion', '', `Acta: ${text(act.code)}`, - `Inspeccion: ${text(visit.code)}`, + `Inspeccion: ${text(inspection.code)}`, `Fecha: ${date(act.occurredAt)}`, - `Empresa: ${companies.join(' / ') || 'Segun alcance del acta'}`, - `Area: ${areas.join(' / ') || 'Segun alcance del acta'}`, - `Inspector/es: ${inspectors.join(' / ') || '-'}`, + `Urgencia: ${urgencyLabel(act.urgency)}`, + `Plazo: ${deadlineText}`, `Responsable empresa: ${text(responsible.fullName)}`, `Cargo: ${text(responsible.position)}`, '', - 'OBJETIVO', ...wrap(text(visit.objective)), '', - 'RESUMEN', ...wrap(text(act.summary)), '', - 'OBSERVACIONES', ...wrap(text(act.observations)), '', - 'ELEMENTOS INSPECCIONADOS', + 'RESUMEN', + ...wrap(text(act.summary)), + '', + 'OBSERVACIONES', + ...wrap(text(act.observations)), + '', + 'INVENTARIO INSPECCIONADO', ]; - if(!assets.length) out.push('-'); - for(const item of assets) out.push(...wrap(`${text(item.code)} | ${text(item.name)}${text(item.commonName,'') ? ` | ${text(item.commonName,'')}` : ''} | ${text(item.typeName)}`)); - out.push('', 'HALLAZGOS'); - if(!findings.length) out.push('Sin hallazgos registrados.'); - for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); } - out.push('', 'CONSTANCIA DE LA EMPRESA'); - if(!companySignature) out.push('Firma o constancia pendiente.'); - else if(text(companySignature.status,'')==='SIGNED'){ - const manifestation=text(companySignature.companyManifestation,'CONFORMITY'); - out.push(manifestation==='DISSENT'?'Firma en disidencia':'Firma en conformidad'); - if(manifestation==='DISSENT') out.push(...wrap(text(companySignature.companyStatement))); - } else { - out.push(...wrap(`${text(companySignature.status)}: ${text(companySignature.reason)}`)); + + if (!inventories.length) out.push('-'); + for (const item of inventories) { + out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`)); } - out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`); + + out.push('', 'HALLAZGOS'); + if (!findings.length) out.push('Sin hallazgos registrados.'); + for (const item of findings) { + const recurrence = item.isRecurrence + ? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}` + : ''; + out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`)); + out.push(...wrap(`Descripcion: ${text(item.description)}`)); + if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`)); + } + + out.push('', 'FIRMAS Y CONSTANCIAS'); + if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.'); + for (const signature of inspectorSignatures) { + out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`)); + } + if (!companySignature) { + out.push('Manifestacion de empresa: pendiente.'); + } else if (text(companySignature.status, '') === 'SIGNED') { + const manifestation = text(companySignature.companyManifestation, 'CONFORMITY'); + out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disidencia' : 'Empresa: firma en conformidad'); + if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement))); + } else { + out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`)); + } + + out.push( + '', + 'INTEGRIDAD', + `Hash del Acta sellada: ${text(source.finalSha256)}`, + `Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`, + ); return out; } -function objectBuffer(id: number, body: Buffer|string): Buffer { const data=Buffer.isBuffer(body)?body:Buffer.from(body,'latin1'); return Buffer.concat([Buffer.from(`${id} 0 obj\n`,'ascii'),data,Buffer.from('\nendobj\n','ascii')]); } +function objectBuffer(id: number, body: Buffer | string): Buffer { + const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1'); + return Buffer.concat([ + Buffer.from(`${id} 0 obj\n`, 'ascii'), + data, + Buffer.from('\nendobj\n', 'ascii'), + ]); +} export function buildInspectionActPdf(snapshot: Record): { buffer: Buffer; sha256: string } { - const all=lines(snapshot); - const chunks:Array=[]; - for(let i=0;i4+i*2); - const contentIds=Array.from({length:pageCount},(_,i)=>5+i*2); - const objects:Buffer[]=[]; - objects.push(objectBuffer(1,'<< /Type /Catalog /Pages 2 0 R >>')); - objects.push(objectBuffer(2,`<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map(id=>`${id} 0 R`).join(' ')}] >>`)); - objects.push(objectBuffer(3,'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')); - chunks.forEach((chunk,index)=>{ - const content=chunk.map((line,i)=>`${i===0?'':'T* '}(${escapePdf(line)}) Tj`).join('\n'); - const stream=Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`,'latin1'); - objects.push(objectBuffer(pageIds[index]!,`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`)); - objects.push(objectBuffer(contentIds[index]!,Buffer.concat([Buffer.from(`<< /Length ${stream.length} >>\nstream\n`,'ascii'),stream,Buffer.from('\nendstream','ascii')]))); + const all = lines(snapshot); + const chunks: Array = []; + for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56)); + if (!chunks.length) chunks.push(['ACTA DE INSPECCION']); + + const pageCount = chunks.length; + const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2); + const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2); + const objects: Buffer[] = []; + objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>')); + objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`)); + objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')); + + chunks.forEach((chunk, index) => { + const content = chunk + .map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`) + .join('\n'); + const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1'); + objects.push(objectBuffer( + pageIds[index]!, + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`, + )); + objects.push(objectBuffer( + contentIds[index]!, + Buffer.concat([ + Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'), + stream, + Buffer.from('\nendstream', 'ascii'), + ]), + )); }); - const header=Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n','binary'); - const offsets:number[]=[0]; let pos=header.length; - for(const obj of objects){ offsets.push(pos); pos+=obj.length; } - const xrefOffset=pos; - const xref=[`xref\n0 ${objects.length+1}\n`,`0000000000 65535 f \n`,...objects.map((_,i)=>`${String(offsets[i+1]).padStart(10,'0')} 00000 n \n`)].join(''); - const trailer=`trailer\n<< /Size ${objects.length+1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; - const buffer=Buffer.concat([header,...objects,Buffer.from(xref+trailer,'ascii')]); - return { buffer, sha256:createHash('sha256').update(buffer).digest('hex') }; + + const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'); + const offsets: number[] = [0]; + let position = header.length; + for (const object of objects) { + offsets.push(position); + position += object.length; + } + const xrefOffset = position; + const xref = [ + `xref\n0 ${objects.length + 1}\n`, + '0000000000 65535 f \n', + ...objects.map((_, index) => `${String(offsets[index + 1]).padStart(10, '0')} 00000 n \n`), + ].join(''); + const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + const buffer = Buffer.concat([header, ...objects, Buffer.from(xref + trailer, 'ascii')]); + return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; } diff --git a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts index 056731a..22cef80 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts @@ -8,20 +8,112 @@ import { buildInspectionActPdf } from './inspection-act-pdf-builder'; @Injectable() export class InspectionActPdfService { - private readonly root:string; - constructor(private readonly dataSource:DataSource, config:ConfigService){ const configured=config.get('INSPECTION_ACT_PDF_ROOT')??'/app/storage/asset-media/inspection-acts-pdf'; if(!isAbsolute(configured))throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); this.root=resolve(configured); if(this.root===parse(this.root).root)throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); } + private readonly root: string; - async ensure(actId:string):Promise{ - const [row]=await this.dataSource.query(`SELECT a.id,a.code,a.closure_sha256 AS "closureSha256",c.final_snapshot AS "finalSnapshot",p.status,p.stored_name AS "storedName" FROM inspection_acts a JOIN inspection_act_closures c ON c.act_id=a.id LEFT JOIN inspection_act_pdf_artifacts p ON p.act_id=a.id WHERE a.id=$1 AND a.status='CLOSED'`,[actId]) as Array<{id:string;code:string;closureSha256:string;finalSnapshot:Record;status:string|null;storedName:string|null}>; - if(!row)throw new NotFoundException({code:'INSPECTION_ACT_NOT_CLOSED',message:'El acta cerrada no está disponible'}); - if(row.status==='READY'&&row.storedName){ try{ await this.content(actId); return; }catch{} } - await this.dataSource.query(`INSERT INTO inspection_act_pdf_artifacts (act_id,status) VALUES ($1,'PENDING') ON CONFLICT (act_id) DO UPDATE SET status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP`,[actId]); - try{ - const snapshot={...row.finalSnapshot,finalSha256:row.closureSha256}; const built=buildInspectionActPdf(snapshot); await mkdir(this.root,{recursive:true,mode:0o700}); const storedName=`${actId}.pdf`; const originalName=`${row.code}.pdf`; const filePath=resolve(this.root,storedName); await writeFile(filePath,built.buffer,{mode:0o600}); - await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf',size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,originalName,storedName,built.buffer.length,built.sha256]); - }catch(error){ const message=error instanceof Error?error.message.slice(0,500):'Error desconocido'; await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,message]).catch(()=>undefined); } + constructor(private readonly dataSource: DataSource, config: ConfigService) { + const configured = config.get('INSPECTION_ACT_PDF_ROOT') + ?? '/app/storage/asset-media/inspection-acts-pdf'; + if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); + this.root = resolve(configured); + if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); } - async content(actId:string):Promise<{buffer:Buffer;originalName:string;mimeType:string}>{ const [row]=await this.dataSource.query(`SELECT original_name AS "originalName",stored_name AS "storedName",mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 FROM inspection_act_pdf_artifacts WHERE act_id=$1 AND status='READY'`,[actId]) as Array<{originalName:string;storedName:string;mimeType:string;sizeBytes:number;sha256:string}>; if(!row)throw new NotFoundException({code:'INSPECTION_ACT_PDF_NOT_READY',message:'El PDF del acta todavía no está disponible'}); const filePath=resolve(this.root,row.storedName); if(!filePath.startsWith(`${this.root}/`))throw this.storageError(); const st=await stat(filePath).catch(()=>null); if(!st?.isFile()||st.size!==row.sizeBytes)throw this.storageError(); const buffer=await readFile(filePath); if(createHash('sha256').update(buffer).digest('hex')!==row.sha256)throw this.storageError(); return {buffer,originalName:row.originalName,mimeType:row.mimeType}; } - private storageError(){return new InternalServerErrorException({code:'INSPECTION_ACT_PDF_STORAGE_ERROR',message:'El PDF del acta no está disponible o no supera la validación de integridad'});} + async ensure(actId: string): Promise { + const [row] = await this.dataSource.query(` + SELECT + act.id, + act.code, + act.closure_sha256 AS "closureSha256", + closure.final_snapshot AS "finalSnapshot", + artifact.status, + artifact.stored_name AS "storedName" + FROM inspection_acts act + JOIN inspection_act_closures closure ON closure.act_id=act.id + LEFT JOIN inspection_act_pdf_artifacts artifact ON artifact.act_id=act.id + WHERE act.id=$1 AND act.status='SEALED' + `, [actId]) as Array<{ + id: string; + code: string; + closureSha256: string; + finalSnapshot: Record; + status: string | null; + storedName: string | null; + }>; + if (!row) { + throw new NotFoundException({ + code: 'INSPECTION_ACT_NOT_SEALED', + message: 'El PDF sólo puede generarse desde un Acta SELLADA', + }); + } + if (row.status === 'READY' && row.storedName) { + try { + await this.content(actId); + return; + } catch {} + } + await this.dataSource.query(` + INSERT INTO inspection_act_pdf_artifacts(act_id,status) + VALUES($1,'PENDING') + ON CONFLICT (act_id) DO UPDATE SET + status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP + `, [actId]); + try { + const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 }; + const built = buildInspectionActPdf(snapshot); + await mkdir(this.root, { recursive: true, mode: 0o700 }); + const storedName = `${actId}.pdf`; + const originalName = `${row.code}.pdf`; + const filePath = resolve(this.root, storedName); + await writeFile(filePath, built.buffer, { mode: 0o600 }); + await this.dataSource.query(` + UPDATE inspection_act_pdf_artifacts + SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf', + size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL, + updated_at=CURRENT_TIMESTAMP + WHERE act_id=$1 + `, [actId, originalName, storedName, built.buffer.length, built.sha256]); + } catch (error) { + const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido'; + await this.dataSource.query(` + UPDATE inspection_act_pdf_artifacts + SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP + WHERE act_id=$1 + `, [actId, message]).catch(() => undefined); + } + } + + async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> { + const [row] = await this.dataSource.query(` + SELECT original_name AS "originalName",stored_name AS "storedName", + mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 + FROM inspection_act_pdf_artifacts + WHERE act_id=$1 AND status='READY' + `, [actId]) as Array<{ + originalName: string; + storedName: string; + mimeType: string; + sizeBytes: number; + sha256: string; + }>; + if (!row) { + throw new NotFoundException({ + code: 'INSPECTION_ACT_PDF_NOT_READY', + message: 'El PDF del Acta todavía no está disponible', + }); + } + const filePath = resolve(this.root, row.storedName); + if (!filePath.startsWith(`${this.root}/`)) throw this.storageError(); + const fileStat = await stat(filePath).catch(() => null); + if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.storageError(); + const buffer = await readFile(filePath); + if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError(); + return { buffer, originalName: row.originalName, mimeType: row.mimeType }; + } + + private storageError(): InternalServerErrorException { + return new InternalServerErrorException({ + code: 'INSPECTION_ACT_PDF_STORAGE_ERROR', + message: 'El PDF del Acta no está disponible o no supera la validación de integridad', + }); + } } diff --git a/api-v3/src/inspection-reports/inspection-deadline-admin.controller.ts b/api-v3/src/inspection-reports/inspection-deadline-admin.controller.ts new file mode 100644 index 0000000..4869ec6 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-deadline-admin.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Delete, Get, Param, Put, Req } from '@nestjs/common'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { UpdateDeadlinePolicyDto, UpsertBusinessCalendarDayDto } from './dto/update-deadline-policy.dto'; +import { InspectionDeadlineAdminService } from './inspection-deadline-admin.service'; + +@Controller('inspection-deadlines') +export class InspectionDeadlineAdminController { + constructor(private readonly deadlines: InspectionDeadlineAdminService) {} + + @Get('policy') + @RequirePermissions('inspections.read') + policy() { + return this.deadlines.policy(); + } + + @Put('policy') + @RequirePermissions('inspections.manage') + updatePolicy( + @Body() dto: UpdateDeadlinePolicyDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.deadlines.updatePolicy(dto, principal, request); + } + + @Get('calendar') + @RequirePermissions('inspections.read') + calendar() { + return this.deadlines.calendar(); + } + + @Put('calendar') + @RequirePermissions('inspections.manage') + upsertDay( + @Body() dto: UpsertBusinessCalendarDayDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.deadlines.upsertCalendarDay(dto, principal, request); + } + + @Delete('calendar/:date') + @RequirePermissions('inspections.manage') + removeDay( + @Param('date') date: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.deadlines.removeCalendarDay(date, principal, request); + } +} diff --git a/api-v3/src/inspection-reports/inspection-deadline-admin.service.ts b/api-v3/src/inspection-reports/inspection-deadline-admin.service.ts new file mode 100644 index 0000000..d4e62aa --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-deadline-admin.service.ts @@ -0,0 +1,160 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AuditAction } from '../database/entities'; +import type { UpdateDeadlinePolicyDto, UpsertBusinessCalendarDayDto } from './dto/update-deadline-policy.dto'; + +@Injectable() +export class InspectionDeadlineAdminService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async policy() { + const [row] = await this.dataSource.query(` + SELECT id, + urgent_days AS "urgentDays", + urgent_day_type AS "urgentDayType", + non_urgent_days AS "nonUrgentDays", + non_urgent_day_type AS "nonUrgentDayType", + updated_by AS "updatedBy", + updated_at AS "updatedAt" + FROM inspection_deadline_policies + ORDER BY created_at + LIMIT 1 + `); + return row ?? { + urgentDays: 5, + urgentDayType: 'BUSINESS', + nonUrgentDays: 10, + nonUrgentDayType: 'BUSINESS', + }; + } + + async updatePolicy( + dto: UpdateDeadlinePolicyDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const before = await this.policy(); + const [existing] = await this.dataSource.query(` + SELECT id FROM inspection_deadline_policies ORDER BY created_at LIMIT 1 + `) as Array<{ id: string }>; + if (existing) { + await this.dataSource.query(` + UPDATE inspection_deadline_policies + SET urgent_days=$2, + urgent_day_type=$3, + non_urgent_days=$4, + non_urgent_day_type=$5, + updated_by=$6, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [ + existing.id, + dto.urgentDays, + dto.urgentDayType, + dto.nonUrgentDays, + dto.nonUrgentDayType, + principal.userId, + ]); + } else { + await this.dataSource.query(` + INSERT INTO inspection_deadline_policies( + urgent_days,urgent_day_type,non_urgent_days,non_urgent_day_type,updated_by + ) VALUES($1,$2,$3,$4,$5) + `, [ + dto.urgentDays, + dto.urgentDayType, + dto.nonUrgentDays, + dto.nonUrgentDayType, + principal.userId, + ]); + } + const after = await this.policy(); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_DEADLINE_POLICY_UPDATED, + entityType: 'inspection_deadline_policy', + entityId: String((after as { id?: string }).id ?? 'singleton'), + beforeData: before as Record, + afterData: after as Record, + metadata: { + appliesOnlyToFutureLockedActs: true, + historicalActsKeepSnapshot: true, + }, + }); + return after; + } + + async calendar() { + const data = await this.dataSource.query(` + SELECT id,date,is_business_day AS "isBusinessDay",label, + updated_by AS "updatedBy",created_at AS "createdAt",updated_at AS "updatedAt" + FROM inspection_business_calendar_days + ORDER BY date + `); + return { data }; + } + + async upsertCalendarDay( + dto: UpsertBusinessCalendarDayDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const [before] = await this.dataSource.query(` + SELECT id,date,is_business_day AS "isBusinessDay",label + FROM inspection_business_calendar_days WHERE date=$1::date + `, [dto.date]); + await this.dataSource.query(` + INSERT INTO inspection_business_calendar_days(date,is_business_day,label,updated_by) + VALUES($1::date,$2,$3,$4) + ON CONFLICT(date) DO UPDATE SET + is_business_day=EXCLUDED.is_business_day, + label=EXCLUDED.label, + updated_by=EXCLUDED.updated_by, + updated_at=CURRENT_TIMESTAMP + `, [dto.date, dto.isBusinessDay, dto.label, principal.userId]); + const [after] = await this.dataSource.query(` + SELECT id,date,is_business_day AS "isBusinessDay",label, + updated_by AS "updatedBy",updated_at AS "updatedAt" + FROM inspection_business_calendar_days WHERE date=$1::date + `, [dto.date]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_BUSINESS_CALENDAR_UPDATED, + entityType: 'inspection_business_calendar_day', + entityId: String(after.id), + beforeData: before ?? null, + afterData: after, + }); + return after; + } + + async removeCalendarDay( + date: string, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const [before] = await this.dataSource.query(` + SELECT id,date,is_business_day AS "isBusinessDay",label + FROM inspection_business_calendar_days WHERE date=$1::date + `, [date]); + if (before) { + await this.dataSource.query(`DELETE FROM inspection_business_calendar_days WHERE id=$1`, [before.id]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_BUSINESS_CALENDAR_UPDATED, + entityType: 'inspection_business_calendar_day', + entityId: String(before.id), + beforeData: before, + afterData: null, + metadata: { removed: true }, + }); + } + return { removed: Boolean(before), date }; + } +} diff --git a/api-v3/src/inspection-reports/inspection-document-delivery.service.ts b/api-v3/src/inspection-reports/inspection-document-delivery.service.ts index d855648..7b92038 100644 --- a/api-v3/src/inspection-reports/inspection-document-delivery.service.ts +++ b/api-v3/src/inspection-reports/inspection-document-delivery.service.ts @@ -10,7 +10,7 @@ import { InspectionActPdfService } from './inspection-act-pdf.service'; import { InspectionReportWordService } from './inspection-report-word.service'; import { SmtpDeliveryService } from './smtp-delivery.service'; -export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR'; +export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR'; export interface DeliveryRow { id: string; @@ -40,21 +40,14 @@ export class InspectionDocumentDeliveryService { async settings() { const [row] = await this.dataSource.query(` - SELECT - office_email AS "officeEmail", - director_email AS "directorEmail", - updated_at AS "updatedAt" + SELECT office_email AS "officeEmail",updated_at AS "updatedAt" FROM institutional_delivery_settings WHERE id=1 - `) as Array<{ - officeEmail: string | null; - directorEmail: string | null; - updatedAt: Date; - }>; + `) as Array<{ officeEmail: string | null; updatedAt: Date }>; return { ...row, - smtpConfigured: this.smtp.configured(), - mailFrom: this.config.get('MAIL_FROM') ?? null, + smtpConfigured: await this.smtp.configured(), + mailFrom: await this.smtp.fromAddress() ?? this.config.get('MAIL_FROM') ?? null, }; } @@ -66,9 +59,9 @@ export class InspectionDocumentDeliveryService { const before = await this.settings(); await this.dataSource.query(` UPDATE institutional_delivery_settings - SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP + SET office_email=$1,updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=1 - `, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]); + `, [dto.officeEmail ?? null, principal.userId]); const after = await this.settings(); await this.audit.record({ ...administrationAuditContext(principal, request), @@ -84,33 +77,23 @@ export class InspectionDocumentDeliveryService { async list() { const data = await this.dataSource.query(` SELECT - d.id, - d.act_id AS "actId", - d.report_id AS "reportId", - d.document_kind AS "documentKind", - d.recipient_kind AS "recipientKind", - d.recipient_asset_id AS "recipientAssetId", - d.recipient_user_id AS "recipientUserId", - d.recipient_email AS "recipientEmail", - d.status, - d.attempts, - d.last_attempt_at AS "lastAttemptAt", - d.sent_at AS "sentAt", - d.provider_message_id AS "providerMessageId", - d.last_error AS "lastError", - d.created_at AS "createdAt", - a.code AS "actCode", - r.code AS "reportCode", + d.id,d.act_id AS "actId",d.report_id AS "reportId", + d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", + d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", + d.recipient_email AS "recipientEmail",d.status,d.attempts, + d.last_attempt_at AS "lastAttemptAt",d.sent_at AS "sentAt", + d.provider_message_id AS "providerMessageId",d.last_error AS "lastError", + d.created_at AS "createdAt",a.code AS "actCode",r.code AS "reportCode", recipient.name AS "recipientAssetName", - CASE - WHEN recipient_user.id IS NULL THEN NULL - ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name)) + CASE WHEN recipient_user.id IS NULL THEN NULL + ELSE btrim(concat_ws(' ',recipient_user.first_name,recipient_user.last_name)) END AS "recipientUserName" FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id + WHERE d.recipient_kind<>'DIRECTOR' ORDER BY d.created_at DESC LIMIT 200 `); @@ -154,11 +137,9 @@ export class InspectionDocumentDeliveryService { async retryPending(principal: AuthPrincipal, request: RequestWithContext) { const rows = await this.dataSource.query(` - SELECT id - FROM inspection_document_deliveries - WHERE status<>'SENT' - ORDER BY created_at ASC - LIMIT 100 + SELECT id FROM inspection_document_deliveries + WHERE status<>'SENT' AND recipient_kind<>'DIRECTOR' + ORDER BY created_at ASC LIMIT 100 `) as Array<{ id: string }>; for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined); return { processed: rows.length }; @@ -166,10 +147,9 @@ export class InspectionDocumentDeliveryService { private async ensureRows(actId: string, reportId: string | null) { const [settings] = await this.dataSource.query(` - SELECT office_email AS "officeEmail",director_email AS "directorEmail" - FROM institutional_delivery_settings - WHERE id=1 - `) as Array<{ officeEmail: string | null; directorEmail: string | null }>; + SELECT office_email AS "officeEmail" + FROM institutional_delivery_settings WHERE id=1 + `) as Array<{ officeEmail: string | null }>; const companies = await this.dataSource.query(` SELECT DISTINCT company.id,profile.notification_email AS email @@ -185,9 +165,7 @@ export class InspectionDocumentDeliveryService { `, [actId]) as Array<{ id: string; email: string | null }>; const [inspector] = await this.dataSource.query(` - SELECT - inspector.id, - inspector.email + SELECT inspector.id,inspector.email FROM inspection_acts act JOIN inspection_visits visit ON visit.id=act.visit_id JOIN users inspector ON inspector.id=visit.lead_inspector_user_id @@ -196,52 +174,32 @@ export class InspectionDocumentDeliveryService { for (const company of companies) { await this.upsertRow({ - actId, - reportId, - documentKind: 'ACT_PDF', - recipientKind: 'COMPANY', - recipientAssetId: company.id, - recipientUserId: null, - recipientKey: company.id, - recipientEmail: company.email, + actId,reportId,documentKind:'ACT_PDF',recipientKind:'COMPANY', + recipientAssetId:company.id,recipientUserId:null,recipientKey:company.id, + recipientEmail:company.email, }); } await this.upsertRow({ - actId, - reportId, - documentKind: 'ACT_PDF', - recipientKind: 'OFFICE', - recipientAssetId: null, - recipientUserId: null, - recipientKey: '00000000-0000-0000-0000-000000000000', - recipientEmail: settings?.officeEmail ?? null, + actId,reportId,documentKind:'ACT_PDF',recipientKind:'OFFICE', + recipientAssetId:null,recipientUserId:null, + recipientKey:'00000000-0000-0000-0000-000000000000', + recipientEmail:settings?.officeEmail ?? null, }); if (inspector) { await this.upsertRow({ - actId, - reportId, - documentKind: 'ACT_PDF', - recipientKind: 'INSPECTOR', - recipientAssetId: null, - recipientUserId: inspector.id, - recipientKey: inspector.id, - recipientEmail: inspector.email, - }); - } - - if (reportId) { - await this.upsertRow({ - actId, - reportId, - documentKind: 'REPORT_WORD', - recipientKind: 'DIRECTOR', - recipientAssetId: null, - recipientUserId: null, - recipientKey: '00000000-0000-0000-0000-000000000000', - recipientEmail: settings?.directorEmail ?? null, + actId,reportId,documentKind:'ACT_PDF',recipientKind:'INSPECTOR', + recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id, + recipientEmail:inspector.email, }); + if (reportId) { + await this.upsertRow({ + actId,reportId,documentKind:'REPORT_WORD',recipientKind:'INSPECTOR', + recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id, + recipientEmail:inspector.email, + }); + } } } @@ -267,31 +225,21 @@ export class InspectionDocumentDeliveryService { recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id), recipient_email=CASE WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email - ELSE EXCLUDED.recipient_email - END, + ELSE EXCLUDED.recipient_email END, status=CASE WHEN inspection_document_deliveries.status='SENT' THEN 'SENT' WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT' - ELSE inspection_document_deliveries.status - END, + ELSE inspection_document_deliveries.status END, updated_at=CURRENT_TIMESTAMP `, [ - input.actId, - input.reportId, - input.documentKind, - input.recipientKind, - input.recipientAssetId, - input.recipientUserId, - input.recipientKey, - input.recipientEmail, - initialStatus, + input.actId,input.reportId,input.documentKind,input.recipientKind, + input.recipientAssetId,input.recipientUserId,input.recipientKey,input.recipientEmail,initialStatus, ]); } private async rowsForAct(actId: string): Promise { return this.dataSource.query(` - SELECT - d.id,d.act_id AS "actId",d.report_id AS "reportId", + SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId", d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", d.recipient_email AS "recipientEmail",d.status,d.attempts, @@ -299,15 +247,14 @@ export class InspectionDocumentDeliveryService { FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id - WHERE d.act_id=$1 + WHERE d.act_id=$1 AND d.recipient_kind<>'DIRECTOR' ORDER BY d.created_at `, [actId]) as Promise; } private async load(id: string): Promise { const [row] = await this.dataSource.query(` - SELECT - d.id,d.act_id AS "actId",d.report_id AS "reportId", + SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId", d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", d.recipient_email AS "recipientEmail",d.status,d.attempts, @@ -315,12 +262,11 @@ export class InspectionDocumentDeliveryService { FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id - WHERE d.id=$1 + WHERE d.id=$1 AND d.recipient_kind<>'DIRECTOR' `, [id]) as DeliveryRow[]; if (!row) { throw new NotFoundException({ - code: 'DOCUMENT_DELIVERY_NOT_FOUND', - message: 'Entrega documental no encontrada', + code:'DOCUMENT_DELIVERY_NOT_FOUND',message:'Entrega documental no encontrada', }); } return row; @@ -330,47 +276,37 @@ export class InspectionDocumentDeliveryService { let email: string | null = null; if (row.recipientKind === 'COMPANY' && row.recipientAssetId) { const [company] = await this.dataSource.query(` - SELECT notification_email AS email - FROM organization_profiles - WHERE asset_id=$1 + SELECT notification_email AS email FROM organization_profiles WHERE asset_id=$1 `, [row.recipientAssetId]) as Array<{ email: string | null }>; email = company?.email ?? null; } else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) { const [inspector] = await this.dataSource.query(` - SELECT email - FROM users - WHERE id=$1 AND is_active=true + SELECT email FROM users WHERE id=$1 AND is_active=true `, [row.recipientUserId]) as Array<{ email: string | null }>; email = inspector?.email ?? null; } else { const [settings] = await this.dataSource.query(` - SELECT office_email AS "officeEmail",director_email AS "directorEmail" - FROM institutional_delivery_settings - WHERE id=1 - `) as Array<{ officeEmail: string | null; directorEmail: string | null }>; - email = row.recipientKind === 'OFFICE' - ? settings?.officeEmail ?? null - : settings?.directorEmail ?? null; + SELECT office_email AS "officeEmail" FROM institutional_delivery_settings WHERE id=1 + `) as Array<{ officeEmail: string | null }>; + email = settings?.officeEmail ?? null; } - await this.dataSource.query(` UPDATE inspection_document_deliveries SET recipient_email=$2, status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END, - last_error=NULL, - updated_at=CURRENT_TIMESTAMP + last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1 AND status<>'SENT' - `, [row.id, email]); + `, [row.id,email]); } private async attempt(row: DeliveryRow) { if (row.status === 'SENT') return; if (!row.recipientEmail) { - await this.setStatus(row.id, 'WAITING_RECIPIENT', 'Destinatario no configurado'); + await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado'); return; } - if (!this.smtp.configured()) { - await this.setStatus(row.id, 'WAITING_TRANSPORT', 'SMTP no configurado'); + if (!await this.smtp.configured()) { + await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado'); return; } @@ -379,28 +315,18 @@ export class InspectionDocumentDeliveryService { if (row.documentKind === 'ACT_PDF') { await this.pdf.ensure(row.actId); const file = await this.pdf.content(row.actId); - attachment = { - filename: file.originalName, - mimeType: file.mimeType, - content: file.buffer, - }; + attachment = { filename:file.originalName,mimeType:file.mimeType,content:file.buffer }; } else { if (!row.reportId) throw new Error('Informe no vinculado'); await this.word.ensure(row.reportId); const file = await this.word.content(row.reportId); const { readFile } = await import('node:fs/promises'); attachment = { - filename: file.originalName, - mimeType: file.mimeType, - content: await readFile(file.filePath), + filename:file.originalName,mimeType:file.mimeType,content:await readFile(file.filePath), }; } } catch (error) { - await this.setStatus( - row.id, - 'WAITING_ARTIFACT', - error instanceof Error ? error.message : 'Documento no disponible', - ); + await this.setStatus(row.id,'WAITING_ARTIFACT',error instanceof Error ? error.message : 'Documento no disponible'); return; } @@ -409,62 +335,41 @@ export class InspectionDocumentDeliveryService { SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING', last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1 - `, [row.id]); + `,[row.id]); try { const label = row.documentKind === 'ACT_PDF' ? `Acta ${row.actCode}` : `Informe ${row.reportCode ?? ''}`; const text = row.documentKind === 'REPORT_WORD' - ? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.` + ? `Se adjunta el INF editable ${row.reportCode ?? ''} para su revisión y preparación antes de cargarlo en GEDO.` : row.recipientKind === 'INSPECTOR' - ? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.` - : `Se adjunta el acta cerrada e inmutable ${row.actCode}.`; + ? `Se adjunta copia del acta sellada e inmutable ${row.actCode} correspondiente a tu inspección.` + : `Se adjunta el acta sellada e inmutable ${row.actCode}.`; const sent = await this.smtp.send({ - to: row.recipientEmail, - subject: `DH Inspección · ${label}`, - text, - attachment, + to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment, }); await this.dataSource.query(` UPDATE inspection_document_deliveries SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2, - last_error=NULL,updated_at=CURRENT_TIMESTAMP - WHERE id=$1 - `, [row.id, sent.messageId]); + last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1 + `,[row.id,sent.messageId]); await this.audit.record({ - action: AuditAction.DOCUMENT_DELIVERY_SENT, - entityType: 'inspection_document_delivery', - entityId: row.id, - source: AuditSource.SYSTEM, - actorUserId: null, - actorUsername: null, - requestId: null, - ip: null, - userAgent: null, - beforeData: null, - afterData: { - recipientKind: row.recipientKind, - recipientUserId: row.recipientUserId, - documentKind: row.documentKind, - status: 'SENT', - }, - metadata: { actId: row.actId, reportId: row.reportId }, + action:AuditAction.DOCUMENT_DELIVERY_SENT, + entityType:'inspection_document_delivery',entityId:row.id,source:AuditSource.SYSTEM, + actorUserId:null,actorUsername:null,requestId:null,ip:null,userAgent:null,beforeData:null, + afterData:{recipientKind:row.recipientKind,recipientUserId:row.recipientUserId,documentKind:row.documentKind,status:'SENT'}, + metadata:{actId:row.actId,reportId:row.reportId}, }); } catch (error) { - await this.setStatus( - row.id, - 'FAILED', - error instanceof Error ? error.message : 'Error de entrega', - ); + await this.setStatus(row.id,'FAILED',error instanceof Error ? error.message : 'Error de entrega'); } } - private async setStatus(id: string, status: string, error: string) { + private async setStatus(id: string,status: string,error: string) { await this.dataSource.query(` UPDATE inspection_document_deliveries - SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP - WHERE id=$1 - `, [id, status, error.slice(0, 500)]); + SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$1 + `,[id,status,error.slice(0,500)]); } } diff --git a/api-v3/src/inspection-reports/inspection-report-review.controller.ts b/api-v3/src/inspection-reports/inspection-report-review.controller.ts deleted file mode 100644 index 0e397bd..0000000 --- a/api-v3/src/inspection-reports/inspection-report-review.controller.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import type { Response } from 'express'; -import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; -import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; -import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto'; -import { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto'; -import { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto'; -import { - MAX_INSPECTION_REPORT_REVISION_BYTES, - type UploadedInspectionReportRevisionFile, -} from './inspection-report-revision-file'; -import { - InspectionReportReviewService, - type InspectionReportReviewView, -} from './inspection-report-review.service'; - -@Controller('inspection-reports/:reportId/review') -export class InspectionReportReviewController { - constructor(private readonly review: InspectionReportReviewService) {} - - @Get() - @RequirePermissions('inspection_reports.read') - get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string): Promise { - return this.review.get(reportId); - } - - @Post('revisions') - @RequirePermissions('inspection_reports.revise') - @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_INSPECTION_REPORT_REVISION_BYTES, files: 1 } })) - revision( - @Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string, - @Body() dto: CreateInspectionReportRevisionDto, - @UploadedFile() file: UploadedInspectionReportRevisionFile | undefined, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ): Promise { - return this.review.createRevision(reportId, dto, file, principal, request); - } - - @Post('approve') - @RequirePermissions('inspection_reports.review') - approve( - @Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string, - @Body() dto: ApproveInspectionReportDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ): Promise { - return this.review.approve(reportId, dto, principal, request); - } - - @Post('sign-final') - @RequirePermissions('inspection_reports.sign_final') - signFinal( - @Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string, - @Body() dto: SignFinalInspectionReportDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ): Promise { - return this.review.signFinal(reportId, dto, principal, request); - } -} - -@Controller('inspection-report-revisions') -export class InspectionReportRevisionContentController { - constructor(private readonly review: InspectionReportReviewService) {} - - @Get(':revisionId/content') - @RequirePermissions('inspection_reports.read') - async content( - @Param('revisionId', new ParseUUIDPipe({ version: '4' })) revisionId: string, - @Res() response: Response, - ): Promise { - const content = await this.review.revisionContent(revisionId); - response.setHeader('Content-Type', content.mimeType); - response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`); - response.setHeader('Cache-Control', 'private, no-store'); - response.setHeader('X-Content-Type-Options', 'nosniff'); - await new Promise((resolveSend, rejectSend) => { - response.sendFile(content.filePath, (error) => { - if (error) rejectSend(error); - else resolveSend(); - }); - }); - } -} diff --git a/api-v3/src/inspection-reports/inspection-report-review.service.ts b/api-v3/src/inspection-reports/inspection-report-review.service.ts deleted file mode 100644 index c1991fd..0000000 --- a/api-v3/src/inspection-reports/inspection-report-review.service.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { isAbsolute, parse, resolve } from 'node:path'; -import { ConflictException, ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { DataSource, EntityManager } from 'typeorm'; -import { administrationAuditContext } from '../administration/common/administration-audit'; -import { AuditService } from '../audit/audit.service'; -import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { AuditAction } from '../database/entities'; -import { sha256CanonicalJson } from '../inspection-closing/canonical-json'; -import type { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto'; -import type { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto'; -import type { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto'; -import { - INSPECTION_REPORT_WORD_MIME, - inspectInspectionReportRevisionUpload, - type UploadedInspectionReportRevisionFile, - validateInspectionReportRevisionContainer, -} from './inspection-report-revision-file'; -import { InspectionReportWordService } from './inspection-report-word.service'; - -export type InspectionReportReviewStatus = 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED'; -export type InspectionReportRevisionSource = 'AUTO' | 'DIRECTOR_UPLOAD'; - -export interface InspectionReportReviewPerson { - id: string; - username: string; - firstName: string; - lastName: string; -} - -export interface InspectionReportRevisionView { - id: string; - reportId: string; - revisionNumber: number; - source: InspectionReportRevisionSource; - originalName: string; - mimeType: string; - sizeBytes: number; - sha256: string; - changeSummary: string | null; - createdAt: Date; - createdBy: InspectionReportReviewPerson; -} - -export interface InspectionReportSignatureView { - id: string; - revisionId: string; - signedAt: Date; - confirmationText: string; - signatureSha256: string; - signedBy: InspectionReportReviewPerson; -} - -export interface InspectionReportReviewView { - reportId: string; - reportCode: string; - reviewStatus: InspectionReportReviewStatus; - currentRevisionNumber: number; - approvedRevisionId: string | null; - approvedAt: Date | null; - reviewNote: string | null; - approvedBy: InspectionReportReviewPerson | null; - signature: InspectionReportSignatureView | null; - revisions: InspectionReportRevisionView[]; -} - -interface LockedReport { - id: string; - code: string; - status: string; - frozenSha256: string; - reviewStatus: InspectionReportReviewStatus; - currentRevisionNumber: number; - approvedRevisionId: string | null; - approvedAt: Date | null; - reviewNote: string | null; -} - -interface StoredRevisionRow { - id: string; - reportId: string; - revisionNumber: number; - source: InspectionReportRevisionSource; - originalName: string; - storedName: string; - mimeType: string; - sizeBytes: number; - sha256: string; -} - -const FINAL_CONFIRMATION_TEXT = 'Confirmo que revisé la versión aprobada y firmo electrónicamente el informe final como Director de Hidrocarburos.'; - -@Injectable() -export class InspectionReportReviewService { - private readonly revisionRoot: string; - - constructor( - private readonly dataSource: DataSource, - private readonly audit: AuditService, - private readonly word: InspectionReportWordService, - config: ConfigService, - ) { - const configured = config.get('INSPECTION_REPORT_REVISION_ROOT') ?? '/app/storage/asset-media/inspection-report-revisions'; - if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_REVISION_ROOT must be an absolute path'); - this.revisionRoot = resolve(configured); - if (this.revisionRoot === parse(this.revisionRoot).root) throw new Error('INSPECTION_REPORT_REVISION_ROOT cannot be the filesystem root'); - } - - async get(reportId: string): Promise { - await this.word.ensure(reportId); - await this.ensureAutomaticRevision(reportId); - return this.loadView(reportId); - } - - async createRevision( - reportId: string, - dto: CreateInspectionReportRevisionDto, - file: UploadedInspectionReportRevisionFile | undefined, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - inspectInspectionReportRevisionUpload(file); - await this.assertDirector(principal.userId, this.dataSource.manager); - await mkdir(this.revisionRoot, { recursive: true, mode: 0o700 }); - const storedName = `${reportId}-${randomUUID()}.docx`; - const filePath = resolve(this.revisionRoot, storedName); - if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError(); - await writeFile(filePath, file!.buffer, { mode: 0o600 }); - try { - await validateInspectionReportRevisionContainer(filePath); - const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); - const created = await this.dataSource.transaction(async (manager) => { - const report = await this.lockReport(manager, reportId); - this.assertOpenForReview(report); - const nextRevision = report.currentRevisionNumber + 1; - const [row] = await manager.query(` - INSERT INTO inspection_report_revisions ( - report_id, revision_number, source, original_name, stored_name, mime_type, - size_bytes, sha256, change_summary, created_by - ) VALUES ($1,$2,'DIRECTOR_UPLOAD',$3,$4,$5,$6,$7,$8,$9) - RETURNING id - `, [ - report.id, - nextRevision, - this.cleanOriginalName(file!.originalname), - storedName, - INSPECTION_REPORT_WORD_MIME, - file!.buffer.length, - sha256, - dto.changeSummary.trim(), - principal.userId, - ]) as Array<{ id: string }>; - await manager.query(` - UPDATE inspection_reports - SET current_revision_number = $2, updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - `, [report.id, nextRevision]); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_REPORT_REVISION_ADDED, - entityType: 'inspection_report_revision', - entityId: row.id, - afterData: { - reportId: report.id, - reportCode: report.code, - revisionNumber: nextRevision, - sha256, - source: 'DIRECTOR_UPLOAD', - }, - }, manager); - return row.id; - }); - if (!created) throw this.storageError(); - } catch (error) { - await rm(filePath, { force: true }).catch(() => undefined); - throw error; - } - return this.loadView(reportId); - } - - async approve( - reportId: string, - dto: ApproveInspectionReportDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - await this.dataSource.transaction(async (manager) => { - await this.assertDirector(principal.userId, manager); - const report = await this.lockReport(manager, reportId); - this.assertOpenForReview(report); - const [revision] = await manager.query(` - SELECT id, revision_number AS "revisionNumber", sha256 - FROM inspection_report_revisions - WHERE report_id = $1 AND revision_number = $2 - `, [report.id, report.currentRevisionNumber]) as Array<{ id: string; revisionNumber: number; sha256: string }>; - if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_REVISION_REQUIRED', message: 'El informe necesita una versión Word válida antes de aprobarse' }); - const approvedAt = new Date(); - await manager.query(` - UPDATE inspection_reports - SET review_status = 'APPROVED', approved_revision_id = $2, approved_by = $3, - approved_at = $4, review_note = $5, updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - `, [report.id, revision.id, principal.userId, approvedAt, dto.note?.trim() || null]); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_REPORT_APPROVED, - entityType: 'inspection_report', - entityId: report.id, - afterData: { - reportCode: report.code, - revisionId: revision.id, - revisionNumber: revision.revisionNumber, - revisionSha256: revision.sha256, - approvedAt: approvedAt.toISOString(), - }, - }, manager); - }); - return this.loadView(reportId); - } - - async signFinal( - reportId: string, - dto: SignFinalInspectionReportDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - if (dto.confirmation !== true) throw new ConflictException({ code: 'INSPECTION_REPORT_SIGNATURE_CONFIRMATION_REQUIRED', message: 'Debés confirmar expresamente la firma final del informe' }); - await this.dataSource.transaction(async (manager) => { - await this.assertDirector(principal.userId, manager); - const report = await this.lockReport(manager, reportId); - if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe ya tiene una firma final inmutable' }); - if (report.reviewStatus !== 'APPROVED' || !report.approvedRevisionId || !report.approvedAt) { - throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_APPROVED', message: 'El Director debe aprobar una versión antes de firmar el informe final' }); - } - const [revision] = await manager.query(` - SELECT id, revision_number AS "revisionNumber", sha256 - FROM inspection_report_revisions - WHERE id = $1 AND report_id = $2 - `, [report.approvedRevisionId, report.id]) as Array<{ id: string; revisionNumber: number; sha256: string }>; - if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_APPROVED_REVISION_MISSING', message: 'La versión aprobada no está disponible para la firma final' }); - const signedAt = new Date(); - const payload = { - schemaVersion: 'DH-INSPECTION-REPORT-SIGNATURE-V1', - reportId: report.id, - reportCode: report.code, - reportFrozenSha256: report.frozenSha256, - revisionId: revision.id, - revisionNumber: revision.revisionNumber, - revisionSha256: revision.sha256, - signedBy: principal.userId, - signedByUsername: principal.username, - signedAt: signedAt.toISOString(), - confirmationText: FINAL_CONFIRMATION_TEXT, - }; - const signatureSha256 = sha256CanonicalJson(payload); - const [signature] = await manager.query(` - INSERT INTO inspection_report_signatures ( - report_id, revision_id, signed_by, signed_at, confirmation_text, - signature_payload, signature_sha256 - ) VALUES ($1,$2,$3,$4,$5,$6,$7) - RETURNING id - `, [report.id, revision.id, principal.userId, signedAt, FINAL_CONFIRMATION_TEXT, payload, signatureSha256]) as Array<{ id: string }>; - await manager.query(` - UPDATE inspection_reports - SET review_status = 'SIGNED', signed_at = $2, updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - `, [report.id, signedAt]); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_REPORT_SIGNED, - entityType: 'inspection_report_signature', - entityId: signature.id, - afterData: { - reportId: report.id, - reportCode: report.code, - revisionId: revision.id, - revisionNumber: revision.revisionNumber, - revisionSha256: revision.sha256, - signatureSha256, - signedAt: signedAt.toISOString(), - }, - }, manager); - }); - return this.loadView(reportId); - } - - async revisionContent(revisionId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { - const [revision] = await this.dataSource.query(` - SELECT id, report_id AS "reportId", revision_number AS "revisionNumber", source, - original_name AS "originalName", stored_name AS "storedName", mime_type AS "mimeType", - size_bytes::integer AS "sizeBytes", sha256 - FROM inspection_report_revisions WHERE id = $1 - `, [revisionId]) as StoredRevisionRow[]; - if (!revision) throw new NotFoundException({ code: 'INSPECTION_REPORT_REVISION_NOT_FOUND', message: 'Versión del informe no encontrada' }); - if (revision.source === 'AUTO') return this.word.content(revision.reportId); - const filePath = resolve(this.revisionRoot, revision.storedName); - if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError(); - const fileStat = await stat(filePath).catch(() => null); - if (!fileStat?.isFile() || fileStat.size !== revision.sizeBytes) throw this.storageError(); - const buffer = await readFile(filePath); - const sha256 = createHash('sha256').update(buffer).digest('hex'); - if (sha256 !== revision.sha256) throw this.storageError(); - return { filePath, originalName: revision.originalName, mimeType: revision.mimeType }; - } - - private async ensureAutomaticRevision(reportId: string): Promise { - await this.dataSource.query(` - INSERT INTO inspection_report_revisions ( - report_id, revision_number, source, original_name, stored_name, mime_type, - size_bytes, sha256, change_summary, created_by, created_at - ) - SELECT - id, 1, 'AUTO', word_original_name, word_stored_name, word_mime_type, - word_size_bytes, word_sha256, 'Versión automática inicial', generated_by, - COALESCE(word_generated_at, generated_at) - FROM inspection_reports - WHERE id = $1 - AND word_status = 'READY' - AND word_original_name IS NOT NULL - AND word_stored_name IS NOT NULL - AND word_mime_type = $2 - AND word_size_bytes > 0 - AND word_sha256 ~ '^[0-9a-f]{64}$' - ON CONFLICT (report_id, revision_number) DO NOTHING - `, [reportId, INSPECTION_REPORT_WORD_MIME]); - await this.dataSource.query(` - UPDATE inspection_reports - SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - AND EXISTS (SELECT 1 FROM inspection_report_revisions WHERE report_id = $1 AND revision_number = 1) - `, [reportId]); - } - - private async loadView(reportId: string): Promise { - const [report] = await this.dataSource.query(` - SELECT - report.id AS "reportId", - report.code AS "reportCode", - report.review_status AS "reviewStatus", - report.current_revision_number AS "currentRevisionNumber", - report.approved_revision_id AS "approvedRevisionId", - report.approved_at AS "approvedAt", - report.review_note AS "reviewNote", - CASE WHEN approver.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', approver.id, 'username', approver.username, 'firstName', approver.first_name, 'lastName', approver.last_name - ) END AS "approvedBy" - FROM inspection_reports report - LEFT JOIN users approver ON approver.id = report.approved_by - WHERE report.id = $1 - `, [reportId]) as Array>; - if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); - const revisions = await this.dataSource.query(` - SELECT - revision.id, - revision.report_id AS "reportId", - revision.revision_number AS "revisionNumber", - revision.source, - revision.original_name AS "originalName", - revision.mime_type AS "mimeType", - revision.size_bytes::integer AS "sizeBytes", - revision.sha256, - revision.change_summary AS "changeSummary", - revision.created_at AS "createdAt", - JSONB_BUILD_OBJECT( - 'id', creator.id, 'username', creator.username, 'firstName', creator.first_name, 'lastName', creator.last_name - ) AS "createdBy" - FROM inspection_report_revisions revision - JOIN users creator ON creator.id = revision.created_by - WHERE revision.report_id = $1 - ORDER BY revision.revision_number DESC - `, [reportId]) as InspectionReportRevisionView[]; - const [signature] = await this.dataSource.query(` - SELECT - signature.id, - signature.revision_id AS "revisionId", - signature.signed_at AS "signedAt", - signature.confirmation_text AS "confirmationText", - signature.signature_sha256 AS "signatureSha256", - JSONB_BUILD_OBJECT( - 'id', signer.id, 'username', signer.username, 'firstName', signer.first_name, 'lastName', signer.last_name - ) AS "signedBy" - FROM inspection_report_signatures signature - JOIN users signer ON signer.id = signature.signed_by - WHERE signature.report_id = $1 - `, [reportId]) as InspectionReportSignatureView[]; - return { ...report, signature: signature ?? null, revisions }; - } - - private async lockReport(manager: EntityManager, reportId: string): Promise { - const [report] = await manager.query(` - SELECT - id, code, status, frozen_sha256 AS "frozenSha256", - review_status AS "reviewStatus", - current_revision_number AS "currentRevisionNumber", - approved_revision_id AS "approvedRevisionId", - approved_at AS "approvedAt", - review_note AS "reviewNote" - FROM inspection_reports - WHERE id = $1 - FOR UPDATE - `, [reportId]) as LockedReport[]; - if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); - if (report.status !== 'FROZEN') throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_REVIEWABLE', message: 'Sólo los informes congelados pueden ingresar al circuito de revisión' }); - return report; - } - - private assertOpenForReview(report: LockedReport): void { - if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe firmado es definitivo y no admite nuevas versiones' }); - if (report.reviewStatus === 'APPROVED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_APPROVED', message: 'La versión ya fue aprobada y sólo resta la firma final del Director' }); - } - - private async assertDirector(userId: string, manager: EntityManager): Promise { - const [role] = await manager.query(` - SELECT role.code - FROM user_roles membership - JOIN roles role ON role.id = membership.role_id - WHERE membership.user_id = $1 AND role.code = 'director' - LIMIT 1 - `, [userId]) as Array<{ code: string }>; - if (!role) throw new ForbiddenException({ code: 'INSPECTION_REPORT_DIRECTOR_REQUIRED', message: 'Esta operación está reservada al Director de Hidrocarburos' }); - } - - private cleanOriginalName(value: string): string { - const cleaned = value.replace(/[\\/\0\r\n]/g, '_').trim(); - return (cleaned || 'informe-corregido.docx').slice(0, 255); - } - - private storageError(): InternalServerErrorException { - return new InternalServerErrorException({ code: 'INSPECTION_REPORT_REVISION_STORAGE_ERROR', message: 'La versión del informe no está disponible o no supera la validación de integridad' }); - } -} diff --git a/api-v3/src/inspection-reports/inspection-report-word-builder.ts b/api-v3/src/inspection-reports/inspection-report-word-builder.ts index 387e270..27f30b8 100644 --- a/api-v3/src/inspection-reports/inspection-report-word-builder.ts +++ b/api-v3/src/inspection-reports/inspection-report-word-builder.ts @@ -11,6 +11,8 @@ interface ReportWordInput { generatedAt: Date; frozenSha256: string; frozenSnapshot: Record; + executiveSummary?: string | null; + reportDescription?: string | null; } const crcTable = (() => { @@ -56,8 +58,8 @@ function text(value: unknown, fallback = '—'): string { } function isoDate(value: unknown): string { - const date = new Date(String(value ?? '')); - return Number.isFinite(date.getTime()) ? date.toLocaleDateString('es-AR') : '—'; + const parsed = new Date(String(value ?? '')); + return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '—'; } function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string { @@ -76,52 +78,96 @@ function table(headers: string[], rows: string[][]): string { return `${header}${body}`; } +function f4Snapshot(input: ReportWordInput) { + const source = asRecord(input.frozenSnapshot.source); + const sealedAct = asRecord(input.frozenSnapshot.sealedAct ?? input.frozenSnapshot.actClosure); + const locked = asRecord(sealedAct.lockedSnapshot ?? sealedAct.preparedSnapshot); + const act = asRecord(locked.act); + const inspection = asRecord(act.inspection ?? act.visit); + const responsible = asRecord(locked.responsible); + const inventories = asArray(locked.inventories ?? locked.assets); + const findings = asArray(locked.findings); + const signatures = asArray(sealedAct.signatures); + return { source, sealedAct, locked, act, inspection, responsible, inventories, findings, signatures }; +} + +function urgency(value: unknown): string { + return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente'; +} + +function deadline(act: Record): string { + const type = text(act.deadlineDayType, '') === 'CALENDAR' ? 'días corridos' : 'días hábiles'; + if (act.deadlineAt) return `${isoDate(act.deadlineAt)} · ${text(act.deadlineDays)} ${type}`; + if (text(act.deadlineBasis, '') === 'GEDO_DATE') return `${text(act.deadlineDays)} ${type} desde la fecha de oficialización GEDO`; + return '—'; +} + function documentXml(input: ReportWordInput): string { - const report = asRecord(input.frozenSnapshot.report); - const actClosure = asRecord(input.frozenSnapshot.actClosure); - const prepared = asRecord(actClosure.preparedSnapshot); - const act = asRecord(prepared.act); - const visit = asRecord(act.visit); - const responsible = asRecord(prepared.responsible); - const team = asArray(prepared.team); - const assets = asArray(prepared.assets); - const findings = asArray(prepared.findings); - const signatures = asArray(actClosure.signatures); - const companies = [...new Set(assets.map((item) => text(asRecord(item.operatorCompany).name, '')).filter(Boolean))]; - const areas = [...new Set(assets.map((item) => text(asRecord(item.operationalArea).name, '')).filter(Boolean))]; - const inspectorNames = team.map((member) => `${text(member.firstName, '')} ${text(member.lastName, '')}`.trim()).filter(Boolean); - const assetRows = assets.map((item) => [text(item.code), text(item.name), text(item.commonName, ''), text(item.typeName)]); - const findingRows = findings.map((item) => [text(item.code), text(item.title), text(item.description), item.severity == null ? '—' : `${text(item.severity)}/10`, isoDate(item.correctionDueOn)]); - const signatureRows = signatures.map((item) => [text(item.signerType), text(item.signerName), text(item.status), isoDate(item.signedAt ?? item.createdAt)]); + const snapshot = f4Snapshot(input); + const findingRows = snapshot.findings.map((item) => [ + text(item.code), + text(item.title), + text(item.description), + item.isRecurrence ? `Sí${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No', + item.severity == null ? '—' : `${text(item.severity)}/10`, + ]); + const inventoryRows = snapshot.inventories.map((item) => [ + text(item.code), + text(item.name), + text(item.typeName ?? item.typeCode), + ]); + const signatureRows = snapshot.signatures.map((item) => [ + text(item.signerType), + text(item.signerName), + text(item.status), + text(item.companyManifestation, ''), + isoDate(item.signedAt ?? item.createdAt), + ]); + + const executive = input.executiveSummary?.trim() + || '[EDITAR] Incorporar aquí el resumen ejecutivo del informe.'; + const description = input.reportDescription?.trim() + || '[EDITAR] Incorporar aquí la descripción técnica, análisis y consideraciones del inspector.'; + const body = [ paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'), - paragraph('Borrador automático para revisión del Director de Hidrocarburos', 'Heading2'), + paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'), labelValue('Informe', input.code), - labelValue('Título', input.title), + labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)), + labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)), + labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)), + labelValue('Urgencia', urgency(snapshot.act.urgency)), + labelValue('Plazo', deadline(snapshot.act)), labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')), - labelValue('Acta', text(report.actCode ?? act.code)), - labelValue('Inspección', text(report.visitCode ?? visit.code)), - labelValue('Empresa', companies.join(' · ') || 'Según alcance del acta'), - labelValue('Área', areas.join(' · ') || 'Según alcance del acta'), - labelValue('Inspector/es', inspectorNames.join(' · ') || '—'), - labelValue('Responsable de empresa', text(responsible.fullName)), - labelValue('Cargo', text(responsible.position)), - paragraph('Datos de la visita', 'Heading1'), - labelValue('Objetivo', text(visit.objective)), - labelValue('Fecha de inspección', isoDate(act.occurredAt)), - labelValue('Resumen', text(act.summary)), - labelValue('Observaciones', text(act.observations)), - paragraph('Elementos inspeccionados', 'Heading1'), - assetRows.length ? table(['Código', 'Nombre técnico', 'Nombre habitual', 'Tipo'], assetRows) : paragraph('No se registraron elementos en la instantánea.'), + paragraph('Resumen ejecutivo', 'Heading1'), + paragraph(executive), + paragraph('Descripción / análisis técnico', 'Heading1'), + paragraph(description), + paragraph('Acta fuente · contenido inmutable', 'Heading1'), + paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'), + labelValue('Resumen del Acta', text(snapshot.act.summary)), + labelValue('Observaciones del Acta', text(snapshot.act.observations)), + labelValue('Responsable de empresa', text(snapshot.responsible.fullName)), + labelValue('Cargo', text(snapshot.responsible.position)), + paragraph('Inventario inspeccionado', 'Heading1'), + inventoryRows.length + ? table(['Código', 'Nombre', 'Tipo'], inventoryRows) + : paragraph('No se registraron elementos de Inventario en el Acta.'), paragraph('Hallazgos', 'Heading1'), - findingRows.length ? table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento'], findingRows) : paragraph('No se registraron hallazgos en la instantánea.'), - paragraph('Firmas y constancias', 'Heading1'), - signatureRows.length ? table(['Tipo', 'Firmante', 'Estado', 'Fecha'], signatureRows) : paragraph('No se registraron firmas en la instantánea.'), - paragraph('Integridad documental', 'Heading1'), - labelValue('Hash del informe', input.frozenSha256), - labelValue('Hash de cierre del acta', text(report.actClosureSha256)), - paragraph('El contenido de este archivo fue generado desde la instantánea congelada del acta. El diseño institucional y la firma final del Director se incorporarán en la etapa de revisión correspondiente.'), + findingRows.length + ? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows) + : paragraph('El Acta no contiene Hallazgos.'), + paragraph('Firmas y manifestaciones', 'Heading1'), + signatureRows.length + ? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows) + : paragraph('No se registraron firmas en la instantánea sellada.'), + paragraph('Integridad de la fuente', 'Heading1'), + labelValue('Hash de la fuente del INF', input.frozenSha256), + labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)), + labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)), + paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'), ].join(''); + return `${body}`; } @@ -147,6 +193,7 @@ function buildZip(entries: ZipEntry[]): Buffer { local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28); locals.push(local, name, entry.data); + const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); @@ -168,6 +215,7 @@ function buildZip(entries: ZipEntry[]): Buffer { centrals.push(central, name); offset += local.length + name.length + entry.data.length; } + const centralData = Buffer.concat(centrals); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); diff --git a/api-v3/src/inspection-reports/inspection-report-word.service.ts b/api-v3/src/inspection-reports/inspection-report-word.service.ts index 4074c81..fd1f559 100644 --- a/api-v3/src/inspection-reports/inspection-report-word.service.ts +++ b/api-v3/src/inspection-reports/inspection-report-word.service.ts @@ -12,6 +12,8 @@ interface WordRow { id: string; code: string; title: string; + executiveSummary: string | null; + reportDescription: string | null; generatedAt: Date; frozenSha256: string; frozenSnapshot: Record; @@ -29,7 +31,8 @@ export class InspectionReportWordService { private readonly root: string; constructor(private readonly dataSource: DataSource, config: ConfigService) { - const configured = config.get('INSPECTION_REPORT_WORD_ROOT') ?? '/app/storage/asset-media/inspection-reports-word'; + const configured = config.get('INSPECTION_REPORT_WORD_ROOT') + ?? '/app/storage/asset-media/inspection-reports-word'; if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path'); this.root = resolve(configured); if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root'); @@ -38,7 +41,11 @@ export class InspectionReportWordService { async ensure(reportId: string): Promise { const row = await this.load(reportId); if (row.wordStatus === 'READY' && row.wordStoredName) { - try { await this.content(row.id); await this.ensureInitialRevision(row); return; } catch {} + try { + await this.content(row.id); + await this.ensureInitialRevision(row); + return; + } catch {} } try { const built = buildInspectionReportWord({ @@ -47,40 +54,51 @@ export class InspectionReportWordService { generatedAt: new Date(row.generatedAt), frozenSha256: row.frozenSha256, frozenSnapshot: row.frozenSnapshot, + executiveSummary: row.executiveSummary, + reportDescription: row.reportDescription, }); await mkdir(this.root, { recursive: true, mode: 0o700 }); - const storedName = `${row.id}.docx`; + const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`; const originalName = `${row.code}.docx`; const filePath = resolve(this.root, storedName); await writeFile(filePath, built.buffer, { mode: 0o600 }); await this.dataSource.query(` UPDATE inspection_reports - SET word_status = 'READY', - word_original_name = $2, - word_stored_name = $3, - word_mime_type = $4, - word_size_bytes = $5, - word_sha256 = $6, - word_generated_at = CURRENT_TIMESTAMP, - word_error = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 + SET word_status='READY', + word_original_name=$2, + word_stored_name=$3, + word_mime_type=$4, + word_size_bytes=$5, + word_sha256=$6, + word_generated_at=CURRENT_TIMESTAMP, + word_error=NULL, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1 `, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]); await this.ensureInitialRevision(await this.load(row.id)); } catch (error) { const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido'; await this.dataSource.query(` UPDATE inspection_reports - SET word_status = 'FAILED', word_error = $2, updated_at = CURRENT_TIMESTAMP - WHERE id = $1 + SET word_status='FAILED',word_error=$2,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 `, [row.id, message]).catch(() => undefined); } } async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { const row = await this.load(reportId); - if (row.wordStatus !== 'READY' || !row.wordStoredName || !row.wordOriginalName || !row.wordSha256 || !row.wordSizeBytes) { - throw new NotFoundException({ code: 'INSPECTION_REPORT_WORD_NOT_READY', message: 'El archivo Word del informe todavía no está disponible' }); + if ( + row.wordStatus !== 'READY' + || !row.wordStoredName + || !row.wordOriginalName + || !row.wordSha256 + || !row.wordSizeBytes + ) { + throw new NotFoundException({ + code: 'INSPECTION_REPORT_WORD_NOT_READY', + message: 'El archivo Word del informe todavía no está disponible', + }); } const filePath = resolve(this.root, row.wordStoredName); if (!filePath.startsWith(`${this.root}/`)) throw this.storageError(); @@ -89,40 +107,78 @@ export class InspectionReportWordService { const buffer = await readFile(filePath); const sha256 = createHash('sha256').update(buffer).digest('hex'); if (sha256 !== row.wordSha256) throw this.storageError(); - return { filePath, originalName: row.wordOriginalName, mimeType: row.wordMimeType ?? WORD_MIME }; + return { + filePath, + originalName: row.wordOriginalName, + mimeType: row.wordMimeType ?? WORD_MIME, + }; } - private async ensureInitialRevision(row: WordRow): Promise { - if (row.wordStatus !== 'READY' || !row.wordOriginalName || !row.wordStoredName || !row.wordMimeType || !row.wordSizeBytes || !row.wordSha256) return; + if ( + row.wordStatus !== 'READY' + || !row.wordOriginalName + || !row.wordStoredName + || !row.wordMimeType + || !row.wordSizeBytes + || !row.wordSha256 + ) return; await this.dataSource.query(` INSERT INTO inspection_report_revisions ( - report_id, revision_number, source, original_name, stored_name, mime_type, - size_bytes, sha256, change_summary, created_by, created_at - ) VALUES ($1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial',$7,COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP)) - ON CONFLICT (report_id, revision_number) DO NOTHING - `, [row.id, row.wordOriginalName, row.wordStoredName, row.wordMimeType, row.wordSizeBytes, row.wordSha256, row.generatedBy]); + report_id,revision_number,source,original_name,stored_name,mime_type, + size_bytes,sha256,change_summary,created_by,created_at + ) VALUES ( + $1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial editable',$7, + COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP) + ) + ON CONFLICT (report_id,revision_number) DO NOTHING + `, [ + row.id, + row.wordOriginalName, + row.wordStoredName, + row.wordMimeType, + row.wordSizeBytes, + row.wordSha256, + row.generatedBy, + ]); await this.dataSource.query(` UPDATE inspection_reports - SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP - WHERE id = $1 + SET current_revision_number=GREATEST(current_revision_number,1),updated_at=CURRENT_TIMESTAMP + WHERE id=$1 `, [row.id]); } private async load(reportId: string): Promise { const [row] = await this.dataSource.query(` - SELECT id, code, title, generated_at AS "generatedAt", frozen_sha256 AS "frozenSha256", - frozen_snapshot AS "frozenSnapshot", word_status AS "wordStatus", - word_original_name AS "wordOriginalName", word_stored_name AS "wordStoredName", - word_mime_type AS "wordMimeType", word_size_bytes::integer AS "wordSizeBytes", - word_sha256 AS "wordSha256", generated_by AS "generatedBy" - FROM inspection_reports WHERE id = $1 + SELECT id,code,title, + executive_summary AS "executiveSummary", + report_description AS "reportDescription", + generated_at AS "generatedAt", + frozen_sha256 AS "frozenSha256", + frozen_snapshot AS "frozenSnapshot", + word_status AS "wordStatus", + word_original_name AS "wordOriginalName", + word_stored_name AS "wordStoredName", + word_mime_type AS "wordMimeType", + word_size_bytes::integer AS "wordSizeBytes", + word_sha256 AS "wordSha256", + generated_by AS "generatedBy" + FROM inspection_reports + WHERE id=$1 `, [reportId]) as WordRow[]; - if (!row) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); + if (!row) { + throw new NotFoundException({ + code: 'INSPECTION_REPORT_NOT_FOUND', + message: 'Informe de inspección no encontrado', + }); + } return row; } private storageError(): InternalServerErrorException { - return new InternalServerErrorException({ code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR', message: 'El archivo Word del informe no está disponible o no supera la validación de integridad' }); + return new InternalServerErrorException({ + code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR', + message: 'El archivo Word del informe no está disponible o no supera la validación de integridad', + }); } } diff --git a/api-v3/src/inspection-reports/inspection-report-workflow.service.ts b/api-v3/src/inspection-reports/inspection-report-workflow.service.ts new file mode 100644 index 0000000..fbe9602 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-report-workflow.service.ts @@ -0,0 +1,428 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { + BadRequestException, + ConflictException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + AuditAction, + InspectionReportStatus, +} from '../database/entities'; +import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto'; +import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; +import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; + +export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024; + +export interface UploadedInspectionReportFile { + buffer: Buffer; + originalname: string; + mimetype: string; + size: number; +} + +interface ReportRow { + id: string; + actId: string; + visitId: string; + code: string; + status: InspectionReportStatus; + executiveSummary: string | null; + reportDescription: string | null; + gedoIfIdentifier: string | null; + gedoOfficializedAt: Date | null; +} + +function reportNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_REPORT_NOT_FOUND', + message: 'Informe de inspección no encontrado', + }); +} + +@Injectable() +export class InspectionReportWorkflowService { + private readonly root: string; + + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + config: ConfigService, + ) { + const configured = config.get('INSPECTION_REPORT_UPLOAD_ROOT') + ?? '/app/storage/asset-media/inspection-reports'; + if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_UPLOAD_ROOT must be absolute'); + this.root = resolve(configured); + if (this.root === parse(this.root).root) { + throw new Error('INSPECTION_REPORT_UPLOAD_ROOT cannot be filesystem root'); + } + } + + async updateNarrative( + reportId: string, + dto: UpdateInspectionReportDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + return this.dataSource.transaction(async (manager) => { + const report = await this.lockReport(manager, reportId); + if (report.status !== InspectionReportStatus.WORKING) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED', + message: 'El contenido editable del INF queda cerrado cuando se registra su IF oficial de GEDO', + }); + } + const before = { + executiveSummary: report.executiveSummary, + reportDescription: report.reportDescription, + }; + const executiveSummary = dto.executiveSummary !== undefined + ? dto.executiveSummary + : report.executiveSummary; + const reportDescription = dto.description !== undefined + ? dto.description + : report.reportDescription; + await manager.query(` + UPDATE inspection_reports + SET executive_summary=$2, + report_description=$3, + word_status=CASE + WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 + THEN 'PENDING' ELSE word_status END, + word_original_name=CASE + WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 + THEN NULL ELSE word_original_name END, + word_stored_name=CASE + WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 + THEN NULL ELSE word_stored_name END, + word_mime_type=CASE + WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 + THEN NULL ELSE word_mime_type END, + word_size_bytes=CASE + WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 + THEN NULL ELSE word_size_bytes END, + word_sha256=CASE + WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 + THEN NULL ELSE word_sha256 END, + word_generated_at=CASE + WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 + THEN NULL ELSE word_generated_at END, + word_error=CASE + WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3 + THEN NULL ELSE word_error END, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [reportId, executiveSummary, reportDescription]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_UPDATED, + entityType: 'inspection_report', + entityId: reportId, + beforeData: before, + afterData: { executiveSummary, reportDescription }, + metadata: { reportCode: report.code, actId: report.actId }, + }, manager); + return this.getWorkflowView(manager, reportId); + }); + } + + async officialize( + reportId: string, + dto: OfficializeInspectionReportDto, + file: UploadedInspectionReportFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + this.assertPdf(file); + const id = randomUUID(); + const storedName = `gedo-${id}.pdf`; + const filePath = resolve(this.root, storedName); + const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); + try { + return await this.dataSource.transaction(async (manager) => { + const report = await this.lockReport(manager, reportId); + if (report.status !== InspectionReportStatus.WORKING) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED', + message: 'El IF oficial de GEDO ya fue registrado y es inmutable', + }); + } + const officializedAt = new Date(dto.gedoOfficializedAt); + const now = new Date(); + if (officializedAt.getTime() > now.getTime() + 24 * 60 * 60 * 1000) { + throw new BadRequestException({ + code: 'INVALID_GEDO_DATE', + message: 'La fecha de GEDO no puede estar más de 24 horas en el futuro', + }); + } + await manager.query(` + UPDATE inspection_reports + SET status='OFFICIALIZED', + gedo_if_identifier=$2, + gedo_officialized_at=$3, + gedo_pdf_original_name=$4, + gedo_pdf_stored_name=$5, + gedo_pdf_mime_type='application/pdf', + gedo_pdf_size_bytes=$6, + gedo_pdf_sha256=$7, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [ + reportId, + dto.gedoIfIdentifier, + officializedAt, + file!.originalname, + storedName, + file!.buffer.length, + sha256, + ]); + // GEDO oficializa el INF, pero no equivale por sí solo a la notificación + // administrativa de un Acta no urgente. El vencimiento queda pendiente + // hasta que el procedimiento defina y registre el evento de notificación. + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_OFFICIALIZED, + entityType: 'inspection_report', + entityId: reportId, + afterData: { + status: InspectionReportStatus.OFFICIALIZED, + gedoIfIdentifier: dto.gedoIfIdentifier, + gedoOfficializedAt: officializedAt, + gedoPdfOriginalName: file!.originalname, + gedoPdfSha256: sha256, + }, + metadata: { + reportCode: report.code, + actId: report.actId, + immutable: true, + deadlineActivation: 'PENDING_NOTIFICATION_EVENT', + }, + }, manager); + return this.getWorkflowView(manager, reportId); + }); + } catch (error) { + await unlink(filePath).catch(() => undefined); + throw error; + } + } + + async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { + const [row] = await this.dataSource.query(` + SELECT + gedo_pdf_original_name AS "originalName", + gedo_pdf_stored_name AS "storedName", + gedo_pdf_mime_type AS "mimeType", + gedo_pdf_size_bytes::integer AS "sizeBytes", + gedo_pdf_sha256 AS sha256 + FROM inspection_reports + WHERE id=$1 + `, [reportId]) as Array<{ + originalName: string | null; + storedName: string | null; + mimeType: string | null; + sizeBytes: number | null; + sha256: string | null; + }>; + if (!row) throw reportNotFound(); + if (!row.originalName || !row.storedName || !row.sizeBytes || !row.sha256) { + throw new NotFoundException({ + code: 'INSPECTION_REPORT_GEDO_PDF_NOT_AVAILABLE', + message: 'El PDF oficial de GEDO todavía no está disponible', + }); + } + const filePath = resolve(this.root, row.storedName); + if (filePath === this.root || !filePath.startsWith(`${this.root}/`)) throw this.reportStorageError(); + const fileStat = await stat(filePath).catch(() => null); + if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.reportStorageError(); + const buffer = await readFile(filePath); + const sha256 = createHash('sha256').update(buffer).digest('hex'); + if (sha256 !== row.sha256) throw this.reportStorageError(); + return { + filePath, + originalName: row.originalName, + mimeType: row.mimeType ?? 'application/pdf', + }; + } + + async addFollowUp( + reportId: string, + dto: CreateInspectionReportFollowUpDto, + file: UploadedInspectionReportFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + if (file && file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) { + throw new BadRequestException({ + code: 'INSPECTION_REPORT_FILE_TOO_LARGE', + message: 'El archivo supera el máximo permitido de 40 MB', + }); + } + const followUpId = randomUUID(); + const ext = this.safeExtension(file?.originalname); + const storedName = file ? `followup-${followUpId}${ext}` : null; + const filePath = storedName ? resolve(this.root, storedName) : null; + const sha256 = file ? createHash('sha256').update(file.buffer).digest('hex') : null; + if (filePath && file) { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file.buffer, { flag: 'wx', mode: 0o600 }); + } + try { + return await this.dataSource.transaction(async (manager) => { + const report = await this.lockReport(manager, reportId); + const occurredAt = new Date(dto.occurredAt); + await manager.query(` + INSERT INTO inspection_report_follow_ups ( + id,report_id,type,external_reference,occurred_at,description, + original_name,stored_name,mime_type,size_bytes,sha256,created_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + `, [ + followUpId, + reportId, + dto.type, + dto.externalReference ?? null, + occurredAt, + dto.description ?? null, + file?.originalname ?? null, + storedName, + file?.mimetype ?? null, + file?.buffer.length ?? null, + sha256, + principal.userId, + ]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_FOLLOW_UP_ADDED, + entityType: 'inspection_report_follow_up', + entityId: followUpId, + afterData: { + reportId, + type: dto.type, + externalReference: dto.externalReference ?? null, + occurredAt, + description: dto.description ?? null, + originalName: file?.originalname ?? null, + sha256, + }, + metadata: { reportCode: report.code, actId: report.actId, appendOnly: true }, + }, manager); + return this.listFollowUpsWithManager(manager, reportId); + }); + } catch (error) { + if (filePath) await unlink(filePath).catch(() => undefined); + throw error; + } + } + + async listFollowUps(reportId: string) { + const report = await this.getReport(this.dataSource.manager, reportId); + if (!report) throw reportNotFound(); + return this.listFollowUpsWithManager(this.dataSource.manager, reportId); + } + + private async listFollowUpsWithManager(manager: EntityManager, reportId: string) { + return manager.query(` + SELECT + follow_up.id, + follow_up.report_id AS "reportId", + follow_up.type, + follow_up.external_reference AS "externalReference", + follow_up.occurred_at AS "occurredAt", + follow_up.description, + follow_up.original_name AS "originalName", + follow_up.mime_type AS "mimeType", + follow_up.size_bytes AS "sizeBytes", + follow_up.sha256, + follow_up.created_by AS "createdBy", + follow_up.created_at AS "createdAt" + FROM inspection_report_follow_ups follow_up + WHERE follow_up.report_id=$1 + ORDER BY follow_up.occurred_at,follow_up.created_at,follow_up.id + `, [reportId]); + } + + private reportStorageError(): InternalServerErrorException { + return new InternalServerErrorException({ + code: 'INSPECTION_REPORT_GEDO_PDF_STORAGE_ERROR', + message: 'El PDF oficial de GEDO no está disponible o no supera la validación de integridad', + }); + } + + private assertPdf(file: UploadedInspectionReportFile | undefined): void { + if (!file?.buffer?.length) { + throw new BadRequestException({ + code: 'GEDO_PDF_REQUIRED', + message: 'Debe adjuntarse el PDF oficial generado por GEDO', + }); + } + if (file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) { + throw new BadRequestException({ + code: 'GEDO_PDF_TOO_LARGE', + message: 'El PDF oficial supera el máximo permitido de 40 MB', + }); + } + if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') { + throw new BadRequestException({ + code: 'INVALID_GEDO_PDF', + message: 'El documento oficial de GEDO debe ser un PDF válido', + }); + } + } + + private safeExtension(name: string | undefined): string { + if (!name) return ''; + const dot = name.lastIndexOf('.'); + if (dot < 0) return ''; + const ext = name.slice(dot).toLowerCase(); + return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : ''; + } + + private async lockReport(manager: EntityManager, id: string): Promise { + const [row] = await manager.query(` + SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, + executive_summary AS "executiveSummary",report_description AS "reportDescription", + gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt" + FROM inspection_reports WHERE id=$1 FOR UPDATE + `, [id]) as ReportRow[]; + if (!row) throw reportNotFound(); + return row; + } + + private async getReport(manager: EntityManager, id: string): Promise { + const [row] = await manager.query(` + SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, + executive_summary AS "executiveSummary",report_description AS "reportDescription", + gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt" + FROM inspection_reports WHERE id=$1 + `, [id]) as ReportRow[]; + return row ?? null; + } + + private async getWorkflowView(manager: EntityManager, id: string) { + const report = await this.getReport(manager, id); + if (!report) throw reportNotFound(); + const [act] = await manager.query(` + SELECT code,urgency,deadline_days AS "deadlineDays",deadline_day_type AS "deadlineDayType", + deadline_basis AS "deadlineBasis",deadline_base_at AS "deadlineBaseAt",deadline_at AS "deadlineAt" + FROM inspection_acts WHERE id=$1 + `, [report.actId]); + return { + ...report, + act, + followUps: await this.listFollowUpsWithManager(manager, id), + }; + } +} diff --git a/api-v3/src/inspection-reports/inspection-reports.controller.ts b/api-v3/src/inspection-reports/inspection-reports.controller.ts index 536a543..f194323 100644 --- a/api-v3/src/inspection-reports/inspection-reports.controller.ts +++ b/api-v3/src/inspection-reports/inspection-reports.controller.ts @@ -1,15 +1,41 @@ -import { Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, Res } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import type { Response } from 'express'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto'; import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto'; +import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; +import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; +import { + InspectionReportWorkflowService, + MAX_INSPECTION_REPORT_FILE_BYTES, + type UploadedInspectionReportFile, +} from './inspection-report-workflow.service'; import { InspectionReportsService } from './inspection-reports.service'; import { InspectionReportWordService } from './inspection-report-word.service'; @Controller('inspection-reports') export class InspectionReportsController { - constructor(private readonly reports: InspectionReportsService, private readonly word: InspectionReportWordService) {} + constructor( + private readonly reports: InspectionReportsService, + private readonly word: InspectionReportWordService, + private readonly workflow: InspectionReportWorkflowService, + ) {} @Get() @RequirePermissions('inspection_reports.read') @@ -42,6 +68,65 @@ export class InspectionReportsController { return response.sendFile(content.filePath); } + @Get(':id/gedo-pdf') + @RequirePermissions('inspection_reports.read') + async gedoPdfContent( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Res() response: Response, + ) { + const content = await this.workflow.officialPdfContent(id); + response.setHeader('Content-Type', content.mimeType); + response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`); + return response.sendFile(content.filePath); + } + + @Patch(':id') + @RequirePermissions('inspection_reports.generate') + updateNarrative( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateInspectionReportDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.workflow.updateNarrative(id, dto, principal, request); + } + + @Post(':id/officialize') + @RequirePermissions('inspection_reports.generate') + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 }, + })) + officialize( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: OfficializeInspectionReportDto, + @UploadedFile() file: UploadedInspectionReportFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.workflow.officialize(id, dto, file, principal, request); + } + + @Get(':id/follow-ups') + @RequirePermissions('inspection_reports.read') + followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.workflow.listFollowUps(id); + } + + @Post(':id/follow-ups') + @RequirePermissions('inspection_reports.generate') + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 }, + })) + addFollowUp( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: CreateInspectionReportFollowUpDto, + @UploadedFile() file: UploadedInspectionReportFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.workflow.addFollowUp(id, dto, file, principal, request); + } + @Get(':id') @RequirePermissions('inspection_reports.read') get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { diff --git a/api-v3/src/inspection-reports/inspection-reports.module.ts b/api-v3/src/inspection-reports/inspection-reports.module.ts index a997459..4696531 100644 --- a/api-v3/src/inspection-reports/inspection-reports.module.ts +++ b/api-v3/src/inspection-reports/inspection-reports.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { AuditModule } from '../audit/audit.module'; import { DocumentDeliveryController } from './document-delivery.controller'; import { InspectionActPdfService } from './inspection-act-pdf.service'; +import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller'; +import { InspectionDeadlineAdminService } from './inspection-deadline-admin.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; -import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller'; -import { InspectionReportReviewService } from './inspection-report-review.service'; +import { InspectionReportWorkflowService } from './inspection-report-workflow.service'; import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller'; import { InspectionReportsService } from './inspection-reports.service'; @@ -15,18 +16,18 @@ import { SmtpDeliveryService } from './smtp-delivery.service'; controllers: [ InspectionReportsController, InspectionActReportController, + InspectionDeadlineAdminController, DocumentDeliveryController, - InspectionReportReviewController, - InspectionReportRevisionContentController, ], providers: [ InspectionReportsService, + InspectionReportWorkflowService, InspectionReportWordService, InspectionActPdfService, + InspectionDeadlineAdminService, InspectionDocumentDeliveryService, SmtpDeliveryService, - InspectionReportReviewService, ], - exports: [InspectionReportsService], + exports: [InspectionReportsService, SmtpDeliveryService], }) export class InspectionReportsModule {} diff --git a/api-v3/src/inspection-reports/inspection-reports.service.ts b/api-v3/src/inspection-reports/inspection-reports.service.ts index 4d7d13f..f4531f6 100644 --- a/api-v3/src/inspection-reports/inspection-reports.service.ts +++ b/api-v3/src/inspection-reports/inspection-reports.service.ts @@ -5,7 +5,6 @@ import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { AuditAction, - DocumentSequenceType, InspectionActStatus, InspectionReportPdfStatus, InspectionReportStatus, @@ -16,7 +15,7 @@ import type { ListInspectionReportsQueryDto } from './dto/list-inspection-report import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; -const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-REPORT-V1'; +const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-INF-V4'; interface ContextAsset { id: string; @@ -32,21 +31,32 @@ export interface InspectionReportListItem { reportNumber: number; code: string; status: InspectionReportStatus; + executiveSummary: string | null; + reportDescription: string | null; + gedoIfIdentifier: string | null; + gedoOfficializedAt: Date | null; + gedoPdfOriginalName: string | null; + gedoPdfSha256: string | null; pdfStatus: InspectionReportPdfStatus; wordStatus: 'PENDING' | 'READY' | 'FAILED'; wordGeneratedAt: Date | null; - reviewStatus: 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED'; - currentRevisionNumber: number; - approvedAt: Date | null; - signedAt: Date | null; title: string; actVersion: number; actClosureSha256: string; frozenSha256: string; generatedAt: Date; generatedBy: { id: string; username: string; firstName: string; lastName: string }; - act: { id: string; code: string; title: string; status: string; occurredAt: Date; closedAt: Date | null }; - visit: { id: string; code: string; title: string; status: string }; + act: { + id: string; + code: string; + title: string; + status: string; + occurredAt: Date; + sealedAt: Date | null; + urgency: string; + deadlineAt: Date | null; + }; + visit: { id: string; code: string; status: string }; companies: ContextAsset[]; areas: ContextAsset[]; findingCount: number; @@ -63,10 +73,9 @@ export interface PendingInspectionReportItem { actTitle: string; actYear: number; occurredAt: Date; - closedAt: Date; + sealedAt: Date; closureSha256: string; visitCode: string; - visitTitle: string; companies: ContextAsset[]; areas: ContextAsset[]; findingCount: number; @@ -127,7 +136,7 @@ export class InspectionReportsService { async listPending(query: ListInspectionReportsQueryDto) { const conditions = [ - `act.status = 'CLOSED'`, + `act.status = 'SEALED'`, 'report.id IS NULL', 'act.closure_sha256 IS NOT NULL', ]; @@ -142,22 +151,6 @@ export class InspectionReportsService { act.code ILIKE ${search} OR act.title ILIKE ${search} OR visit.code ILIKE ${search} - OR visit.title ILIKE ${search} - OR EXISTS ( - SELECT 1 - FROM inspection_act_assets search_link - INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id - LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id - LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id - WHERE search_link.act_id = act.id - AND search_link.included = true - AND ( - search_asset.name ILIKE ${search} - OR search_asset.code ILIKE ${search} - OR search_company.name ILIKE ${search} - OR search_area.name ILIKE ${search} - ) - ) )`); } if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); @@ -196,10 +189,9 @@ export class InspectionReportsService { act.title AS "actTitle", act.act_year AS "actYear", act.occurred_at AS "occurredAt", - act.closed_at AS "closedAt", + act.sealed_at AS "sealedAt", act.closure_sha256 AS "closureSha256", visit.code AS "visitCode", - visit.title AS "visitTitle", COALESCE(context.companies, '[]'::jsonb) AS companies, COALESCE(context.areas, '[]'::jsonb) AS areas, COALESCE(finding_count.total, 0)::integer AS "findingCount" @@ -226,16 +218,7 @@ export class InspectionReportsService { } async get(id: string): Promise { - const [report] = (await this.dataSource.query( - `${this.reportSelect('WHERE report.id = $1')}`, - [id], - )) as InspectionReportListItem[]; - if (!report) throw reportNotFound(); - const [snapshot] = (await this.dataSource.query( - 'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1', - [id], - )) as Array<{ frozenSnapshot: Record }>; - return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; + return this.getWithManager(this.dataSource.manager, id); } async generate( @@ -244,144 +227,144 @@ export class InspectionReportsService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - const report = await this.dataSource.transaction(async (manager) => this.ensureFrozenReport(manager, actId, principal, request)); + const report = await this.dataSource.transaction((manager) => + this.ensureFrozenReport(manager, actId, principal, request), + ); await this.word.ensure(report.id); return this.get(report.id); } + /** + * Conserva una copia interna inmutable del Acta sellada como fuente documental. + * El INF que se genera desde esa fuente permanece editable y versionable. + */ async ensureFrozenReport( manager: EntityManager, actId: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { - const [act] = (await manager.query(` - SELECT - act.id, - act.visit_id AS "visitId", - act.act_year AS "actYear", - act.code, - act.title, - act.status, - act.current_version AS "currentVersion", - act.closure_sha256 AS "closureSha256", - visit.code AS "visitCode", - visit.title AS "visitTitle" - FROM inspection_acts act - INNER JOIN inspection_visits visit ON visit.id = act.visit_id - WHERE act.id = $1 - FOR UPDATE OF act - `, [actId])) as Array<{ - id: string; - visitId: string; - actYear: number; - code: string; - title: string; - status: InspectionActStatus; - currentVersion: number; - closureSha256: string | null; - visitCode: string; - visitTitle: string; - }>; - if (!act) throw actNotFound(); - if (act.status !== InspectionActStatus.CLOSED || !act.closureSha256) { - throw new ConflictException({ - code: 'INSPECTION_REPORT_ACT_NOT_CLOSED', - message: 'El informe sólo puede emitirse después del cierre definitivo del acta', - }); - } - await this.assertActorAssigned(manager, act.visitId, principal.userId); - const [existing] = (await manager.query( - 'SELECT id FROM inspection_reports WHERE act_id = $1', - [actId], - )) as Array<{ id: string }>; - if (existing) return this.getWithManager(manager, existing.id); - const [closure] = (await manager.query(` - SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256" - FROM inspection_act_closures - WHERE act_id = $1 - `, [actId])) as Array<{ - finalSnapshot: Record | null; - finalSha256: string | null; - }>; - if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) { - throw new ConflictException({ - code: 'INSPECTION_REPORT_CLOSURE_NOT_FROZEN', - message: 'El cierre del acta no tiene una instantánea final válida para emitir el informe', - }); - } - const reportNumber = await this.allocateNumber(manager, act.actYear); - if (reportNumber > 999999) { - throw new ConflictException({ - code: 'INSPECTION_REPORT_SEQUENCE_EXHAUSTED', - message: 'La numeración anual de informes agotó su rango disponible', - }); - } - const code = `INF-${act.actYear}-${String(reportNumber).padStart(6, '0')}`; - const generatedAt = new Date(); - const title = `Informe técnico · ${act.title}`.slice(0, 220); - const frozenSnapshot = { - schemaVersion: REPORT_SCHEMA_VERSION, - report: { - code, - reportYear: act.actYear, - reportNumber, - generatedAt: generatedAt.toISOString(), - generatedBy: principal.userId, - generatedByUsername: principal.username, - visitId: act.visitId, - visitCode: act.visitCode, - visitTitle: act.visitTitle, - actId: act.id, - actCode: act.code, - actVersion: act.currentVersion, - actClosureSha256: act.closureSha256, - }, - actClosure: closure.finalSnapshot, - }; - const frozenSha256 = sha256CanonicalJson(frozenSnapshot); - const [created] = (await manager.query(` - INSERT INTO inspection_reports ( - visit_id, act_id, report_year, report_number, code, status, pdf_status, - title, act_version, act_closure_sha256, frozen_sha256, frozen_snapshot, - generated_at, generated_by - ) VALUES ( - $1, $2, $3, $4, $5, 'FROZEN', 'PENDING', $6, $7, $8, $9, $10, $11, $12 - ) - RETURNING id - `, [ - act.visitId, + const [act] = (await manager.query(` + SELECT act.id, - act.actYear, - reportNumber, + act.visit_id AS "visitId", + act.act_year AS "actYear", + act.act_number AS "actNumber", + act.code, + act.title, + act.status, + act.current_version AS "currentVersion", + act.closure_sha256 AS "closureSha256", + act.occurred_at AS "occurredAt", + visit.code AS "visitCode" + FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + WHERE act.id = $1 + FOR UPDATE OF act + `, [actId])) as Array<{ + id: string; + visitId: string; + actYear: number; + actNumber: number; + code: string; + title: string; + status: InspectionActStatus; + currentVersion: number; + closureSha256: string | null; + occurredAt: Date; + visitCode: string; + }>; + if (!act) throw actNotFound(); + if (act.status !== InspectionActStatus.SEALED || !act.closureSha256) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ACT_NOT_SEALED', + message: 'El INF sólo puede generarse después de sellar el acta', + }); + } + await this.assertActorAssigned(manager, act.visitId, principal.userId); + const [existing] = (await manager.query( + 'SELECT id FROM inspection_reports WHERE act_id = $1', + [actId], + )) as Array<{ id: string }>; + if (existing) return this.getWithManager(manager, existing.id); + + const [closure] = (await manager.query(` + SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256" + FROM inspection_act_closures + WHERE act_id = $1 + `, [actId])) as Array<{ + finalSnapshot: Record | null; + finalSha256: string | null; + }>; + if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ACT_SNAPSHOT_INVALID', + message: 'El acta sellada no tiene una instantánea final válida', + }); + } + + const reportNumber = act.actNumber; + const code = act.code.replace(/^ACT-/, 'INF-'); + const generatedAt = new Date(); + const title = `Informe de inspección · ${act.code}`.slice(0, 220); + const frozenSnapshot = { + schemaVersion: REPORT_SCHEMA_VERSION, + source: { + actId: act.id, + actCode: act.code, + actVersion: act.currentVersion, + actClosureSha256: act.closureSha256, + inspectionId: act.visitId, + inspectionCode: act.visitCode, + }, + sealedAct: closure.finalSnapshot, + }; + const frozenSha256 = sha256CanonicalJson(frozenSnapshot); + const [created] = (await manager.query(` + INSERT INTO inspection_reports ( + visit_id,act_id,report_year,report_number,code,status,pdf_status, + executive_summary,report_description,title,act_version,act_closure_sha256, + frozen_sha256,frozen_snapshot,generated_at,generated_by + ) VALUES ( + $1,$2,$3,$4,$5,'WORKING','PENDING',NULL,NULL,$6,$7,$8,$9,$10,$11,$12 + ) + RETURNING id + `, [ + act.visitId, + act.id, + act.actYear, + reportNumber, + code, + title, + act.currentVersion, + act.closureSha256, + frozenSha256, + frozenSnapshot, + generatedAt, + principal.userId, + ])) as Array<{ id: string }>; + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_GENERATED, + entityType: 'inspection_report', + entityId: created.id, + afterData: { code, - title, - act.currentVersion, - act.closureSha256, + actId: act.id, + inspectionId: act.visitId, + actVersion: act.currentVersion, frozenSha256, - frozenSnapshot, - generatedAt, - principal.userId, - ])) as Array<{ id: string }>; - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_REPORT_GENERATED, - entityType: 'inspection_report', - entityId: created.id, - afterData: { - code, - actId: act.id, - visitId: act.visitId, - actVersion: act.currentVersion, - frozenSha256, - pdfStatus: InspectionReportPdfStatus.PENDING, - }, - }, manager); - return this.getWithManager(manager, created.id); + status: InspectionReportStatus.WORKING, + }, + }, manager); + return this.getWithManager(manager, created.id); } async ensureWordForAct(actId: string): Promise { - const [row] = await this.dataSource.query('SELECT id FROM inspection_reports WHERE act_id = $1', [actId]) as Array<{ id: string }>; + const [row] = await this.dataSource.query( + 'SELECT id FROM inspection_reports WHERE act_id = $1', + [actId], + ) as Array<{ id: string }>; if (row) await this.word.ensure(row.id); await this.delivery.dispatchForAct(actId).catch(() => undefined); } @@ -398,25 +381,10 @@ export class InspectionReportsService { conditions.push(`( report.code ILIKE ${search} OR report.title ILIKE ${search} + OR report.gedo_if_identifier ILIKE ${search} OR act.code ILIKE ${search} OR act.title ILIKE ${search} OR visit.code ILIKE ${search} - OR visit.title ILIKE ${search} - OR EXISTS ( - SELECT 1 - FROM inspection_act_assets search_link - INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id - LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id - LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id - WHERE search_link.act_id = act.id - AND search_link.included = true - AND ( - search_asset.name ILIKE ${search} - OR search_asset.code ILIKE ${search} - OR search_company.name ILIKE ${search} - OR search_area.name ILIKE ${search} - ) - ) )`); } if (query.year) conditions.push(`report.report_year = ${add(query.year)}`); @@ -454,20 +422,16 @@ export class InspectionReportsService { return ` SELECT COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id', company.id, - 'code', company.code, - 'name', company.name - )) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies, + 'id',company.id,'code',company.code,'name',company.name + )) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies, COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id', area.id, - 'code', area.code, - 'name', area.name - )) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas + 'id',area.id,'code',area.code,'name',area.name + )) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas FROM inspection_act_assets context_link - INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id - LEFT JOIN assets company ON company.id = context_asset.operator_company_id - LEFT JOIN assets area ON area.id = context_asset.operational_area_id - WHERE context_link.act_id = act.id AND context_link.included = true + INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id + LEFT JOIN assets company ON company.id=context_asset.operator_company_id + LEFT JOIN assets area ON area.id=context_asset.operational_area_id + WHERE context_link.act_id=act.id AND context_link.included=true `; } @@ -481,50 +445,43 @@ export class InspectionReportsService { report.report_number AS "reportNumber", report.code, report.status, + report.executive_summary AS "executiveSummary", + report.report_description AS "reportDescription", + report.gedo_if_identifier AS "gedoIfIdentifier", + report.gedo_officialized_at AS "gedoOfficializedAt", + report.gedo_pdf_original_name AS "gedoPdfOriginalName", + report.gedo_pdf_sha256 AS "gedoPdfSha256", report.pdf_status AS "pdfStatus", report.word_status AS "wordStatus", report.word_generated_at AS "wordGeneratedAt", - report.review_status AS "reviewStatus", - report.current_revision_number AS "currentRevisionNumber", - report.approved_at AS "approvedAt", - report.signed_at AS "signedAt", report.title, report.act_version AS "actVersion", report.act_closure_sha256 AS "actClosureSha256", report.frozen_sha256 AS "frozenSha256", report.generated_at AS "generatedAt", JSONB_BUILD_OBJECT( - 'id', generator.id, - 'username', generator.username, - 'firstName', generator.first_name, - 'lastName', generator.last_name + 'id',generator.id,'username',generator.username, + 'firstName',generator.first_name,'lastName',generator.last_name ) AS "generatedBy", JSONB_BUILD_OBJECT( - 'id', act.id, - 'code', act.code, - 'title', act.title, - 'status', act.status, - 'occurredAt', act.occurred_at, - 'closedAt', act.closed_at + 'id',act.id,'code',act.code,'title',act.title,'status',act.status, + 'occurredAt',act.occurred_at,'sealedAt',act.sealed_at, + 'urgency',act.urgency,'deadlineAt',act.deadline_at ) AS act, JSONB_BUILD_OBJECT( - 'id', visit.id, - 'code', visit.code, - 'title', visit.title, - 'status', visit.status + 'id',visit.id,'code',visit.code,'status',visit.status ) AS visit, - COALESCE(context.companies, '[]'::jsonb) AS companies, - COALESCE(context.areas, '[]'::jsonb) AS areas, - COALESCE(finding_count.total, 0)::integer AS "findingCount" + COALESCE(context.companies,'[]'::jsonb) AS companies, + COALESCE(context.areas,'[]'::jsonb) AS areas, + COALESCE(finding_count.total,0)::integer AS "findingCount" FROM inspection_reports report - INNER JOIN inspection_acts act ON act.id = report.act_id - INNER JOIN inspection_visits visit ON visit.id = report.visit_id - INNER JOIN users generator ON generator.id = report.generated_by + INNER JOIN inspection_acts act ON act.id=report.act_id + INNER JOIN inspection_visits visit ON visit.id=report.visit_id + INNER JOIN users generator ON generator.id=report.generated_by LEFT JOIN LATERAL (${this.contextSelect()}) context ON true LEFT JOIN LATERAL ( - SELECT COUNT(*) AS total - FROM inspection_findings finding - WHERE finding.act_id = act.id AND finding.status <> 'VOIDED' + SELECT COUNT(*) AS total FROM inspection_findings finding + WHERE finding.act_id=act.id AND finding.status<>'VOIDED' ) finding_count ON true ${where} `; @@ -543,29 +500,15 @@ export class InspectionReportsService { return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; } - private async allocateNumber(manager: EntityManager, year: number): Promise { - const [row] = (await manager.query(` - INSERT INTO document_annual_sequences (document_type, year, last_number) - VALUES ($1, $2, 1) - ON CONFLICT (document_type, year) DO UPDATE SET - last_number = document_annual_sequences.last_number + 1, - updated_at = CURRENT_TIMESTAMP - RETURNING last_number AS number - `, [DocumentSequenceType.REPORT, year])) as Array<{ number: number }>; - return Number(row.number); - } - private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise { const rows = (await manager.query(` - SELECT 1 - FROM inspection_visit_members - WHERE visit_id = $1 AND user_id = $2 AND included = true - LIMIT 1 + SELECT 1 FROM inspection_visit_members + WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1 `, [visitId, userId])) as unknown[]; - if (rows.length === 0) { + if (!rows.length) { throw new ForbiddenException({ code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED', - message: 'Sólo un inspector asignado a la visita puede solicitar el informe', + message: 'Sólo un inspector asignado a la inspección puede solicitar el informe', }); } } diff --git a/api-v3/src/inspection-reports/smtp-delivery.service.ts b/api-v3/src/inspection-reports/smtp-delivery.service.ts index bce88d6..0e54de4 100644 --- a/api-v3/src/inspection-reports/smtp-delivery.service.ts +++ b/api-v3/src/inspection-reports/smtp-delivery.service.ts @@ -1,13 +1,27 @@ +import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'; import { connect as connectNet, Socket } from 'node:net'; import { connect as connectTls, TLSSocket } from 'node:tls'; -import { randomUUID } from 'node:crypto'; -import { ConfigService } from '@nestjs/config'; import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import { SmtpSecurityMode } from '../database/entities'; interface MailAttachment { filename:string; mimeType:string; content:Buffer; } interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; } interface Reply { code:number; text:string; } +export interface EffectiveSmtpSettings { + source: 'DATABASE' | 'ENVIRONMENT'; + host: string; + port: number; + securityMode: SmtpSecurityMode; + username: string | null; + password: string; + fromName: string | null; + fromEmail: string; + replyTo: string | null; +} + class SmtpSession { private buffer=''; private waiters:Array<(reply:Reply)=>void>=[]; private replyLines:string[]=[]; private readonly dataHandler=(chunk:Buffer|string)=>this.onData(typeof chunk==='string'?chunk:chunk.toString('utf8')); @@ -20,18 +34,164 @@ class SmtpSession { async command(command:string, expected:number|number[]){ this.socket.write(`${command}\r\n`); const reply=await this.reply(); const allowed=Array.isArray(expected)?expected:[expected]; if(!allowed.includes(reply.code))throw new Error(`SMTP ${reply.code}: ${reply.text}`); return reply; } write(data:string){this.socket.write(data);} end(){this.socket.end();} - current(){return this.socket;} } function subject(value:string){return `=?UTF-8?B?${Buffer.from(value,'utf8').toString('base64')}?=`;} function envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();} -function mime(input:MailInput,from:string){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].join('\r\n'); return body.replace(/^\./gm,'..'); } +function mime(input:MailInput,from:string,replyTo:string|null){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,replyTo?`Reply-To: ${replyTo}`:null,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].filter((line):line is string=>line!==null).join('\r\n'); return body.replace(/^\./gm,'..'); } @Injectable() export class SmtpDeliveryService { - constructor(private readonly config:ConfigService){} - configured(){return Boolean(this.config.get('SMTP_HOST')&&this.config.get('MAIL_FROM'));} - async send(input:MailInput):Promise<{messageId:string}>{ const host=this.config.get('SMTP_HOST'); const from=this.config.get('MAIL_FROM'); if(!host||!from)throw new Error('SMTP no configurado'); const port=Number(this.config.get('SMTP_PORT')??587); const secure=String(this.config.get('SMTP_SECURE')??'false').toLowerCase()==='true'; const user=this.config.get('SMTP_USER')??''; const pass=this.config.get('SMTP_PASS')??''; const base=await new Promise((resolve,reject)=>{ if(secure){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); } else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); } }); const session=new SmtpSession(base); const welcome=await session.reply(); if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`); let ehlo=await session.command(`EHLO dh-inspeccion`,250); if(!secure){ if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS'); await session.command('STARTTLS',220); const rawForTls=session.detachForUpgrade() as Socket; const upgraded=await new Promise((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }); session.replaceSocket(upgraded); ehlo=await session.command('EHLO dh-inspeccion',250); } - if(user){ if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${user}\u0000${pass}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); } else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(user).toString('base64'),334); await session.command(Buffer.from(pass).toString('base64'),235); } } - await session.command(`MAIL FROM:<${envelope(from)}>`,250); await session.command(`RCPT TO:<${input.to}>`,[250,251]); await session.command('DATA',354); session.write(`${mime(input,from)}\r\n.\r\n`); const sent=await session.reply(); if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`); await session.command('QUIT',221).catch(()=>undefined); session.end(); const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i); return {messageId:match?.[1]??randomUUID()}; } + constructor( + private readonly dataSource:DataSource, + private readonly config:ConfigService, + ){} + + async configured():Promise{return Boolean(await this.resolveSettings());} + + async fromAddress():Promise{ + const settings=await this.resolveSettings(); + if(!settings)return null; + return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail; + } + + async publicSettings(){ + const [row]=await this.dataSource.query(` + SELECT id,host,port,security_mode AS "securityMode",username, + (password_enc IS NOT NULL) AS "hasPassword",from_name AS "fromName", + from_email AS "fromEmail",reply_to AS "replyTo",enabled, + updated_at AS "updatedAt" + FROM system_smtp_settings ORDER BY created_at LIMIT 1 + `) as Array>; + if(row)return {...row,source:'DATABASE'}; + const fallback=await this.resolveEnvironment(); + return fallback?{ + source:'ENVIRONMENT',host:fallback.host,port:fallback.port, + securityMode:fallback.securityMode,username:fallback.username, + hasPassword:Boolean(fallback.password),fromName:fallback.fromName, + fromEmail:fallback.fromEmail,replyTo:fallback.replyTo,enabled:true, + }:{source:'NONE',enabled:false}; + } + + async saveSettings(input:{ + host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null; + password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean; + },userId:string){ + const [existing]=await this.dataSource.query(`SELECT id,password_enc AS "passwordEnc" FROM system_smtp_settings ORDER BY created_at LIMIT 1`) as Array<{id:string;passwordEnc:string|null}>; + const passwordEnc=input.password===undefined + ? existing?.passwordEnc??null + : input.password?this.encryptSecret(input.password):null; + if(existing){ + await this.dataSource.query(` + UPDATE system_smtp_settings SET host=$2,port=$3,security_mode=$4,username=$5, + password_enc=$6,from_name=$7,from_email=$8,reply_to=$9,enabled=$10, + updated_by=$11,updated_at=CURRENT_TIMESTAMP WHERE id=$1 + `,[existing.id,input.host,input.port,input.securityMode,input.username??null,passwordEnc,input.fromName,input.fromEmail,input.replyTo??null,input.enabled,userId]); + }else{ + await this.dataSource.query(` + INSERT INTO system_smtp_settings(host,port,security_mode,username,password_enc,from_name,from_email,reply_to,enabled,updated_by) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + `,[input.host,input.port,input.securityMode,input.username??null,passwordEnc,input.fromName,input.fromEmail,input.replyTo??null,input.enabled,userId]); + } + return this.publicSettings(); + } + + async send(input:MailInput):Promise<{messageId:string}>{ + const settings=await this.resolveSettings(); + if(!settings)throw new Error('SMTP no configurado'); + const {host,port,securityMode,userName,password}= { + host:settings.host,port:settings.port,securityMode:settings.securityMode, + userName:settings.username??'',password:settings.password, + }; + const implicitTls=securityMode===SmtpSecurityMode.TLS; + const base=await new Promise((resolve,reject)=>{ + if(implicitTls){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); } + else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); } + }); + const session=new SmtpSession(base); + const welcome=await session.reply(); + if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`); + let ehlo=await session.command('EHLO dh-inspeccion',250); + if(securityMode===SmtpSecurityMode.STARTTLS){ + if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS'); + await session.command('STARTTLS',220); + const rawForTls=session.detachForUpgrade() as Socket; + const upgraded=await new Promise((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }); + session.replaceSocket(upgraded); + ehlo=await session.command('EHLO dh-inspeccion',250); + } + if(userName){ + if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${userName}\u0000${password}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); } + else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(userName).toString('base64'),334); await session.command(Buffer.from(password).toString('base64'),235); } + } + const from=settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail; + await session.command(`MAIL FROM:<${envelope(from)}>`,250); + await session.command(`RCPT TO:<${input.to}>`,[250,251]); + await session.command('DATA',354); + session.write(`${mime(input,from,settings.replyTo)}\r\n.\r\n`); + const sent=await session.reply(); + if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`); + await session.command('QUIT',221).catch(()=>undefined); + session.end(); + const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i); + return {messageId:match?.[1]??randomUUID()}; + } + + private async resolveSettings():Promise{ + const [row]=await this.dataSource.query(` + SELECT host,port,security_mode AS "securityMode",username,password_enc AS "passwordEnc", + from_name AS "fromName",from_email AS "fromEmail",reply_to AS "replyTo",enabled + FROM system_smtp_settings ORDER BY created_at LIMIT 1 + `) as Array<{ + host:string;port:number;securityMode:SmtpSecurityMode;username:string|null;passwordEnc:string|null; + fromName:string;fromEmail:string;replyTo:string|null;enabled:boolean; + }>; + if(row){ + if(!row.enabled)return null; + return { + source:'DATABASE',host:row.host,port:Number(row.port),securityMode:row.securityMode, + username:row.username,password:row.passwordEnc?this.decryptSecret(row.passwordEnc):'', + fromName:row.fromName,fromEmail:row.fromEmail,replyTo:row.replyTo, + }; + } + return this.resolveEnvironment(); + } + + private async resolveEnvironment():Promise{ + const host=this.config.get('SMTP_HOST'); + const rawFrom=this.config.get('MAIL_FROM'); + if(!host||!rawFrom)return null; + const fromEmail=envelope(rawFrom); + const fromName=rawFrom.includes('<')?rawFrom.slice(0,rawFrom.indexOf('<')).trim().replace(/^"|"$/g,'')||null:null; + const secure=String(this.config.get('SMTP_SECURE')??'false').toLowerCase()==='true'; + return { + source:'ENVIRONMENT',host,port:Number(this.config.get('SMTP_PORT')??(secure?465:587)), + securityMode:secure?SmtpSecurityMode.TLS:SmtpSecurityMode.STARTTLS, + username:this.config.get('SMTP_USER')??null,password:this.config.get('SMTP_PASS')??'', + fromName,fromEmail,replyTo:null, + }; + } + + private encryptionKey():Buffer{ + const raw=this.config.get('SMTP_SETTINGS_MASTER_KEY'); + if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada'); + const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64'); + if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes'); + return key; + } + + private encryptSecret(value:string):string{ + const iv=randomBytes(12); const cipher=createCipheriv('aes-256-gcm',this.encryptionKey(),iv); + const encrypted=Buffer.concat([cipher.update(value,'utf8'),cipher.final()]); + const tag=cipher.getAuthTag(); + return `v1:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`; + } + + private decryptSecret(value:string):string{ + const [version,ivB64,tagB64,cipherB64]=value.split(':'); + if(version!=='v1'||!ivB64||!tagB64||!cipherB64)throw new Error('Credencial SMTP cifrada inválida'); + const decipher=createDecipheriv('aes-256-gcm',this.encryptionKey(),Buffer.from(ivB64,'base64')); + decipher.setAuthTag(Buffer.from(tagB64,'base64')); + return Buffer.concat([decipher.update(Buffer.from(cipherB64,'base64')),decipher.final()]).toString('utf8'); + } } diff --git a/api-v3/src/inspection-verifications/inspection-verifications.service.ts b/api-v3/src/inspection-verifications/inspection-verifications.service.ts index eff5ccd..a2fcdcc 100644 --- a/api-v3/src/inspection-verifications/inspection-verifications.service.ts +++ b/api-v3/src/inspection-verifications/inspection-verifications.service.ts @@ -27,10 +27,8 @@ interface VerificationContextView { interface VerificationVisitView { id: string; code: string; - title: string; status: InspectionVisitStatus; plannedStartAt: Date | null; - plannedEndAt: Date | null; } export interface VerificationPlanningItem { @@ -39,12 +37,11 @@ export interface VerificationPlanningItem { title: string; status: 'OPEN'; nextControlOn: string; - companyResponseReceivedOn: string; asset: VerificationContextView & { typeName: string }; company: VerificationContextView | null; area: VerificationContextView | null; act: { id: string; code: string }; - sourceVisit: { id: string; code: string; title: string }; + sourceVisit: { id: string; code: string }; verificationVisit: VerificationVisitView | null; } @@ -75,7 +72,6 @@ interface LockedFindingRow { areaId: string | null; areaCode: string | null; areaName: string | null; - companyResponseReceivedOn: string | null; nextControlOn: string | null; status: string; } @@ -93,7 +89,6 @@ export class InspectionVerificationsService { const values: unknown[] = []; const baseFilters = [ `finding.status = 'OPEN'`, - `finding.company_response_received_on IS NOT NULL`, `finding.next_control_on IS NOT NULL`, `COALESCE(( SELECT latest_verification.outcome @@ -132,10 +127,8 @@ export class InspectionVerificationsService { SELECT visit.id, visit.code, - visit.title, visit.status, - visit.planned_start_at AS "plannedStartAt", - visit.planned_end_at AS "plannedEndAt" + visit.planned_start_at AS "plannedStartAt" FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id WHERE verification_link.finding_id = finding.id @@ -181,7 +174,6 @@ export class InspectionVerificationsService { finding.title, finding.status, finding.next_control_on AS "nextControlOn", - finding.company_response_received_on AS "companyResponseReceivedOn", jsonb_build_object( 'id', asset.id, 'code', asset.code, @@ -201,16 +193,13 @@ export class InspectionVerificationsService { jsonb_build_object('id', act.id, 'code', act.code) AS act, jsonb_build_object( 'id', source_visit.id, - 'code', source_visit.code, - 'title', source_visit.title + 'code', source_visit.code ) AS "sourceVisit", CASE WHEN verification_visit.id IS NULL THEN NULL ELSE jsonb_build_object( 'id', verification_visit.id, 'code', verification_visit.code, - 'title', verification_visit.title, 'status', verification_visit.status, - 'plannedStartAt', verification_visit."plannedStartAt", - 'plannedEndAt', verification_visit."plannedEndAt" + 'plannedStartAt', verification_visit."plannedStartAt" ) END AS "verificationVisit" FROM inspection_findings finding ${joins} @@ -503,7 +492,6 @@ export class InspectionVerificationsService { finding.title, finding.status, finding.asset_id AS "assetId", - finding.company_response_received_on AS "companyResponseReceivedOn", finding.next_control_on AS "nextControlOn", asset.code AS "assetCode", asset.name AS "assetName", @@ -524,13 +512,11 @@ export class InspectionVerificationsService { if (rows.length !== findingIds.length) { throw new NotFoundException({ code: 'VERIFICATION_FINDING_NOT_FOUND', message: 'Uno o más hallazgos ya no están disponibles' }); } - const invalid = rows.find((row) => - row.status !== 'OPEN' || !row.companyResponseReceivedOn || !row.nextControlOn, - ); + const invalid = rows.find((row) => row.status !== 'OPEN' || !row.nextControlOn); if (invalid) { throw new ConflictException({ code: 'VERIFICATION_FINDING_NOT_READY', - message: `${invalid.code} no está listo para una verificación planificada`, + message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`, }); } const missingContext = rows.find((row) => !row.companyId || !row.areaId); @@ -569,7 +555,6 @@ export class InspectionVerificationsService { const companyName = rows[0]?.companyName ?? 'Empresa'; const areaName = rows[0]?.areaName ?? 'Área'; const code = await nextInspectionVisitCode(manager, plannedStartAt); - const title = code; const findingCodes = rows.map((row) => row.code).join(', '); const notes = dto.notes?.trim() || null; const instructions = [ @@ -579,7 +564,6 @@ export class InspectionVerificationsService { const visit = manager.getRepository(InspectionVisit).create({ code, - title, objective: 'Verificar en campo la resolución de hallazgos con fecha de control programada.', status: InspectionVisitStatus.DRAFT, scopeAssetId: areaId, @@ -587,7 +571,6 @@ export class InspectionVerificationsService { operatorCompanyId: companyId, leadInspectorUserId: null, plannedStartAt, - plannedEndAt: null, actualStartedAt: null, actualClosedAt: null, instructions, @@ -650,7 +633,6 @@ export class InspectionVerificationsService { code: visit.code, status: visit.status, plannedStartAt: visit.plannedStartAt, - plannedEndAt: visit.plannedEndAt, companyId, areaId, findingIds, @@ -662,10 +644,8 @@ export class InspectionVerificationsService { visit: { id: visit.id, code: visit.code, - title: visit.title, status: visit.status, plannedStartAt: visit.plannedStartAt, - plannedEndAt: visit.plannedEndAt, }, company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName }, area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName }, @@ -719,9 +699,6 @@ export class InspectionVerificationsService { 'title', finding.title, 'description', finding.description, 'correctionDueOn', finding.correction_due_on, - 'companyResponse', finding.company_response, - 'companyResponseReceivedOn', finding.company_response_received_on, - 'companyCommittedCorrectionOn', finding.company_committed_correction_on, 'nextControlOn', finding.next_control_on, 'currentVersion', finding.current_version, 'verification', JSONB_BUILD_OBJECT( @@ -759,5 +736,4 @@ export class InspectionVerificationsService { ]); return versionNumber; } - } diff --git a/api-v3/src/inspection-visits/dto/cancel-inspection-visit.dto.ts b/api-v3/src/inspection-visits/dto/cancel-inspection-visit.dto.ts new file mode 100644 index 0000000..4116c81 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/cancel-inspection-visit.dto.ts @@ -0,0 +1,10 @@ +import { Transform } from 'class-transformer'; +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class CancelInspectionVisitDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(10) + @MaxLength(4000) + reason!: string; +} diff --git a/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts b/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts index 8adab5f..abbce3a 100644 --- a/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts +++ b/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts @@ -18,12 +18,11 @@ const optionalText = ({ value }: { value: unknown }) => /** * Hallazgo capturado desde la APK sobre un Inventario ya seleccionado. * El assetId se toma de la URL para evitar inconsistencias entre pantalla y payload. - * `actId` queda opcional sólo para compatibilidad con Android 0.11.0; F3.2 siempre lo envía. + * El Acta es obligatoria: nunca se infiere ni se adivina en backend. */ export class CreateFieldFindingDto { - @IsOptional() @IsUUID('4') - actId?: string; + actId!: string; @IsOptional() @Transform(optionalText) @@ -43,6 +42,7 @@ export class CreateFieldFindingDto { @MaxLength(12000) customLegalBasis?: string | null; + /** Descripción libre escrita por el inspector. Nunca se reemplaza por el catálogo. */ @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(1) diff --git a/api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts b/api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts index 5df166f..0a570fc 100644 --- a/api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts +++ b/api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts @@ -1,12 +1,7 @@ -import { IsOptional, IsUUID } from 'class-validator'; +import { IsUUID } from 'class-validator'; -/** - * Selección explícita del Acta activa desde la APK. - * `actId` queda opcional sólo durante la transición desde Android 0.11.0; - * F3.2 móvil siempre lo informa. - */ +/** Selección explícita y obligatoria del Acta activa desde la APK. */ export class FieldFindingActQueryDto { - @IsOptional() @IsUUID('4') - actId?: string; + actId!: string; } diff --git a/api-v3/src/inspection-visits/f4-inspection-visits.service.ts b/api-v3/src/inspection-visits/f4-inspection-visits.service.ts new file mode 100644 index 0000000..8987e5d --- /dev/null +++ b/api-v3/src/inspection-visits/f4-inspection-visits.service.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; +import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; +import type { InspectionVisitView } from './inspection-visits.service'; +import { InspectionVisitsService } from './inspection-visits.service'; + +/** + * Capa activa F4 sobre el servicio de Inspecciones. + * + * Conserva las operaciones de planificación/equipo/Inventario ya probadas y aplica + * la clasificación técnica F4 del checklist. El contrato público de Inspección ya + * no expone título independiente ni fecha prevista de fin. + */ +@Injectable() +export class F4InspectionVisitsService extends InspectionVisitsService { + constructor( + private readonly f4DataSource: DataSource, + audit: AuditService, + ) { + super(f4DataSource, audit); + } + + override async list(query: ListInspectionVisitsQueryDto) { + const response = await super.list(query); + return { + ...response, + data: response.data.map((visit) => this.normalizeChecklist(visit)), + }; + } + + override async getById(id: string): Promise { + await this.reclassifyCurrentChecklist(id); + return this.normalizeChecklist(await super.getById(id)); + } + + override async create( + dto: CreateInspectionVisitDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + const created = await super.create(dto, principal, request); + await this.reclassifyCurrentChecklist(created.id); + return this.normalizeChecklist(await super.getById(created.id)); + } + + override async generateChecklist( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + await super.generateChecklist(id, principal, request); + await this.reclassifyCurrentChecklist(id); + return this.normalizeChecklist(await super.getById(id)); + } + + private normalizeChecklist(visit: T): T { + if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit; + const view = visit as T & InspectionVisitView; + view.checklist = { + ...view.checklist, + companyOverdue: 0, + items: view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE'), + }; + return view; + } + + private async reclassifyCurrentChecklist(visitId: string): Promise { + await this.f4DataSource.transaction(async (manager) => { + const [visit] = await manager.query(` + SELECT checklist_generation AS generation, planned_start_at AS "plannedStartAt" + FROM inspection_visits + WHERE id=$1 + FOR UPDATE + `, [visitId]) as Array<{ generation: number; plannedStartAt: Date | null }>; + if (!visit || Number(visit.generation) < 1 || !visit.plannedStartAt) return; + + const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10); + await manager.query(` + UPDATE inspection_visit_checklist_items item + SET + item_kind = CASE + WHEN finding.status='OPEN' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on < $3::date + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id + AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, + verification.created_at DESC, + verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + THEN 'VERIFICATION_OVERDUE' + WHEN finding.status='OPEN' + AND finding.next_control_on IS NOT NULL + AND finding.next_control_on BETWEEN $3::date AND ($3::date + 30) + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id + AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, + verification.created_at DESC, + verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + THEN 'UPCOMING_CONTROL' + ELSE 'ANTECEDENT' + END, + reference_on = CASE + WHEN finding.status='OPEN' + AND finding.next_control_on IS NOT NULL + AND COALESCE(( + SELECT verification.outcome + FROM inspection_finding_verification_visits verification + WHERE verification.finding_id=finding.id + AND verification.outcome IS NOT NULL + ORDER BY verification.result_recorded_at DESC NULLS LAST, + verification.created_at DESC, + verification.id DESC + LIMIT 1 + ), '') <> 'RESOLVED' + THEN finding.next_control_on + ELSE NULL + END + FROM inspection_findings finding + WHERE item.visit_id=$1 + AND item.generation_number=$2 + AND finding.id=item.finding_id + `, [visitId, visit.generation, targetDate]); + }); + } +} diff --git a/api-v3/src/inspection-visits/inspection-visit-code.ts b/api-v3/src/inspection-visits/inspection-visit-code.ts index 3b48b52..fa946bd 100644 --- a/api-v3/src/inspection-visits/inspection-visit-code.ts +++ b/api-v3/src/inspection-visits/inspection-visit-code.ts @@ -4,12 +4,14 @@ export async function nextInspectionVisitCode( manager: EntityManager, plannedStartAt: Date, ): Promise { - const [yearRow] = (await manager.query(` - SELECT EXTRACT( - YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza' - )::integer AS year - `, [plannedStartAt])) as Array<{ year: number }>; - const year = Number(yearRow?.year); + const [dateRow] = (await manager.query(` + SELECT + EXTRACT(YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::integer AS year, + TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD') AS day, + TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'MM') AS month, + TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'YY') AS short_year + `, [plannedStartAt])) as Array<{ year: number; day: string; month: string; short_year: string }>; + const year = Number(dateRow?.year); if (!Number.isInteger(year) || year < 2000 || year > 9999) { throw new Error('Unable to derive inspection visit year'); } @@ -20,15 +22,15 @@ export async function nextInspectionVisitCode( ); const [sequenceRow] = (await manager.query(` - SELECT COALESCE(MAX(RIGHT(code, 6)::integer), 0)::integer AS sequence + SELECT COALESCE(MAX(SUBSTRING(code FROM 6 FOR 5)::integer), 0)::integer AS sequence FROM inspection_visits - WHERE code LIKE $1 - AND code ~ ('^INS-' || $2::text || '-[0-9]{6}$') - `, [`INS-${year}-%`, year])) as Array<{ sequence: number }>; + WHERE code ~ '^INSP-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}$' + AND RIGHT(code, 2) = $1 + `, [dateRow.short_year])) as Array<{ sequence: number }>; const sequence = Number(sequenceRow?.sequence ?? 0) + 1; - if (sequence > 999999) { + if (sequence > 99999) { throw new Error(`Inspection visit sequence exhausted for ${year}`); } - return `INS-${year}-${String(sequence).padStart(6, '0')}`; + return `INSP-${String(sequence).padStart(5, '0')}-${dateRow.day}-${dateRow.month}-${dateRow.short_year}`; } diff --git a/api-v3/src/inspection-visits/inspection-visit-lifecycle.service.ts b/api-v3/src/inspection-visits/inspection-visit-lifecycle.service.ts new file mode 100644 index 0000000..d1d7418 --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-visit-lifecycle.service.ts @@ -0,0 +1,362 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AuditAction, InspectionVisit, InspectionVisitStatus } from '../database/entities'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import type { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto'; +import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; +import { InspectionVisitsService } from './inspection-visits.service'; + +interface PendingActRow { + id: string; + code: string; + status: string; +} + +@Injectable() +export class InspectionVisitLifecycleService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly visits: InspectionVisitsService, + ) {} + + async plan( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + await this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (visit.status !== InspectionVisitStatus.DRAFT) { + throw new ConflictException({ + code: 'INSPECTION_NOT_DRAFT', + message: 'Sólo una Inspección en borrador puede planificarse', + }); + } + await this.validatePlanningContext(manager, visit); + visit.status = InspectionVisitStatus.PLANNED; + visit.cancellationReason = null; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: InspectionVisitStatus.DRAFT }, + afterData: { status: InspectionVisitStatus.PLANNED }, + metadata: { transition: 'PLAN' }, + }, manager); + }); + return this.visits.getById(id); + } + + async unplan( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + await this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (visit.status !== InspectionVisitStatus.PLANNED) { + throw new ConflictException({ + code: 'INSPECTION_NOT_PLANNED_FOR_UNPLAN', + message: 'Sólo una Inspección planificada puede volver a borrador', + }); + } + const [startedAct] = await manager.query(` + SELECT 1 + FROM inspection_acts act + WHERE act.visit_id=$1 + AND act.status NOT IN ('DRAFT','CANCELLED') + LIMIT 1 + `, [id]) as unknown[]; + if (startedAct) { + throw new ConflictException({ + code: 'INSPECTION_UNPLAN_ACT_ALREADY_ADVANCED', + message: 'La Inspección ya tiene un Acta avanzada y no puede volver a borrador', + }); + } + visit.status = InspectionVisitStatus.DRAFT; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: InspectionVisitStatus.PLANNED }, + afterData: { status: InspectionVisitStatus.DRAFT }, + metadata: { transition: 'UNPLAN', explicitAction: true }, + }, manager); + }); + return this.visits.getById(id); + } + + async start( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + assertMobileInspector(principal); + await this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (visit.status !== InspectionVisitStatus.PLANNED) { + throw new ConflictException({ + code: 'INSPECTION_NOT_PLANNED', + message: 'La Inspección debe estar planificada antes de iniciarse', + }); + } + await this.validatePlanningContext(manager, visit); + await this.assertActorAssigned(manager, visit, principal); + const startedAt = new Date(); + visit.status = InspectionVisitStatus.IN_PROGRESS; + visit.actualStartedAt = startedAt; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STARTED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: InspectionVisitStatus.PLANNED, actualStartedAt: null }, + afterData: { status: InspectionVisitStatus.IN_PROGRESS, actualStartedAt: startedAt }, + metadata: { transition: 'START', source: 'ANDROID' }, + }, manager); + }); + return this.visits.getById(id); + } + + async close( + id: string, + dto: CloseInspectionVisitDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + assertMobileInspector(principal); + await this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (visit.status !== InspectionVisitStatus.IN_PROGRESS) { + throw new ConflictException({ + code: 'INSPECTION_NOT_IN_PROGRESS', + message: 'La Inspección debe estar en curso para cerrarse', + }); + } + await this.assertActorAssigned(manager, visit, principal); + + const acts = await manager.query(` + SELECT id,code,status + FROM inspection_acts + WHERE visit_id=$1 AND status<>'CANCELLED' + ORDER BY occurred_at,created_at,id + FOR UPDATE + `, [id]) as PendingActRow[]; + if (acts.length === 0) { + throw new ConflictException({ + code: 'INSPECTION_REQUIRES_ACT', + message: 'La Inspección debe tener al menos un Acta antes de cerrarse', + }); + } + const pending = acts.filter((act) => act.status !== 'SEALED'); + if (pending.length > 0) { + throw new ConflictException({ + code: 'INSPECTION_ACTS_NOT_SEALED', + message: 'Todas las Actas deben estar SELLADAS antes de cerrar la Inspección', + pendingActs: pending.map((act) => ({ id: act.id, code: act.code, status: act.status })), + }); + } + await this.assertVerificationResultsComplete(manager, id); + + const serverClosedAt = new Date(); + const clientClosedAt = new Date(dto.clientClosedAt); + if (!Number.isFinite(clientClosedAt.getTime())) { + throw new BadRequestException({ + code: 'INSPECTION_CLOSE_TIME_INVALID', + message: 'La fecha de cierre informada por el dispositivo no es válida', + }); + } + if (visit.actualStartedAt && clientClosedAt.getTime() < visit.actualStartedAt.getTime()) { + throw new BadRequestException({ + code: 'INSPECTION_CLOSE_BEFORE_START', + message: 'La fecha de cierre no puede ser anterior al inicio de la Inspección', + }); + } + if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { + throw new BadRequestException({ + code: 'INSPECTION_CLOSE_TIME_IN_FUTURE', + message: 'La fecha del dispositivo no puede estar más de 24 horas en el futuro', + }); + } + + visit.status = InspectionVisitStatus.CLOSED; + visit.actualClosedAt = serverClosedAt; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: InspectionVisitStatus.IN_PROGRESS }, + afterData: { + status: InspectionVisitStatus.CLOSED, + clientClosedAt: clientClosedAt.toISOString(), + serverClosedAt: serverClosedAt.toISOString(), + sealedActs: acts.length, + }, + metadata: { + transition: 'CLOSE', + source: 'ANDROID', + allActsSealed: true, + allVerificationResultsRecorded: true, + }, + }, manager); + }); + return this.visits.getById(id); + } + + async cancel( + id: string, + dto: CancelInspectionVisitDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + await this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (![InspectionVisitStatus.DRAFT, InspectionVisitStatus.PLANNED, InspectionVisitStatus.IN_PROGRESS].includes(visit.status)) { + throw new ConflictException({ + code: 'INSPECTION_CANNOT_CANCEL', + message: 'La Inspección ya no puede cancelarse en su estado actual', + }); + } + const [sealedAct] = await manager.query(` + SELECT 1 FROM inspection_acts + WHERE visit_id=$1 AND status='SEALED' + LIMIT 1 + `, [id]) as unknown[]; + if (sealedAct) { + throw new ConflictException({ + code: 'INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL', + message: 'Una Inspección con Actas SELLADAS no puede cancelarse', + }); + } + const before = visit.status; + visit.status = InspectionVisitStatus.CANCELLED; + visit.cancellationReason = dto.reason; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: before }, + afterData: { status: InspectionVisitStatus.CANCELLED, cancellationReason: dto.reason }, + metadata: { transition: 'CANCEL' }, + }, manager); + }); + return this.visits.getById(id); + } + + private async lockVisit(manager: EntityManager, id: string): Promise { + const visit = await manager.getRepository(InspectionVisit) + .createQueryBuilder('visit') + .where('visit.id=:id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!visit) { + throw new NotFoundException({ code: 'INSPECTION_NOT_FOUND', message: 'Inspección no encontrada' }); + } + return visit; + } + + private async assertVerificationResultsComplete(manager: EntityManager, inspectionId: string): Promise { + const [row] = await manager.query(` + SELECT + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE outcome IS NOT NULL)::integer AS completed + FROM inspection_finding_verification_visits + WHERE visit_id=$1 + `, [inspectionId]) as Array<{ total: number; completed: number }>; + if (Number(row?.total ?? 0) > 0 && Number(row.completed) !== Number(row.total)) { + throw new ConflictException({ + code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED', + message: 'Registrá el resultado de todas las verificaciones antes de cerrar la Inspección', + }); + } + } + + private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise { + if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) { + throw new ConflictException({ + code: 'INSPECTION_PLANNING_INCOMPLETE', + message: 'Definí Área/Yacimiento, Operadora, Inspector responsable y fecha de inicio antes de planificar', + }); + } + if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) { + throw new BadRequestException({ + code: 'INSPECTION_CHECKLIST_REQUIRED', + message: 'Generá el checklist automático antes de confirmar la planificación', + }); + } + const [relation] = await manager.query(` + SELECT 1 + FROM area_company_relations + WHERE area_id=$1 AND company_id=$2 + AND relation_role='OPERATOR' + AND valid_from<=CURRENT_TIMESTAMP + AND (valid_until IS NULL OR valid_until>CURRENT_TIMESTAMP) + LIMIT 1 + `, [visit.operationalAreaId, visit.operatorCompanyId]) as unknown[]; + if (!relation) { + throw new ConflictException({ + code: 'INSPECTION_OPERATOR_RELATION_INVALID', + message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área/Yacimiento', + }); + } + const [inspector] = await manager.query(` + SELECT 1 + FROM users user_account + JOIN user_roles user_role ON user_role.user_id=user_account.id + JOIN role_permissions role_permission ON role_permission.role_id=user_role.role_id + JOIN permissions permission ON permission.id=role_permission.permission_id + WHERE user_account.id=$1 AND user_account.status='ACTIVE' + AND permission.code='inspections.execute' + LIMIT 1 + `, [visit.leadInspectorUserId]) as unknown[]; + if (!inspector) { + throw new ConflictException({ + code: 'INSPECTION_LEAD_INVALID', + message: 'El Inspector responsable no está activo o no puede ejecutar Inspecciones', + }); + } + } + + private async assertActorAssigned( + manager: EntityManager, + visit: InspectionVisit, + principal: AuthPrincipal, + ): Promise { + if (principal.permissions.includes('inspections.manage')) return; + const [member] = await manager.query(` + SELECT 1 FROM inspection_visit_members + WHERE visit_id=$1 AND user_id=$2 AND included=true + LIMIT 1 + `, [visit.id, principal.userId]) as unknown[]; + if (!member && visit.leadInspectorUserId !== principal.userId) { + throw new ForbiddenException({ + code: 'INSPECTION_NOT_ASSIGNED', + message: 'La Inspección no está asignada al usuario actual', + }); + } + } +} diff --git a/api-v3/src/inspection-visits/inspection-visits.controller.ts b/api-v3/src/inspection-visits/inspection-visits.controller.ts index 5fb0c86..7be5b20 100644 --- a/api-v3/src/inspection-visits/inspection-visits.controller.ts +++ b/api-v3/src/inspection-visits/inspection-visits.controller.ts @@ -13,7 +13,7 @@ import { import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto'; +import { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto'; import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto'; @@ -21,11 +21,15 @@ import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; +import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service'; import { InspectionVisitsService } from './inspection-visits.service'; @Controller('inspection-visits') export class InspectionVisitsController { - constructor(private readonly visits: InspectionVisitsService) {} + constructor( + private readonly visits: InspectionVisitsService, + private readonly lifecycle: InspectionVisitLifecycleService, + ) {} @Get() @RequirePermissions('inspections.read') @@ -135,15 +139,24 @@ export class InspectionVisitsController { return this.visits.replaceTeam(id, dto, principal, request); } - @Put(':id/status') + @Post(':id/plan') @RequirePermissions('inspections.manage') - changeStatus( + plan( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: ChangeInspectionVisitStatusDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, ) { - return this.visits.changeStatus(id, dto, principal, request); + return this.lifecycle.plan(id, principal, request); + } + + @Post(':id/unplan') + @RequirePermissions('inspections.manage') + unplan( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.lifecycle.unplan(id, principal, request); } @Post(':id/start') @@ -153,7 +166,7 @@ export class InspectionVisitsController { @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, ) { - return this.visits.start(id, principal, request); + return this.lifecycle.start(id, principal, request); } @Post(':id/close') @@ -164,6 +177,17 @@ export class InspectionVisitsController { @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, ) { - return this.visits.close(id, dto, principal, request); + return this.lifecycle.close(id, dto, principal, request); + } + + @Post(':id/cancel') + @RequirePermissions('inspections.manage') + cancel( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: CancelInspectionVisitDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.lifecycle.cancel(id, dto, principal, request); } } diff --git a/api-v3/src/inspection-visits/inspection-visits.module.ts b/api-v3/src/inspection-visits/inspection-visits.module.ts index 46e3326..1f139e7 100644 --- a/api-v3/src/inspection-visits/inspection-visits.module.ts +++ b/api-v3/src/inspection-visits/inspection-visits.module.ts @@ -3,10 +3,12 @@ import { AssetMasterModule } from '../asset-master/asset-master.module'; import { AuditModule } from '../audit/audit.module'; import { InspectionFindingsModule } from '../inspection-findings/inspection-findings.module'; import { F3FieldInventoryStructureService } from './f3-field-inventory-structure.service'; +import { F4InspectionVisitsService } from './f4-inspection-visits.service'; import { FieldFindingsController } from './field-findings.controller'; import { FieldFindingsService } from './field-findings.service'; import { FieldInventoryController } from './field-inventory.controller'; import { FieldInventoryService } from './field-inventory.service'; +import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service'; import { InspectionVisitsController } from './inspection-visits.controller'; import { InspectionVisitsService } from './inspection-visits.service'; @@ -14,7 +16,8 @@ import { InspectionVisitsService } from './inspection-visits.service'; imports: [AuditModule, AssetMasterModule, InspectionFindingsModule], controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController], providers: [ - InspectionVisitsService, + { provide: InspectionVisitsService, useClass: F4InspectionVisitsService }, + InspectionVisitLifecycleService, FieldInventoryService, F3FieldInventoryStructureService, FieldFindingsService, diff --git a/api-v3/src/inspection-visits/inspection-visits.service.ts b/api-v3/src/inspection-visits/inspection-visits.service.ts index 8bb29d6..b2cf21d 100644 --- a/api-v3/src/inspection-visits/inspection-visits.service.ts +++ b/api-v3/src/inspection-visits/inspection-visits.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, ConflictException, - ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -18,14 +17,11 @@ import { InspectionVisitAssetPlanningSource, InspectionVisitStatus, } from '../database/entities'; -import type { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto'; -import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; -import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import { nextInspectionVisitCode } from './inspection-visit-code'; export interface InspectionPerson { @@ -91,7 +87,6 @@ export interface InspectionVisitChecklistSummary { export interface InspectionVisitListItem { id: string; code: string; - title: string; objective: string | null; status: InspectionVisitStatus; scopeAsset: InspectionAssetSummary | null; @@ -99,7 +94,6 @@ export interface InspectionVisitListItem { operatorCompany: InspectionPlanningContextAsset | null; leadInspector: InspectionPerson | null; plannedStartAt: Date | null; - plannedEndAt: Date | null; actualStartedAt: Date | null; actualClosedAt: Date | null; instructions: string | null; @@ -165,7 +159,7 @@ export class InspectionVisitsService { }; if (query.search?.trim()) { const search = add(`%${query.search.trim()}%`); - conditions.push(`(visit.code ILIKE ${search} OR visit.title ILIKE ${search})`); + conditions.push(`visit.code ILIKE ${search}`); } if (query.status) conditions.push(`visit.status = ${add(query.status)}`); if (query.companyId) { @@ -324,7 +318,6 @@ export class InspectionVisitsService { const code = await nextInspectionVisitCode(manager, plannedStartAt); const visit = manager.getRepository(InspectionVisit).create({ code, - title: code, objective: null, status: InspectionVisitStatus.DRAFT, scopeAssetId: operationalAreaId, @@ -332,7 +325,6 @@ export class InspectionVisitsService { operatorCompanyId, leadInspectorUserId: dto.leadInspectorUserId, plannedStartAt, - plannedEndAt: null, actualStartedAt: null, actualClosedAt: null, instructions: null, @@ -715,209 +707,11 @@ export class InspectionVisitsService { }); } - async changeStatus( - id: string, - dto: ChangeInspectionVisitStatusDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - return this.dataSource.transaction(async (manager) => { - const visit = await this.lockVisit(manager, id); - if (visit.status === dto.status) return this.loadView(manager, id); - const cancellation = dto.status === InspectionVisitStatus.CANCELLED; - const reversiblePlanning = - (visit.status === InspectionVisitStatus.DRAFT && dto.status === InspectionVisitStatus.PLANNED) || - (visit.status === InspectionVisitStatus.PLANNED && dto.status === InspectionVisitStatus.DRAFT); - const cancellable = cancellation && [ - InspectionVisitStatus.DRAFT, - InspectionVisitStatus.PLANNED, - InspectionVisitStatus.IN_PROGRESS, - ].includes(visit.status); - if (!reversiblePlanning && !cancellable) { - throw new ConflictException({ - code: 'INSPECTION_VISIT_INVALID_TRANSITION', - message: 'La transición solicitada no está permitida en esta fase', - }); - } - if (dto.status === InspectionVisitStatus.PLANNED) { - await this.validatePlan(manager, visit); - } - const reason = dto.reason?.trim() || null; - if (cancellation && (!reason || reason.length < 10)) { - throw new BadRequestException({ - code: 'INSPECTION_CANCELLATION_REASON_REQUIRED', - message: 'Indicá un motivo de cancelación de al menos 10 caracteres', - }); - } - const beforeStatus = visit.status; - visit.status = dto.status; - visit.cancellationReason = cancellation ? reason : null; - visit.updatedBy = principal.userId; - await manager.getRepository(InspectionVisit).save(visit); - const updated = await this.loadView(manager, id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, - entityType: 'inspection_visit', - entityId: id, - beforeData: { status: beforeStatus }, - afterData: { status: dto.status, cancellationReason: visit.cancellationReason }, - }, manager); - return updated; - }); - } - - async start( - id: string, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - assertMobileInspector(principal); - return this.dataSource.transaction(async (manager) => { - const visit = await this.lockVisit(manager, id); - if (visit.status !== InspectionVisitStatus.PLANNED) { - throw new ConflictException({ - code: 'INSPECTION_VISIT_NOT_PLANNED', - message: 'La visita debe estar planificada antes de iniciarse', - }); - } - await this.validatePlan(manager, visit); - if (!principal.permissions.includes('inspections.manage')) { - const memberIds = await this.activeMemberIds(manager, id); - if (!memberIds.includes(principal.userId)) { - throw new ForbiddenException({ - code: 'INSPECTION_VISIT_NOT_ASSIGNED', - message: 'La visita no está asignada al usuario actual', - }); - } - } - visit.status = InspectionVisitStatus.IN_PROGRESS; - visit.actualStartedAt = new Date(); - visit.updatedBy = principal.userId; - await manager.getRepository(InspectionVisit).save(visit); - const updated = await this.loadView(manager, id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_VISIT_STARTED, - entityType: 'inspection_visit', - entityId: id, - beforeData: { status: InspectionVisitStatus.PLANNED, actualStartedAt: null }, - afterData: { - status: InspectionVisitStatus.IN_PROGRESS, - actualStartedAt: visit.actualStartedAt, - }, - }, manager); - return updated; - }); - } - - - async close( - id: string, - dto: CloseInspectionVisitDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - assertMobileInspector(principal); - return this.dataSource.transaction(async (manager) => { - const visit = await this.lockVisit(manager, id); - if (visit.status !== InspectionVisitStatus.IN_PROGRESS) { - throw new ConflictException({ - code: 'INSPECTION_VISIT_NOT_IN_PROGRESS', - message: 'La inspección debe estar en curso para cerrarse', - }); - } - if (!principal.permissions.includes('inspections.manage')) { - const memberIds = await this.activeMemberIds(manager, id); - if (!memberIds.includes(principal.userId)) { - throw new ForbiddenException({ - code: 'INSPECTION_VISIT_NOT_ASSIGNED', - message: 'La inspección no está asignada al usuario actual', - }); - } - } - const [acts] = (await manager.query(` - SELECT - COUNT(*) FILTER (WHERE act.status = 'DRAFT')::integer AS drafts, - COUNT(*) FILTER ( - WHERE act.status = 'READY' - AND NOT EXISTS ( - SELECT 1 FROM inspection_act_signatures signature - WHERE signature.act_id = act.id - AND signature.signer_type = 'INSPECTOR' - AND signature.status = 'SIGNED' - ) - )::integer AS "readyWithoutInspector", - COUNT(*) FILTER ( - WHERE act.status = 'READY' - AND NOT EXISTS ( - SELECT 1 FROM inspection_act_signatures signature - WHERE signature.act_id = act.id - AND signature.signer_type = 'COMPANY_RESPONSIBLE' - ) - )::integer AS "pendingCompany" - FROM inspection_acts act - WHERE act.visit_id = $1 - AND act.status <> 'CANCELLED' - `, [id])) as Array<{ drafts: number; readyWithoutInspector: number; pendingCompany: number }>; - if (Number(acts?.drafts ?? 0) > 0) { - throw new ConflictException({ - code: 'INSPECTION_VISIT_DRAFT_ACTS_PENDING', - message: 'Prepará o cancelá las actas en borrador antes de cerrar la inspección', - }); - } - if (Number(acts?.readyWithoutInspector ?? 0) > 0) { - throw new ConflictException({ - code: 'INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING', - message: 'Toda acta preparada debe tener firma de inspector antes de cerrar la inspección', - }); - } - const serverClosedAt = new Date(); - const clientClosedAt = new Date(dto.clientClosedAt); - if (visit.actualStartedAt && clientClosedAt.getTime() < visit.actualStartedAt.getTime()) { - throw new BadRequestException({ - code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME', - message: 'La fecha de cierre del dispositivo no puede ser anterior al inicio de la inspección', - }); - } - if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { - throw new BadRequestException({ - code: 'INSPECTION_VISIT_INVALID_DEVICE_TIME', - message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro', - }); - } - visit.status = InspectionVisitStatus.CLOSED; - visit.actualClosedAt = serverClosedAt; - visit.updatedBy = principal.userId; - await manager.getRepository(InspectionVisit).save(visit); - const updated = await this.loadView(manager, id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, - entityType: 'inspection_visit', - entityId: id, - beforeData: { status: InspectionVisitStatus.IN_PROGRESS }, - afterData: { - status: InspectionVisitStatus.CLOSED, - clientClosedAt: clientClosedAt.toISOString(), - serverClosedAt: serverClosedAt.toISOString(), - pendingCompanySignatures: Number(acts?.pendingCompany ?? 0), - }, - metadata: { - closeSource: 'ANDROID', - actsMayCompleteCompanySignatureLater: true, - }, - }, manager); - return updated; - }); - } - private visitSelect(where: string): string { return ` SELECT visit.id, visit.code, - visit.title, visit.objective, visit.status, CASE WHEN scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( @@ -943,7 +737,6 @@ export class InspectionVisitsService { 'lastName', lead.last_name ) END AS "leadInspector", visit.planned_start_at AS "plannedStartAt", - visit.planned_end_at AS "plannedEndAt", visit.actual_started_at AS "actualStartedAt", visit.actual_closed_at AS "actualClosedAt", visit.instructions, @@ -1615,7 +1408,6 @@ export class InspectionVisitsService { private auditView(visit: InspectionVisitView): Record { return { code: visit.code, - title: visit.title, objective: visit.objective, status: visit.status, scopeAssetId: visit.scopeAsset?.id ?? null, @@ -1623,7 +1415,6 @@ export class InspectionVisitsService { operatorCompanyId: visit.operatorCompany?.id ?? null, leadInspectorUserId: visit.leadInspector?.id ?? null, plannedStartAt: visit.plannedStartAt, - plannedEndAt: visit.plannedEndAt, actualStartedAt: visit.actualStartedAt, actualClosedAt: visit.actualClosedAt, instructions: visit.instructions, diff --git a/api-v3/src/survey-execution/dto/review-survey-report.dto.ts b/api-v3/src/survey-execution/dto/review-survey-report.dto.ts deleted file mode 100644 index cf878fb..0000000 --- a/api-v3/src/survey-execution/dto/review-survey-report.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; - -export enum SurveyReviewDecision { - APPROVE = 'APPROVE', - REJECT = 'REJECT', -} - -export class ReviewSurveyReportDto { - @IsEnum(SurveyReviewDecision) - decision!: SurveyReviewDecision; - - @IsOptional() - @IsString() - @MaxLength(4000) - notes?: string | null; -} diff --git a/api-v3/src/survey-execution/dto/save-survey-report.dto.ts b/api-v3/src/survey-execution/dto/save-survey-report.dto.ts deleted file mode 100644 index 74f1cb0..0000000 --- a/api-v3/src/survey-execution/dto/save-survey-report.dto.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Type } from 'class-transformer'; -import { - ArrayMaxSize, - ArrayUnique, - IsArray, - IsEnum, - IsISO8601, - IsNumber, - IsOptional, - IsString, - IsUUID, - Max, - MaxLength, - Min, -} from 'class-validator'; -import { SurveyReportOutcome } from '../../database/entities'; - -export class SaveSurveyReportDto { - @IsOptional() - @IsEnum(SurveyReportOutcome) - outcome?: SurveyReportOutcome | null; - - @IsOptional() - @IsISO8601({ strict: true }) - observedAt?: string | null; - - @IsOptional() - @Type(() => Number) - @IsNumber({ maxDecimalPlaces: 6 }) - @Min(-90) - @Max(90) - latitude?: number | null; - - @IsOptional() - @Type(() => Number) - @IsNumber({ maxDecimalPlaces: 6 }) - @Min(-180) - @Max(180) - longitude?: number | null; - - @IsOptional() - @Type(() => Number) - @IsNumber({ maxDecimalPlaces: 3 }) - @Min(0) - @Max(100000) - accuracyM?: number | null; - - @IsOptional() - @IsString() - @MaxLength(8000) - notes?: string | null; - - @IsArray() - @ArrayMaxSize(50) - @ArrayUnique() - @IsUUID('4', { each: true }) - mediaIds!: string[]; -} diff --git a/api-v3/src/survey-execution/survey-execution.controller.ts b/api-v3/src/survey-execution/survey-execution.controller.ts deleted file mode 100644 index 8d8790c..0000000 --- a/api-v3/src/survey-execution/survey-execution.controller.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - Body, - Controller, - Get, - Param, - ParseUUIDPipe, - Post, - Put, - Req, -} from '@nestjs/common'; -import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; -import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; -import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { ReviewSurveyReportDto } from './dto/review-survey-report.dto'; -import { SaveSurveyReportDto } from './dto/save-survey-report.dto'; -import { SurveyExecutionService } from './survey-execution.service'; - -@Controller('survey-campaign-targets/:targetId/report') -export class SurveyExecutionController { - constructor(private readonly execution: SurveyExecutionService) {} - - @Get() - @RequirePermissions('surveys.read_reports') - get( - @Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string, - ) { - return this.execution.get(targetId); - } - - @Put() - @RequirePermissions('surveys.capture') - save( - @Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string, - @Body() dto: SaveSurveyReportDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.execution.save(targetId, dto, principal, request); - } - - @Post('submit') - @RequirePermissions('surveys.capture') - submit( - @Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.execution.submit(targetId, principal, request); - } - - @Post('review') - @RequirePermissions('surveys.review') - review( - @Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string, - @Body() dto: ReviewSurveyReportDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.execution.review(targetId, dto, principal, request); - } -} diff --git a/api-v3/src/survey-execution/survey-execution.module.ts b/api-v3/src/survey-execution/survey-execution.module.ts deleted file mode 100644 index 6e0bf58..0000000 --- a/api-v3/src/survey-execution/survey-execution.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AssetMasterModule } from '../asset-master/asset-master.module'; -import { AuditModule } from '../audit/audit.module'; -import { SurveyExecutionController } from './survey-execution.controller'; -import { SurveyExecutionService } from './survey-execution.service'; - -@Module({ - imports: [AuditModule, AssetMasterModule], - controllers: [SurveyExecutionController], - providers: [SurveyExecutionService], -}) -export class SurveyExecutionModule {} diff --git a/api-v3/src/survey-execution/survey-execution.service.ts b/api-v3/src/survey-execution/survey-execution.service.ts deleted file mode 100644 index a0d16a8..0000000 --- a/api-v3/src/survey-execution/survey-execution.service.ts +++ /dev/null @@ -1,924 +0,0 @@ -import { - BadRequestException, - ConflictException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { DataSource, EntityManager } from 'typeorm'; -import { administrationAuditContext } from '../administration/common/administration-audit'; -import { AssetHistoryService } from '../asset-master/asset-history.service'; -import { AuditService } from '../audit/audit.service'; -import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { - Asset, - AssetDataOrigin, - AssetInformationStatus, - AssetVersionChangeType, - AuditAction, - SurveyCampaign, - SurveyCampaignStatus, - SurveyCampaignTarget, - SurveyReportOutcome, - SurveyReportStatus, - SurveyReportVersionEvent, - SurveyTargetReport, - SurveyTargetStatus, -} from '../database/entities'; -import { - SurveyReviewDecision, - type ReviewSurveyReportDto, -} from './dto/review-survey-report.dto'; -import type { SaveSurveyReportDto } from './dto/save-survey-report.dto'; - -interface ExecutionPerson { - id: string; - username: string; - firstName: string; - lastName: string; -} - -interface ExecutionContextView { - target: { - id: string; - status: SurveyTargetStatus; - dueAt: Date | null; - instructions: string | null; - assignedUser: ExecutionPerson | null; - }; - campaign: { - id: string; - code: string; - name: string; - status: SurveyCampaignStatus; - }; - asset: { - id: string; - code: string; - name: string; - typeName: string; - informationStatus: AssetInformationStatus; - currentVersion: number; - }; -} - -interface SurveyReportView { - id: string; - targetId: string; - outcome: SurveyReportOutcome | null; - status: SurveyReportStatus; - observedAt: Date | null; - latitude: number | null; - longitude: number | null; - accuracyM: number | null; - notes: string | null; - assetVersionAtSubmission: number | null; - submittedAt: Date | null; - submittedBy: ExecutionPerson | null; - reviewedAt: Date | null; - reviewedBy: ExecutionPerson | null; - reviewNotes: string | null; - selectedMediaIds: string[]; - createdAt: Date; - updatedAt: Date; -} - -interface ReportMediaView { - id: string; - kind: 'PHOTO'; - originalName: string; - mimeType: string; - sizeBytes: number; - sha256: string; - title: string | null; - description: string | null; - capturedAt: Date | null; - latitude: number | null; - longitude: number | null; - accuracyM: number | null; - source: string; - createdAt: Date; - selected: boolean; -} - -interface ReportVersionView { - id: string; - versionNumber: number; - event: SurveyReportVersionEvent; - snapshot: Record; - actorUserId: string | null; - actorUsername: string | null; - createdAt: Date; -} - -export interface SurveyExecutionView extends ExecutionContextView { - report: SurveyReportView | null; - availableMedia: ReportMediaView[]; - versions: ReportVersionView[]; -} - -function targetNotFound(): NotFoundException { - return new NotFoundException({ - code: 'SURVEY_TARGET_NOT_FOUND', - message: 'Objetivo de relevamiento no encontrado', - }); -} - -function reportNotFound(): NotFoundException { - return new NotFoundException({ - code: 'SURVEY_REPORT_NOT_FOUND', - message: 'El objetivo todavía no tiene un informe de campo', - }); -} - -@Injectable() -export class SurveyExecutionService { - constructor( - private readonly dataSource: DataSource, - private readonly audit: AuditService, - private readonly history: AssetHistoryService, - ) {} - - async get(targetId: string): Promise { - return this.dataSource.transaction((manager) => this.loadView(manager, targetId)); - } - - async save( - targetId: string, - dto: SaveSurveyReportDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - return this.dataSource.transaction(async (manager) => { - const { target, campaign } = await this.lockTargetContext(manager, targetId); - this.assertCaptureAllowed(target, campaign, principal); - const repository = manager.getRepository(SurveyTargetReport); - let report = await this.lockReport(manager, targetId, false); - const before = report ? this.reportAuditView(report) : null; - if (!report) { - report = repository.create({ - targetId, - outcome: null, - status: SurveyReportStatus.DRAFT, - observedAt: null, - latitude: null, - longitude: null, - accuracyM: null, - notes: null, - assetVersionAtSubmission: null, - submittedAt: null, - submittedBy: null, - reviewedAt: null, - reviewedBy: null, - reviewNotes: null, - createdBy: principal.userId, - updatedBy: principal.userId, - }); - } else if ( - report.status === SurveyReportStatus.SUBMITTED || - report.status === SurveyReportStatus.APPROVED - ) { - throw new ConflictException({ - code: 'SURVEY_REPORT_LOCKED', - message: 'El informe enviado o aprobado ya no puede editarse', - }); - } - - const nextLatitude = dto.latitude === undefined - ? this.numberOrNull(report.latitude) - : dto.latitude; - const nextLongitude = dto.longitude === undefined - ? this.numberOrNull(report.longitude) - : dto.longitude; - const nextAccuracy = dto.accuracyM === undefined - ? this.numberOrNull(report.accuracyM) - : dto.accuracyM; - this.assertCoordinatePair(nextLatitude, nextLongitude, nextAccuracy); - await this.validateMedia(manager, target.assetId, dto.mediaIds); - - if (dto.outcome !== undefined) report.outcome = dto.outcome; - if (dto.observedAt !== undefined) { - report.observedAt = dto.observedAt ? new Date(dto.observedAt) : null; - } - if (dto.latitude !== undefined) { - report.latitude = dto.latitude == null ? null : String(dto.latitude); - } - if (dto.longitude !== undefined) { - report.longitude = dto.longitude == null ? null : String(dto.longitude); - } - if (dto.accuracyM !== undefined) { - report.accuracyM = dto.accuracyM == null ? null : String(dto.accuracyM); - } - if (dto.notes !== undefined) report.notes = dto.notes?.trim() || null; - if (report.status === SurveyReportStatus.REJECTED) { - report.status = SurveyReportStatus.DRAFT; - report.assetVersionAtSubmission = null; - report.submittedAt = null; - report.submittedBy = null; - report.reviewedAt = null; - report.reviewedBy = null; - report.reviewNotes = null; - } - report.updatedBy = principal.userId; - await repository.save(report); - await this.replaceMediaSelection(manager, report.id, dto.mediaIds, principal.userId); - - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_REPORT_SAVED, - entityType: 'survey_target_report', - entityId: report.id, - beforeData: before, - afterData: this.reportAuditView(report), - metadata: { targetId, mediaCount: dto.mediaIds.length }, - }, manager); - return this.loadView(manager, targetId); - }); - } - - async submit( - targetId: string, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - return this.dataSource.transaction(async (manager) => { - const { target, campaign } = await this.lockTargetContext(manager, targetId); - this.assertCaptureAllowed(target, campaign, principal); - const report = await this.lockReport(manager, targetId, true); - if (!report) throw reportNotFound(); - if (report.status !== SurveyReportStatus.DRAFT) { - throw new ConflictException({ - code: 'SURVEY_REPORT_NOT_DRAFT', - message: 'Sólo se puede enviar un informe guardado como borrador', - }); - } - await this.assertComplete(manager, report); - - const asset = await this.lockAsset(manager, target.assetId); - const beforeAsset = this.assetAuditView(asset); - asset.informationStatus = report.outcome === SurveyReportOutcome.NOT_LOCATED - ? AssetInformationStatus.OBSERVED - : AssetInformationStatus.SURVEYED; - asset.dataOrigin = AssetDataOrigin.FIELD_SURVEY; - asset.sourceName = `Relevamiento ${campaign.code}`; - asset.sourceReference = `survey-report:${report.id}`; - asset.sourceObservedAt = report.observedAt; - asset.sourceNotes = report.notes; - asset.provenanceVerifiedAt = null; - asset.provenanceVerifiedBy = null; - asset.provenanceUpdatedAt = new Date(); - asset.provenanceUpdatedBy = principal.userId; - asset.updatedBy = principal.userId; - await manager.getRepository(Asset).save(asset); - const assetVersion = await this.history.capture( - manager, - asset.id, - AssetVersionChangeType.PROVENANCE_UPDATED, - principal, - request, - ); - asset.currentVersion = assetVersion; - - report.status = SurveyReportStatus.SUBMITTED; - report.assetVersionAtSubmission = assetVersion; - report.submittedAt = new Date(); - report.submittedBy = principal.userId; - report.reviewedAt = null; - report.reviewedBy = null; - report.reviewNotes = null; - report.updatedBy = principal.userId; - await manager.getRepository(SurveyTargetReport).save(report); - - target.status = SurveyTargetStatus.SUBMITTED; - target.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaignTarget).save(target); - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - - const reportVersion = await this.captureReportVersion( - manager, - report, - SurveyReportVersionEvent.SUBMITTED, - principal, - ); - await this.recordAssetChange( - manager, - asset, - beforeAsset, - AuditAction.ASSET_PROVENANCE_UPDATED, - principal, - request, - targetId, - assetVersion, - ); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_REPORT_SUBMITTED, - entityType: 'survey_target_report', - entityId: report.id, - afterData: this.reportAuditView(report), - metadata: { targetId, assetId: asset.id, assetVersion, reportVersion }, - }, manager); - return this.loadView(manager, targetId); - }); - } - - async review( - targetId: string, - dto: ReviewSurveyReportDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - return this.dataSource.transaction(async (manager) => { - const { target, campaign } = await this.lockTargetContext(manager, targetId); - if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS', - message: 'La campaña debe estar en curso para revisar informes', - }); - } - const report = await this.lockReport(manager, targetId, true); - if (!report) throw reportNotFound(); - if (report.status !== SurveyReportStatus.SUBMITTED || target.status !== SurveyTargetStatus.SUBMITTED) { - throw new ConflictException({ - code: 'SURVEY_REPORT_NOT_SUBMITTED', - message: 'El informe no está pendiente de revisión', - }); - } - const notes = dto.notes?.trim() || null; - if (dto.decision === SurveyReviewDecision.REJECT && (!notes || notes.length < 10)) { - throw new BadRequestException({ - code: 'SURVEY_REJECTION_REASON_REQUIRED', - message: 'Indicá un motivo de rechazo de al menos 10 caracteres', - }); - } - - const asset = await this.lockAsset(manager, target.assetId); - if ( - dto.decision === SurveyReviewDecision.APPROVE && - asset.currentVersion !== report.assetVersionAtSubmission - ) { - throw new ConflictException({ - code: 'SURVEY_ASSET_CHANGED_AFTER_SUBMISSION', - message: 'El activo cambió después del envío; rechazá el informe para que sea revisado nuevamente', - }); - } - const beforeAsset = this.assetAuditView(asset); - let assetVersion = asset.currentVersion; - - if (dto.decision === SurveyReviewDecision.APPROVE) { - if (report.outcome !== SurveyReportOutcome.NOT_LOCATED) { - asset.informationStatus = AssetInformationStatus.VALIDATED; - asset.provenanceVerifiedAt = new Date(); - asset.provenanceVerifiedBy = principal.userId; - asset.provenanceUpdatedAt = new Date(); - asset.provenanceUpdatedBy = principal.userId; - asset.updatedBy = principal.userId; - await manager.getRepository(Asset).save(asset); - assetVersion = await this.history.capture( - manager, - asset.id, - AssetVersionChangeType.PROVENANCE_VERIFIED, - principal, - request, - ); - asset.currentVersion = assetVersion; - await this.recordAssetChange( - manager, - asset, - beforeAsset, - AuditAction.ASSET_PROVENANCE_VERIFIED, - principal, - request, - targetId, - assetVersion, - ); - } - report.status = SurveyReportStatus.APPROVED; - target.status = SurveyTargetStatus.COMPLETED; - } else { - const assetStillMatchesSubmission = - asset.currentVersion === report.assetVersionAtSubmission; - const assetNeedsUpdate = assetStillMatchesSubmission && ( - asset.informationStatus !== AssetInformationStatus.OBSERVED || - asset.provenanceVerifiedAt !== null || - asset.provenanceVerifiedBy !== null - ); - if (assetNeedsUpdate) { - asset.informationStatus = AssetInformationStatus.OBSERVED; - asset.provenanceVerifiedAt = null; - asset.provenanceVerifiedBy = null; - asset.provenanceUpdatedAt = new Date(); - asset.provenanceUpdatedBy = principal.userId; - asset.updatedBy = principal.userId; - await manager.getRepository(Asset).save(asset); - assetVersion = await this.history.capture( - manager, - asset.id, - AssetVersionChangeType.STATUS_CHANGED, - principal, - request, - ); - asset.currentVersion = assetVersion; - await this.recordAssetChange( - manager, - asset, - beforeAsset, - AuditAction.ASSET_INFORMATION_STATUS_CHANGED, - principal, - request, - targetId, - assetVersion, - ); - } - report.status = SurveyReportStatus.REJECTED; - target.status = SurveyTargetStatus.IN_PROGRESS; - } - - report.reviewedAt = new Date(); - report.reviewedBy = principal.userId; - report.reviewNotes = notes; - report.updatedBy = principal.userId; - await manager.getRepository(SurveyTargetReport).save(report); - target.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaignTarget).save(target); - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - - const event = dto.decision === SurveyReviewDecision.APPROVE - ? SurveyReportVersionEvent.APPROVED - : SurveyReportVersionEvent.REJECTED; - const reportVersion = await this.captureReportVersion(manager, report, event, principal); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: dto.decision === SurveyReviewDecision.APPROVE - ? AuditAction.SURVEY_REPORT_APPROVED - : AuditAction.SURVEY_REPORT_REJECTED, - entityType: 'survey_target_report', - entityId: report.id, - afterData: this.reportAuditView(report), - metadata: { targetId, assetId: asset.id, assetVersion, reportVersion }, - }, manager); - return this.loadView(manager, targetId); - }); - } - - private async loadView(manager: EntityManager, targetId: string): Promise { - const [context] = (await manager.query(` - SELECT - JSONB_BUILD_OBJECT( - 'id', target.id, - 'status', target.status, - 'dueAt', target.due_at, - 'instructions', target.instructions, - 'assignedUser', CASE WHEN assignee.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', assignee.id, - 'username', assignee.username, - 'firstName', assignee.first_name, - 'lastName', assignee.last_name - ) END - ) AS target, - JSONB_BUILD_OBJECT( - 'id', campaign.id, - 'code', campaign.code, - 'name', campaign.name, - 'status', campaign.status - ) AS campaign, - JSONB_BUILD_OBJECT( - 'id', asset.id, - 'code', asset.code, - 'name', asset.name, - 'typeName', asset_type.name, - 'informationStatus', asset.information_status, - 'currentVersion', asset.current_version - ) AS asset - FROM survey_campaign_targets target - INNER JOIN survey_campaigns campaign ON campaign.id = target.campaign_id - INNER JOIN assets asset ON asset.id = target.asset_id - INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id - LEFT JOIN users assignee ON assignee.id = target.assigned_user_id - WHERE target.id = $1 - `, [targetId])) as ExecutionContextView[]; - if (!context) throw targetNotFound(); - - const [report] = (await manager.query(` - SELECT - report.id, - report.target_id AS "targetId", - report.outcome, - report.status, - report.observed_at AS "observedAt", - report.latitude::double precision AS latitude, - report.longitude::double precision AS longitude, - report.accuracy_m::double precision AS "accuracyM", - report.notes, - report.asset_version_at_submission AS "assetVersionAtSubmission", - report.submitted_at AS "submittedAt", - CASE WHEN submitter.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', submitter.id, - 'username', submitter.username, - 'firstName', submitter.first_name, - 'lastName', submitter.last_name - ) END AS "submittedBy", - report.reviewed_at AS "reviewedAt", - CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', reviewer.id, - 'username', reviewer.username, - 'firstName', reviewer.first_name, - 'lastName', reviewer.last_name - ) END AS "reviewedBy", - report.review_notes AS "reviewNotes", - COALESCE(( - SELECT ARRAY_AGG(link.media_id ORDER BY link.created_at) - FROM survey_target_report_media link - INNER JOIN asset_media media ON media.id = link.media_id - WHERE link.report_id = report.id - AND link.included = true - AND media.deleted_at IS NULL - ), ARRAY[]::uuid[]) AS "selectedMediaIds", - report.created_at AS "createdAt", - report.updated_at AS "updatedAt" - FROM survey_target_reports report - LEFT JOIN users submitter ON submitter.id = report.submitted_by - LEFT JOIN users reviewer ON reviewer.id = report.reviewed_by - WHERE report.target_id = $1 - `, [targetId])) as SurveyReportView[]; - - const availableMedia = (await manager.query(` - SELECT - media.id, - media.kind, - media.original_name AS "originalName", - media.mime_type AS "mimeType", - media.size_bytes::double precision AS "sizeBytes", - media.sha256, - media.title, - media.description, - media.captured_at AS "capturedAt", - media.latitude::double precision AS latitude, - media.longitude::double precision AS longitude, - media.accuracy_m::double precision AS "accuracyM", - media.source, - media.created_at AS "createdAt", - EXISTS ( - SELECT 1 FROM survey_target_report_media link - WHERE link.report_id = $2 AND link.media_id = media.id AND link.included = true - ) AS selected - FROM asset_media media - WHERE media.asset_id = $1 - AND media.kind = 'PHOTO' - AND media.deleted_at IS NULL - ORDER BY media.created_at DESC - `, [context.asset.id, report?.id ?? null])) as ReportMediaView[]; - - const versions = report - ? (await manager.query(` - SELECT - version.id, - version.version_number AS "versionNumber", - version.event, - version.snapshot, - version.actor_user_id AS "actorUserId", - version.actor_username AS "actorUsername", - version.created_at AS "createdAt" - FROM survey_target_report_versions version - WHERE version.report_id = $1 - ORDER BY version.version_number DESC - `, [report.id])) as ReportVersionView[] - : []; - return { ...context, report: report ?? null, availableMedia, versions }; - } - - private async lockTargetContext( - manager: EntityManager, - targetId: string, - ): Promise<{ target: SurveyCampaignTarget; campaign: SurveyCampaign }> { - const target = await manager.getRepository(SurveyCampaignTarget) - .createQueryBuilder('target') - .where('target.id = :targetId', { targetId }) - .setLock('pessimistic_write') - .getOne(); - if (!target) throw targetNotFound(); - const campaign = await manager.getRepository(SurveyCampaign) - .createQueryBuilder('campaign') - .where('campaign.id = :campaignId', { campaignId: target.campaignId }) - .setLock('pessimistic_write') - .getOne(); - if (!campaign) throw targetNotFound(); - return { target, campaign }; - } - - private async lockReport( - manager: EntityManager, - targetId: string, - required: boolean, - ): Promise { - const report = await manager.getRepository(SurveyTargetReport) - .createQueryBuilder('report') - .where('report.targetId = :targetId', { targetId }) - .setLock('pessimistic_write') - .getOne(); - if (!report && required) throw reportNotFound(); - return report; - } - - private async lockAsset(manager: EntityManager, assetId: string): Promise { - const asset = await manager.getRepository(Asset) - .createQueryBuilder('asset') - .where('asset.id = :assetId', { assetId }) - .setLock('pessimistic_write') - .getOne(); - if (!asset) throw targetNotFound(); - return asset; - } - - private assertCaptureAllowed( - target: SurveyCampaignTarget, - campaign: SurveyCampaign, - principal: AuthPrincipal, - ): void { - if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS', - message: 'La campaña debe estar en curso para capturar el relevamiento', - }); - } - if (target.status !== SurveyTargetStatus.IN_PROGRESS) { - throw new ConflictException({ - code: 'SURVEY_TARGET_NOT_IN_PROGRESS', - message: 'El objetivo debe estar en ejecución para editar o enviar el informe', - }); - } - if ( - !principal.permissions.includes('surveys.manage') && - target.assignedUserId !== principal.userId - ) { - throw new ForbiddenException({ - code: 'SURVEY_TARGET_NOT_ASSIGNED', - message: 'El objetivo no está asignado al usuario actual', - }); - } - } - - private assertCoordinatePair( - latitude: number | null | undefined, - longitude: number | null | undefined, - accuracyM: number | null | undefined, - ): void { - if ((latitude == null) !== (longitude == null)) { - throw new BadRequestException({ - code: 'SURVEY_COORDINATES_INCOMPLETE', - message: 'Latitud y longitud deben informarse juntas', - }); - } - if (accuracyM != null && latitude == null) { - throw new BadRequestException({ - code: 'SURVEY_ACCURACY_WITHOUT_LOCATION', - message: 'La precisión requiere coordenadas GPS', - }); - } - } - - private async validateMedia( - manager: EntityManager, - assetId: string, - mediaIds: string[], - ): Promise { - if (mediaIds.length === 0) return; - const [row] = (await manager.query(` - SELECT COUNT(*)::integer AS count - FROM asset_media - WHERE asset_id = $1 - AND id = ANY($2::uuid[]) - AND kind = 'PHOTO' - AND deleted_at IS NULL - `, [assetId, mediaIds])) as Array<{ count: number }>; - if (Number(row?.count ?? 0) !== mediaIds.length) { - throw new BadRequestException({ - code: 'SURVEY_MEDIA_INVALID', - message: 'Una o más evidencias no son fotografías activas del activo relevado', - }); - } - } - - private async replaceMediaSelection( - manager: EntityManager, - reportId: string, - mediaIds: string[], - userId: string, - ): Promise { - await manager.query(` - UPDATE survey_target_report_media - SET included = false, updated_at = CURRENT_TIMESTAMP - WHERE report_id = $1 AND included = true - `, [reportId]); - for (const mediaId of mediaIds) { - await manager.query(` - INSERT INTO survey_target_report_media ( - report_id, media_id, included, added_by - ) VALUES ($1, $2, true, $3) - ON CONFLICT (report_id, media_id) DO UPDATE SET - included = true, - added_by = EXCLUDED.added_by, - updated_at = CURRENT_TIMESTAMP - `, [reportId, mediaId, userId]); - } - } - - private async assertComplete( - manager: EntityManager, - report: SurveyTargetReport, - ): Promise { - if (!report.outcome) { - throw new BadRequestException({ - code: 'SURVEY_OUTCOME_REQUIRED', - message: 'Seleccioná el resultado de la verificación', - }); - } - if (!report.observedAt) { - throw new BadRequestException({ - code: 'SURVEY_OBSERVED_AT_REQUIRED', - message: 'Indicá la fecha y hora de observación', - }); - } - if (report.latitude == null || report.longitude == null || report.accuracyM == null) { - throw new BadRequestException({ - code: 'SURVEY_GPS_REQUIRED', - message: 'Capturá ubicación y precisión GPS antes de enviar', - }); - } - if ( - report.outcome !== SurveyReportOutcome.CONFIRMED && - (!report.notes || report.notes.trim().length < 10) - ) { - throw new BadRequestException({ - code: 'SURVEY_NOTES_REQUIRED', - message: 'Describí los cambios o la imposibilidad de localizar el activo', - }); - } - const [row] = (await manager.query(` - SELECT COUNT(*)::integer AS count - FROM survey_target_report_media link - INNER JOIN asset_media media ON media.id = link.media_id - WHERE link.report_id = $1 - AND link.included = true - AND media.kind = 'PHOTO' - AND media.deleted_at IS NULL - `, [report.id])) as Array<{ count: number }>; - if (Number(row?.count ?? 0) < 1) { - throw new BadRequestException({ - code: 'SURVEY_PHOTO_REQUIRED', - message: 'Seleccioná al menos una fotografía como evidencia', - }); - } - } - - private async captureReportVersion( - manager: EntityManager, - report: SurveyTargetReport, - event: SurveyReportVersionEvent, - principal: AuthPrincipal, - ): Promise { - const [row] = (await manager.query(` - SELECT COALESCE(MAX(version_number), 0)::integer + 1 AS "versionNumber" - FROM survey_target_report_versions - WHERE report_id = $1 - `, [report.id])) as Array<{ versionNumber: number }>; - const versionNumber = Number(row?.versionNumber ?? 1); - const snapshot = await this.buildReportSnapshot(manager, report.id); - await manager.query(` - INSERT INTO survey_target_report_versions ( - report_id, version_number, event, snapshot, actor_user_id, actor_username - ) VALUES ($1, $2, $3, $4, $5, $6) - `, [report.id, versionNumber, event, snapshot, principal.userId, principal.username]); - return versionNumber; - } - - private async buildReportSnapshot( - manager: EntityManager, - reportId: string, - ): Promise> { - const [row] = (await manager.query(` - SELECT JSONB_BUILD_OBJECT( - 'id', report.id, - 'status', report.status, - 'outcome', report.outcome, - 'observedAt', report.observed_at, - 'location', JSONB_BUILD_OBJECT( - 'latitude', report.latitude, - 'longitude', report.longitude, - 'accuracyM', report.accuracy_m - ), - 'notes', report.notes, - 'assetVersionAtSubmission', report.asset_version_at_submission, - 'submittedAt', report.submitted_at, - 'submittedBy', report.submitted_by, - 'reviewedAt', report.reviewed_at, - 'reviewedBy', report.reviewed_by, - 'reviewNotes', report.review_notes, - 'target', JSONB_BUILD_OBJECT( - 'id', target.id, - 'status', target.status, - 'dueAt', target.due_at, - 'instructions', target.instructions - ), - 'campaign', JSONB_BUILD_OBJECT( - 'id', campaign.id, - 'code', campaign.code, - 'name', campaign.name - ), - 'asset', JSONB_BUILD_OBJECT( - 'id', asset.id, - 'code', asset.code, - 'name', asset.name, - 'informationStatus', asset.information_status, - 'currentVersion', asset.current_version - ), - 'evidence', COALESCE(( - SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'id', media.id, - 'originalName', media.original_name, - 'mimeType', media.mime_type, - 'sizeBytes', media.size_bytes, - 'sha256', media.sha256, - 'title', media.title, - 'description', media.description, - 'capturedAt', media.captured_at, - 'latitude', media.latitude, - 'longitude', media.longitude, - 'accuracyM', media.accuracy_m, - 'source', media.source, - 'createdAt', media.created_at - ) ORDER BY media.created_at, media.id) - FROM survey_target_report_media link - INNER JOIN asset_media media ON media.id = link.media_id - WHERE link.report_id = report.id AND link.included = true - ), '[]'::jsonb) - ) AS snapshot - FROM survey_target_reports report - INNER JOIN survey_campaign_targets target ON target.id = report.target_id - INNER JOIN survey_campaigns campaign ON campaign.id = target.campaign_id - INNER JOIN assets asset ON asset.id = target.asset_id - WHERE report.id = $1 - `, [reportId])) as Array<{ snapshot: Record }>; - if (!row) throw reportNotFound(); - return row.snapshot; - } - - private async recordAssetChange( - manager: EntityManager, - asset: Asset, - beforeData: Record, - action: AuditAction, - principal: AuthPrincipal, - request: RequestWithContext, - targetId: string, - versionNumber: number, - ): Promise { - await this.audit.record({ - ...administrationAuditContext(principal, request), - action, - entityType: 'asset', - entityId: asset.id, - beforeData, - afterData: this.assetAuditView(asset), - metadata: { targetId, versionNumber, source: 'survey_report' }, - }, manager); - } - - private assetAuditView(asset: Asset): Record { - return { - informationStatus: asset.informationStatus, - dataOrigin: asset.dataOrigin, - sourceName: asset.sourceName, - sourceReference: asset.sourceReference, - sourceObservedAt: asset.sourceObservedAt, - provenanceVerifiedAt: asset.provenanceVerifiedAt, - provenanceVerifiedBy: asset.provenanceVerifiedBy, - currentVersion: asset.currentVersion, - }; - } - - private reportAuditView(report: SurveyTargetReport): Record { - return { - targetId: report.targetId, - outcome: report.outcome, - status: report.status, - observedAt: report.observedAt, - latitude: this.numberOrNull(report.latitude), - longitude: this.numberOrNull(report.longitude), - accuracyM: this.numberOrNull(report.accuracyM), - notes: report.notes, - assetVersionAtSubmission: report.assetVersionAtSubmission, - submittedAt: report.submittedAt, - submittedBy: report.submittedBy, - reviewedAt: report.reviewedAt, - reviewedBy: report.reviewedBy, - reviewNotes: report.reviewNotes, - }; - } - - private numberOrNull(value: string | number | null): number | null { - return value == null ? null : Number(value); - } -} diff --git a/api-v3/src/survey-planning/dto/add-survey-target.dto.ts b/api-v3/src/survey-planning/dto/add-survey-target.dto.ts deleted file mode 100644 index 1842129..0000000 --- a/api-v3/src/survey-planning/dto/add-survey-target.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Transform } from 'class-transformer'; -import { IsISO8601, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; - -export class AddSurveyTargetDto { - @IsUUID('4') - assetId!: string; - - @IsOptional() - @IsUUID('4') - assignedUserId?: string | null; - - @IsOptional() - @IsISO8601({ strict: true }) - dueAt?: string | null; - - @IsOptional() - @Transform(({ value }) => - typeof value === 'string' && value.trim() ? value.trim() : null, - ) - @IsString() - @MaxLength(4000) - instructions?: string | null; -} diff --git a/api-v3/src/survey-planning/dto/assign-survey-target.dto.ts b/api-v3/src/survey-planning/dto/assign-survey-target.dto.ts deleted file mode 100644 index 84bf835..0000000 --- a/api-v3/src/survey-planning/dto/assign-survey-target.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsOptional, IsUUID } from 'class-validator'; - -export class AssignSurveyTargetDto { - @IsOptional() - @IsUUID('4') - assignedUserId!: string | null; -} diff --git a/api-v3/src/survey-planning/dto/change-survey-campaign-status.dto.ts b/api-v3/src/survey-planning/dto/change-survey-campaign-status.dto.ts deleted file mode 100644 index 253e16e..0000000 --- a/api-v3/src/survey-planning/dto/change-survey-campaign-status.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { SurveyCampaignStatus } from '../../database/entities'; - -export class ChangeSurveyCampaignStatusDto { - @IsEnum(SurveyCampaignStatus) - status!: SurveyCampaignStatus; -} diff --git a/api-v3/src/survey-planning/dto/change-survey-target-status.dto.ts b/api-v3/src/survey-planning/dto/change-survey-target-status.dto.ts deleted file mode 100644 index 760b817..0000000 --- a/api-v3/src/survey-planning/dto/change-survey-target-status.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { SurveyTargetStatus } from '../../database/entities'; - -export class ChangeSurveyTargetStatusDto { - @IsEnum(SurveyTargetStatus) - status!: SurveyTargetStatus; -} diff --git a/api-v3/src/survey-planning/dto/create-survey-campaign.dto.ts b/api-v3/src/survey-planning/dto/create-survey-campaign.dto.ts deleted file mode 100644 index dcea6a9..0000000 --- a/api-v3/src/survey-planning/dto/create-survey-campaign.dto.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Transform } from 'class-transformer'; -import { - IsISO8601, - IsOptional, - IsString, - IsUUID, - Matches, - MaxLength, - MinLength, -} from 'class-validator'; - -export class CreateSurveyCampaignDto { - @Transform(({ value }) => - typeof value === 'string' ? value.trim().toUpperCase() : value, - ) - @IsString() - @MinLength(1) - @MaxLength(80) - @Matches(/^[A-Z0-9][A-Z0-9._/-]*$/) - code!: string; - - @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) - @IsString() - @MinLength(1) - @MaxLength(200) - name!: string; - - @IsOptional() - @Transform(({ value }) => - typeof value === 'string' && value.trim() ? value.trim() : null, - ) - @IsString() - @MaxLength(4000) - description?: string | null; - - @IsOptional() - @IsISO8601({ strict: true }) - plannedStartAt?: string | null; - - @IsOptional() - @IsISO8601({ strict: true }) - plannedEndAt?: string | null; - - @IsOptional() - @IsUUID('4') - scopeAssetId?: string | null; - - @IsOptional() - @IsUUID('4') - coordinatorUserId?: string | null; -} diff --git a/api-v3/src/survey-planning/dto/list-survey-campaigns-query.dto.ts b/api-v3/src/survey-planning/dto/list-survey-campaigns-query.dto.ts deleted file mode 100644 index fa37549..0000000 --- a/api-v3/src/survey-planning/dto/list-survey-campaigns-query.dto.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Type } from 'class-transformer'; -import { - IsEnum, - IsInt, - IsOptional, - IsString, - IsUUID, - Max, - MaxLength, - Min, -} from 'class-validator'; -import { SurveyCampaignStatus } from '../../database/entities'; - -export class ListSurveyCampaignsQueryDto { - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - page = 1; - - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - @Max(100) - pageSize = 25; - - @IsOptional() - @IsString() - @MaxLength(200) - search?: string; - - @IsOptional() - @IsEnum(SurveyCampaignStatus) - status?: SurveyCampaignStatus; - - @IsOptional() - @IsUUID('4') - coordinatorUserId?: string; -} diff --git a/api-v3/src/survey-planning/dto/update-survey-campaign.dto.ts b/api-v3/src/survey-planning/dto/update-survey-campaign.dto.ts deleted file mode 100644 index 5beed45..0000000 --- a/api-v3/src/survey-planning/dto/update-survey-campaign.dto.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Transform } from 'class-transformer'; -import { - IsISO8601, - IsOptional, - IsString, - IsUUID, - Matches, - MaxLength, - MinLength, -} from 'class-validator'; - -export class UpdateSurveyCampaignDto { - @IsOptional() - @Transform(({ value }) => - typeof value === 'string' ? value.trim().toUpperCase() : value, - ) - @IsString() - @MinLength(1) - @MaxLength(80) - @Matches(/^[A-Z0-9][A-Z0-9._/-]*$/) - code?: string; - - @IsOptional() - @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) - @IsString() - @MinLength(1) - @MaxLength(200) - name?: string; - - @IsOptional() - @Transform(({ value }) => - typeof value === 'string' && value.trim() ? value.trim() : null, - ) - @IsString() - @MaxLength(4000) - description?: string | null; - - @IsOptional() - @IsISO8601({ strict: true }) - plannedStartAt?: string | null; - - @IsOptional() - @IsISO8601({ strict: true }) - plannedEndAt?: string | null; - - @IsOptional() - @IsUUID('4') - scopeAssetId?: string | null; - - @IsOptional() - @IsUUID('4') - coordinatorUserId?: string | null; -} diff --git a/api-v3/src/survey-planning/dto/update-survey-target.dto.ts b/api-v3/src/survey-planning/dto/update-survey-target.dto.ts deleted file mode 100644 index b3c9acc..0000000 --- a/api-v3/src/survey-planning/dto/update-survey-target.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Transform } from 'class-transformer'; -import { IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator'; - -export class UpdateSurveyTargetDto { - @IsOptional() - @IsISO8601({ strict: true }) - dueAt?: string | null; - - @IsOptional() - @Transform(({ value }) => - typeof value === 'string' && value.trim() ? value.trim() : null, - ) - @IsString() - @MaxLength(4000) - instructions?: string | null; -} diff --git a/api-v3/src/survey-planning/survey-campaigns.controller.ts b/api-v3/src/survey-planning/survey-campaigns.controller.ts deleted file mode 100644 index a1a506a..0000000 --- a/api-v3/src/survey-planning/survey-campaigns.controller.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - Body, - Controller, - Get, - Param, - ParseUUIDPipe, - Patch, - Post, - Put, - Query, - Req, -} from '@nestjs/common'; -import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; -import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; -import type { - AuthPrincipal, - RequestWithContext, -} from '../common/http/request-context'; -import { AddSurveyTargetDto } from './dto/add-survey-target.dto'; -import { AssignSurveyTargetDto } from './dto/assign-survey-target.dto'; -import { ChangeSurveyCampaignStatusDto } from './dto/change-survey-campaign-status.dto'; -import { ChangeSurveyTargetStatusDto } from './dto/change-survey-target-status.dto'; -import { CreateSurveyCampaignDto } from './dto/create-survey-campaign.dto'; -import { ListSurveyCampaignsQueryDto } from './dto/list-survey-campaigns-query.dto'; -import { UpdateSurveyCampaignDto } from './dto/update-survey-campaign.dto'; -import { UpdateSurveyTargetDto } from './dto/update-survey-target.dto'; -import { SurveyPlanningService } from './survey-planning.service'; - -@Controller('survey-campaigns') -export class SurveyCampaignsController { - constructor(private readonly planning: SurveyPlanningService) {} - - @Get() - @RequirePermissions('surveys.read') - list(@Query() query: ListSurveyCampaignsQueryDto) { - return this.planning.list(query); - } - - @Get('assignees') - @RequirePermissions('surveys.assign') - assignees() { - return this.planning.listAssignees(); - } - - @Post() - @RequirePermissions('surveys.manage') - create( - @Body() dto: CreateSurveyCampaignDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.create(dto, principal, request); - } - - @Get(':id') - @RequirePermissions('surveys.read') - get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { - return this.planning.getById(id); - } - - @Patch(':id') - @RequirePermissions('surveys.manage') - update( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: UpdateSurveyCampaignDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.update(id, dto, principal, request); - } - - @Put(':id/status') - @RequirePermissions('surveys.manage') - changeStatus( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: ChangeSurveyCampaignStatusDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.changeCampaignStatus(id, dto, principal, request); - } - - @Post(':id/targets') - @RequirePermissions('surveys.manage') - addTarget( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: AddSurveyTargetDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.addTarget(id, dto, principal, request); - } -} - -@Controller('survey-campaign-targets') -export class SurveyCampaignTargetsController { - constructor(private readonly planning: SurveyPlanningService) {} - - @Patch(':id') - @RequirePermissions('surveys.manage') - update( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: UpdateSurveyTargetDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.updateTarget(id, dto, principal, request); - } - - @Put(':id/assignment') - @RequirePermissions('surveys.assign') - assign( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: AssignSurveyTargetDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.assignTarget(id, dto, principal, request); - } - - @Put(':id/status') - @RequirePermissions('surveys.execute') - changeStatus( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: ChangeSurveyTargetStatusDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.changeTargetStatus(id, dto, principal, request); - } -} diff --git a/api-v3/src/survey-planning/survey-planning.module.ts b/api-v3/src/survey-planning/survey-planning.module.ts deleted file mode 100644 index 42a8cf6..0000000 --- a/api-v3/src/survey-planning/survey-planning.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AuditModule } from '../audit/audit.module'; -import { - SurveyCampaignsController, - SurveyCampaignTargetsController, -} from './survey-campaigns.controller'; -import { SurveyPlanningService } from './survey-planning.service'; - -@Module({ - imports: [AuditModule], - controllers: [SurveyCampaignsController, SurveyCampaignTargetsController], - providers: [SurveyPlanningService], -}) -export class SurveyPlanningModule {} diff --git a/api-v3/src/survey-planning/survey-planning.service.ts b/api-v3/src/survey-planning/survey-planning.service.ts deleted file mode 100644 index 7a4a111..0000000 --- a/api-v3/src/survey-planning/survey-planning.service.ts +++ /dev/null @@ -1,791 +0,0 @@ -import { - BadRequestException, - ConflictException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { DataSource, EntityManager } from 'typeorm'; -import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit'; -import { AuditService } from '../audit/audit.service'; -import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { - AuditAction, - SurveyCampaign, - SurveyCampaignStatus, - SurveyCampaignTarget, - SurveyTargetStatus, -} from '../database/entities'; -import type { AddSurveyTargetDto } from './dto/add-survey-target.dto'; -import type { AssignSurveyTargetDto } from './dto/assign-survey-target.dto'; -import type { ChangeSurveyCampaignStatusDto } from './dto/change-survey-campaign-status.dto'; -import type { ChangeSurveyTargetStatusDto } from './dto/change-survey-target-status.dto'; -import type { CreateSurveyCampaignDto } from './dto/create-survey-campaign.dto'; -import type { ListSurveyCampaignsQueryDto } from './dto/list-survey-campaigns-query.dto'; -import type { UpdateSurveyCampaignDto } from './dto/update-survey-campaign.dto'; -import type { UpdateSurveyTargetDto } from './dto/update-survey-target.dto'; - -export interface PersonSummary { - id: string; - username: string; - firstName: string; - lastName: string; -} - -interface AssetSummary { - id: string; - code: string; - name: string; -} - -export interface SurveyCampaignListItem { - id: string; - code: string; - name: string; - description: string | null; - status: SurveyCampaignStatus; - plannedStartAt: Date | null; - plannedEndAt: Date | null; - scopeAsset: AssetSummary | null; - coordinator: PersonSummary | null; - targetCount: number; - pendingCount: number; - inProgressCount: number; - submittedCount: number; - completedCount: number; - skippedCount: number; - createdAt: Date; - updatedAt: Date; -} - -export interface SurveyTargetView { - id: string; - campaignId: string; - asset: AssetSummary & { typeName: string }; - assignedUser: PersonSummary | null; - status: SurveyTargetStatus; - dueAt: Date | null; - instructions: string | null; - createdAt: Date; - updatedAt: Date; -} - -export interface SurveyCampaignView extends SurveyCampaignListItem { - targets: SurveyTargetView[]; -} - -const campaignTransitions: Record = { - [SurveyCampaignStatus.DRAFT]: [SurveyCampaignStatus.PLANNED, SurveyCampaignStatus.CANCELLED], - [SurveyCampaignStatus.PLANNED]: [ - SurveyCampaignStatus.DRAFT, - SurveyCampaignStatus.IN_PROGRESS, - SurveyCampaignStatus.CANCELLED, - ], - [SurveyCampaignStatus.IN_PROGRESS]: [ - SurveyCampaignStatus.COMPLETED, - SurveyCampaignStatus.CANCELLED, - ], - [SurveyCampaignStatus.COMPLETED]: [], - [SurveyCampaignStatus.CANCELLED]: [], -}; - -const targetTransitions: Record = { - [SurveyTargetStatus.PENDING]: [SurveyTargetStatus.IN_PROGRESS, SurveyTargetStatus.SKIPPED], - [SurveyTargetStatus.IN_PROGRESS]: [ - SurveyTargetStatus.PENDING, - SurveyTargetStatus.SKIPPED, - ], - [SurveyTargetStatus.SUBMITTED]: [], - [SurveyTargetStatus.COMPLETED]: [], - [SurveyTargetStatus.SKIPPED]: [], -}; - -function campaignNotFound(): NotFoundException { - return new NotFoundException({ - code: 'SURVEY_CAMPAIGN_NOT_FOUND', - message: 'Campaña de relevamiento no encontrada', - }); -} - -function targetNotFound(): NotFoundException { - return new NotFoundException({ - code: 'SURVEY_TARGET_NOT_FOUND', - message: 'Objetivo de relevamiento no encontrado', - }); -} - -function dateValue(value: string | null | undefined): Date | null { - return value ? new Date(value) : null; -} - -@Injectable() -export class SurveyPlanningService { - constructor( - private readonly dataSource: DataSource, - private readonly audit: AuditService, - ) {} - - async list(query: ListSurveyCampaignsQueryDto) { - const conditions: string[] = []; - const parameters: unknown[] = []; - const addParameter = (value: unknown) => { - parameters.push(value); - return `$${parameters.length}`; - }; - - if (query.search?.trim()) { - const search = addParameter(`%${query.search.trim()}%`); - conditions.push(`(campaign.code ILIKE ${search} OR campaign.name ILIKE ${search})`); - } - if (query.status) conditions.push(`campaign.status = ${addParameter(query.status)}`); - if (query.coordinatorUserId) { - conditions.push(`campaign.coordinator_user_id = ${addParameter(query.coordinatorUserId)}`); - } - - const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; - const [countRow] = (await this.dataSource.query( - `SELECT COUNT(*)::integer AS total FROM survey_campaigns campaign ${where}`, - parameters, - )) as Array<{ total: number }>; - const total = Number(countRow?.total ?? 0); - const offset = (query.page - 1) * query.pageSize; - const limitParameter = addParameter(query.pageSize); - const offsetParameter = addParameter(offset); - const data = (await this.dataSource.query( - `${this.campaignSelect(where)} - ORDER BY campaign.updated_at DESC, campaign.code ASC - LIMIT ${limitParameter} OFFSET ${offsetParameter}`, - parameters, - )) as SurveyCampaignListItem[]; - - return { - data, - meta: { - page: query.page, - pageSize: query.pageSize, - total, - totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), - }, - }; - } - - async listAssignees(): Promise<{ data: PersonSummary[] }> { - const data = (await this.dataSource.query(` - SELECT DISTINCT - user_account.id, - user_account.username, - user_account.first_name AS "firstName", - user_account.last_name AS "lastName" - FROM users user_account - INNER JOIN user_roles user_role ON user_role.user_id = user_account.id - INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id - INNER JOIN permissions permission ON permission.id = role_permission.permission_id - WHERE user_account.status = 'ACTIVE' - AND permission.code = 'surveys.execute' - ORDER BY user_account.last_name, user_account.first_name, user_account.username - `)) as PersonSummary[]; - return { data }; - } - - async getById(id: string): Promise { - return this.dataSource.transaction((manager) => this.loadCampaignView(manager, id)); - } - - async create( - dto: CreateSurveyCampaignDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - try { - return await this.dataSource.transaction(async (manager) => { - await this.validateDates(dto.plannedStartAt ?? null, dto.plannedEndAt ?? null); - await this.requireAsset(manager, dto.scopeAssetId ?? null); - await this.requireActiveUser(manager, dto.coordinatorUserId ?? null); - const campaign = manager.getRepository(SurveyCampaign).create({ - code: dto.code, - name: dto.name, - description: dto.description ?? null, - status: SurveyCampaignStatus.DRAFT, - plannedStartAt: dateValue(dto.plannedStartAt), - plannedEndAt: dateValue(dto.plannedEndAt), - scopeAssetId: dto.scopeAssetId ?? null, - coordinatorUserId: dto.coordinatorUserId ?? null, - createdBy: principal.userId, - updatedBy: principal.userId, - }); - await manager.getRepository(SurveyCampaign).save(campaign); - const created = await this.loadCampaignView(manager, campaign.id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_CAMPAIGN_CREATED, - entityType: 'survey_campaign', - entityId: campaign.id, - afterData: this.auditCampaign(created), - }, manager); - return created; - }); - } catch (error) { - if (isUniqueViolation(error)) throw this.campaignConflict(); - throw error; - } - } - - async update( - id: string, - dto: UpdateSurveyCampaignDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - if (Object.keys(dto).length === 0) { - throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); - } - try { - return await this.dataSource.transaction(async (manager) => { - const campaign = await this.lockCampaign(manager, id); - this.assertCampaignEditable(campaign); - const before = await this.loadCampaignView(manager, id); - const nextStart = dto.plannedStartAt === undefined - ? campaign.plannedStartAt - : dateValue(dto.plannedStartAt); - const nextEnd = dto.plannedEndAt === undefined - ? campaign.plannedEndAt - : dateValue(dto.plannedEndAt); - await this.validateDates(nextStart, nextEnd); - if (dto.scopeAssetId !== undefined) { - await this.requireAsset(manager, dto.scopeAssetId); - await this.assertCampaignTargetsInScope(manager, id, dto.scopeAssetId); - } - if (dto.coordinatorUserId !== undefined) { - await this.requireActiveUser(manager, dto.coordinatorUserId); - } - - if (dto.code !== undefined) campaign.code = dto.code; - if (dto.name !== undefined) campaign.name = dto.name; - if (dto.description !== undefined) campaign.description = dto.description; - if (dto.plannedStartAt !== undefined) campaign.plannedStartAt = nextStart; - if (dto.plannedEndAt !== undefined) campaign.plannedEndAt = nextEnd; - if (dto.scopeAssetId !== undefined) campaign.scopeAssetId = dto.scopeAssetId; - if (dto.coordinatorUserId !== undefined) { - campaign.coordinatorUserId = dto.coordinatorUserId; - } - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - const updated = await this.loadCampaignView(manager, id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_CAMPAIGN_UPDATED, - entityType: 'survey_campaign', - entityId: id, - beforeData: this.auditCampaign(before), - afterData: this.auditCampaign(updated), - }, manager); - return updated; - }); - } catch (error) { - if (isUniqueViolation(error)) throw this.campaignConflict(); - throw error; - } - } - - async changeCampaignStatus( - id: string, - dto: ChangeSurveyCampaignStatusDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - return this.dataSource.transaction(async (manager) => { - const campaign = await this.lockCampaign(manager, id); - if (campaign.status === dto.status) return this.loadCampaignView(manager, id); - if (!campaignTransitions[campaign.status].includes(dto.status)) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_INVALID_TRANSITION', - message: `No se puede pasar la campaña de ${campaign.status} a ${dto.status}`, - }); - } - const [counts] = (await manager.query(` - SELECT - COUNT(*)::integer AS total, - COUNT(*) FILTER (WHERE status IN ('PENDING', 'IN_PROGRESS', 'SUBMITTED'))::integer AS outstanding - FROM survey_campaign_targets WHERE campaign_id = $1 - `, [id])) as Array<{ total: number; outstanding: number }>; - if (dto.status === SurveyCampaignStatus.IN_PROGRESS && Number(counts?.total ?? 0) === 0) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_EMPTY', - message: 'Agregá al menos un activo antes de iniciar la campaña', - }); - } - if ( - dto.status === SurveyCampaignStatus.COMPLETED && - Number(counts?.outstanding ?? 0) > 0 - ) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_HAS_OPEN_TARGETS', - message: 'Todos los objetivos deben estar completados u omitidos', - }); - } - const beforeStatus = campaign.status; - campaign.status = dto.status; - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - const updated = await this.loadCampaignView(manager, id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_CAMPAIGN_STATUS_CHANGED, - entityType: 'survey_campaign', - entityId: id, - beforeData: { status: beforeStatus }, - afterData: { status: dto.status }, - }, manager); - return updated; - }); - } - - async addTarget( - campaignId: string, - dto: AddSurveyTargetDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - try { - return await this.dataSource.transaction(async (manager) => { - const campaign = await this.lockCampaign(manager, campaignId); - this.assertCampaignEditable(campaign); - await this.requireAsset(manager, dto.assetId); - await this.assertAssetInScope(manager, dto.assetId, campaign.scopeAssetId); - await this.requireAssignee(manager, dto.assignedUserId ?? null); - const target = manager.getRepository(SurveyCampaignTarget).create({ - campaignId, - assetId: dto.assetId, - assignedUserId: dto.assignedUserId ?? null, - status: SurveyTargetStatus.PENDING, - dueAt: dateValue(dto.dueAt), - instructions: dto.instructions ?? null, - createdBy: principal.userId, - updatedBy: principal.userId, - }); - await manager.getRepository(SurveyCampaignTarget).save(target); - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - const targetView = await this.loadTargetView(manager, target.id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_TARGET_ADDED, - entityType: 'survey_campaign_target', - entityId: target.id, - afterData: targetView as unknown as Record, - metadata: { campaignId }, - }, manager); - return this.loadCampaignView(manager, campaignId); - }); - } catch (error) { - if (isUniqueViolation(error)) { - throw new ConflictException({ - code: 'SURVEY_TARGET_ALREADY_EXISTS', - message: 'El activo ya forma parte de esta campaña', - }); - } - throw error; - } - } - - async updateTarget( - id: string, - dto: UpdateSurveyTargetDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - if (Object.keys(dto).length === 0) { - throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); - } - return this.dataSource.transaction(async (manager) => { - const target = await this.lockTarget(manager, id); - const campaign = await this.lockCampaign(manager, target.campaignId); - this.assertCampaignEditable(campaign); - this.assertTargetPlanningEditable(target); - const before = await this.loadTargetView(manager, id); - if (dto.dueAt !== undefined) target.dueAt = dateValue(dto.dueAt); - if (dto.instructions !== undefined) target.instructions = dto.instructions; - target.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaignTarget).save(target); - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - const updated = await this.loadTargetView(manager, id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_TARGET_UPDATED, - entityType: 'survey_campaign_target', - entityId: id, - beforeData: before as unknown as Record, - afterData: updated as unknown as Record, - metadata: { campaignId: target.campaignId }, - }, manager); - return this.loadCampaignView(manager, target.campaignId); - }); - } - - async assignTarget( - id: string, - dto: AssignSurveyTargetDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - return this.dataSource.transaction(async (manager) => { - const target = await this.lockTarget(manager, id); - const campaign = await this.lockCampaign(manager, target.campaignId); - this.assertCampaignEditable(campaign); - this.assertTargetPlanningEditable(target); - await this.requireAssignee(manager, dto.assignedUserId); - const beforeUserId = target.assignedUserId; - if (beforeUserId === dto.assignedUserId) { - return this.loadCampaignView(manager, target.campaignId); - } - target.assignedUserId = dto.assignedUserId; - target.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaignTarget).save(target); - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_TARGET_ASSIGNED, - entityType: 'survey_campaign_target', - entityId: id, - beforeData: { assignedUserId: beforeUserId }, - afterData: { assignedUserId: dto.assignedUserId }, - metadata: { campaignId: target.campaignId }, - }, manager); - return this.loadCampaignView(manager, target.campaignId); - }); - } - - async changeTargetStatus( - id: string, - dto: ChangeSurveyTargetStatusDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - return this.dataSource.transaction(async (manager) => { - const target = await this.lockTarget(manager, id); - const campaign = await this.lockCampaign(manager, target.campaignId); - const planningOmission = - principal.permissions.includes('surveys.manage') && - [SurveyCampaignStatus.DRAFT, SurveyCampaignStatus.PLANNED].includes(campaign.status) && - ( - (target.status === SurveyTargetStatus.PENDING && dto.status === SurveyTargetStatus.SKIPPED) || - (target.status === SurveyTargetStatus.SKIPPED && dto.status === SurveyTargetStatus.PENDING) - ); - if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS && !planningOmission) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS', - message: 'La campaña debe estar en curso para registrar avance; durante la planificación sólo se puede omitir o restaurar un objetivo', - }); - } - if ( - !principal.permissions.includes('surveys.manage') && - target.assignedUserId !== principal.userId - ) { - throw new ForbiddenException({ - code: 'SURVEY_TARGET_NOT_ASSIGNED', - message: 'El objetivo no está asignado al usuario actual', - }); - } - if (target.status === dto.status) return this.loadCampaignView(manager, target.campaignId); - if (!planningOmission && !targetTransitions[target.status].includes(dto.status)) { - throw new ConflictException({ - code: 'SURVEY_TARGET_INVALID_TRANSITION', - message: `No se puede pasar el objetivo de ${target.status} a ${dto.status}`, - }); - } - const beforeStatus = target.status; - target.status = dto.status; - target.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaignTarget).save(target); - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_TARGET_STATUS_CHANGED, - entityType: 'survey_campaign_target', - entityId: id, - beforeData: { status: beforeStatus }, - afterData: { status: dto.status }, - metadata: { campaignId: target.campaignId }, - }, manager); - return this.loadCampaignView(manager, target.campaignId); - }); - } - - private campaignSelect(where: string): string { - return ` - SELECT - campaign.id, - campaign.code, - campaign.name, - campaign.description, - campaign.status, - campaign.planned_start_at AS "plannedStartAt", - campaign.planned_end_at AS "plannedEndAt", - CASE WHEN scope_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', scope_asset.id, 'code', scope_asset.code, 'name', scope_asset.name - ) END AS "scopeAsset", - CASE WHEN coordinator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', coordinator.id, - 'username', coordinator.username, - 'firstName', coordinator.first_name, - 'lastName', coordinator.last_name - ) END AS coordinator, - COALESCE(target_counts.total, 0)::integer AS "targetCount", - COALESCE(target_counts.pending, 0)::integer AS "pendingCount", - COALESCE(target_counts.in_progress, 0)::integer AS "inProgressCount", - COALESCE(target_counts.submitted, 0)::integer AS "submittedCount", - COALESCE(target_counts.completed, 0)::integer AS "completedCount", - COALESCE(target_counts.skipped, 0)::integer AS "skippedCount", - campaign.created_at AS "createdAt", - campaign.updated_at AS "updatedAt" - FROM survey_campaigns campaign - LEFT JOIN assets scope_asset ON scope_asset.id = campaign.scope_asset_id - LEFT JOIN users coordinator ON coordinator.id = campaign.coordinator_user_id - LEFT JOIN LATERAL ( - SELECT - COUNT(*) AS total, - COUNT(*) FILTER (WHERE target.status = 'PENDING') AS pending, - COUNT(*) FILTER (WHERE target.status = 'IN_PROGRESS') AS in_progress, - COUNT(*) FILTER (WHERE target.status = 'SUBMITTED') AS submitted, - COUNT(*) FILTER (WHERE target.status = 'COMPLETED') AS completed, - COUNT(*) FILTER (WHERE target.status = 'SKIPPED') AS skipped - FROM survey_campaign_targets target - WHERE target.campaign_id = campaign.id - ) target_counts ON true - ${where} - `; - } - - private async loadCampaignView( - manager: EntityManager, - id: string, - ): Promise { - const [campaign] = (await manager.query( - this.campaignSelect('WHERE campaign.id = $1'), - [id], - )) as SurveyCampaignListItem[]; - if (!campaign) throw campaignNotFound(); - const targets = (await manager.query(` - ${this.targetSelect()} - WHERE target.campaign_id = $1 - ORDER BY - CASE target.status - WHEN 'SUBMITTED' THEN 1 WHEN 'IN_PROGRESS' THEN 2 WHEN 'PENDING' THEN 3 - WHEN 'COMPLETED' THEN 4 ELSE 5 - END, - target.due_at NULLS LAST, - asset.code - `, [id])) as SurveyTargetView[]; - return { ...campaign, targets }; - } - - private async loadTargetView(manager: EntityManager, id: string): Promise { - const [target] = (await manager.query(` - ${this.targetSelect()} WHERE target.id = $1 - `, [id])) as SurveyTargetView[]; - if (!target) throw targetNotFound(); - return target; - } - - private targetSelect(): string { - return ` - SELECT - target.id, - target.campaign_id AS "campaignId", - JSONB_BUILD_OBJECT( - 'id', asset.id, - 'code', asset.code, - 'name', asset.name, - 'typeName', asset_type.name - ) AS asset, - CASE WHEN assignee.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', assignee.id, - 'username', assignee.username, - 'firstName', assignee.first_name, - 'lastName', assignee.last_name - ) END AS "assignedUser", - target.status, - target.due_at AS "dueAt", - target.instructions, - target.created_at AS "createdAt", - target.updated_at AS "updatedAt" - FROM survey_campaign_targets target - INNER JOIN assets asset ON asset.id = target.asset_id - INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id - LEFT JOIN users assignee ON assignee.id = target.assigned_user_id - `; - } - - private async lockCampaign(manager: EntityManager, id: string): Promise { - const campaign = await manager.getRepository(SurveyCampaign) - .createQueryBuilder('campaign') - .where('campaign.id = :id', { id }) - .setLock('pessimistic_write') - .getOne(); - if (!campaign) throw campaignNotFound(); - return campaign; - } - - private async lockTarget(manager: EntityManager, id: string): Promise { - const target = await manager.getRepository(SurveyCampaignTarget) - .createQueryBuilder('target') - .where('target.id = :id', { id }) - .setLock('pessimistic_write') - .getOne(); - if (!target) throw targetNotFound(); - return target; - } - - private async requireAsset(manager: EntityManager, id: string | null): Promise { - if (!id) return; - const rows = await manager.query(`SELECT 1 FROM assets WHERE id = $1`, [id]) as unknown[]; - if (rows.length === 0) { - throw new BadRequestException({ - code: 'SURVEY_ASSET_NOT_FOUND', - message: 'El activo seleccionado no existe', - }); - } - } - - private async requireActiveUser(manager: EntityManager, id: string | null): Promise { - if (!id) return; - const rows = await manager.query( - `SELECT 1 FROM users WHERE id = $1 AND status = 'ACTIVE'`, - [id], - ) as unknown[]; - if (rows.length === 0) { - throw new BadRequestException({ - code: 'SURVEY_USER_NOT_ACTIVE', - message: 'El usuario seleccionado no existe o está inactivo', - }); - } - } - - private async requireAssignee(manager: EntityManager, id: string | null): Promise { - if (!id) return; - const rows = await manager.query(` - SELECT 1 - FROM users user_account - WHERE user_account.id = $1 - AND user_account.status = 'ACTIVE' - AND EXISTS ( - SELECT 1 - FROM user_roles user_role - INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id - INNER JOIN permissions permission ON permission.id = role_permission.permission_id - WHERE user_role.user_id = user_account.id - AND permission.code = 'surveys.execute' - ) - `, [id]) as unknown[]; - if (rows.length === 0) { - throw new BadRequestException({ - code: 'SURVEY_ASSIGNEE_INVALID', - message: 'El responsable debe ser un usuario activo con permiso de ejecución', - }); - } - } - - private async assertAssetInScope( - manager: EntityManager, - assetId: string, - scopeAssetId: string | null, - ): Promise { - if (!scopeAssetId) return; - const rows = await manager.query(` - WITH RECURSIVE ancestors AS ( - SELECT id, parent_id FROM assets WHERE id = $1 - UNION ALL - SELECT parent.id, parent.parent_id - FROM assets parent - INNER JOIN ancestors child ON child.parent_id = parent.id - ) - SELECT 1 FROM ancestors WHERE id = $2 - `, [assetId, scopeAssetId]) as unknown[]; - if (rows.length === 0) { - throw new BadRequestException({ - code: 'SURVEY_TARGET_OUTSIDE_SCOPE', - message: 'El activo no pertenece al alcance jerárquico de la campaña', - }); - } - } - - private async assertCampaignTargetsInScope( - manager: EntityManager, - campaignId: string, - scopeAssetId: string | null, - ): Promise { - if (!scopeAssetId) return; - const [row] = (await manager.query(` - SELECT COUNT(*)::integer AS outside - FROM survey_campaign_targets target - WHERE target.campaign_id = $1 - AND NOT EXISTS ( - WITH RECURSIVE ancestors AS ( - SELECT id, parent_id FROM assets WHERE id = target.asset_id - UNION ALL - SELECT parent.id, parent.parent_id - FROM assets parent - INNER JOIN ancestors child ON child.parent_id = parent.id - ) - SELECT 1 FROM ancestors WHERE id = $2 - ) - `, [campaignId, scopeAssetId])) as Array<{ outside: number }>; - if (Number(row?.outside ?? 0) > 0) { - throw new ConflictException({ - code: 'SURVEY_SCOPE_EXCLUDES_TARGETS', - message: 'El nuevo alcance dejaría objetivos existentes fuera de la campaña', - }); - } - } - - private async validateDates( - start: string | Date | null, - end: string | Date | null, - ): Promise { - if (start && end && new Date(end).getTime() < new Date(start).getTime()) { - throw new BadRequestException({ - code: 'SURVEY_INVALID_DATES', - message: 'La fecha de finalización no puede ser anterior al inicio', - }); - } - } - - private assertCampaignEditable(campaign: SurveyCampaign): void { - if ( - campaign.status === SurveyCampaignStatus.COMPLETED || - campaign.status === SurveyCampaignStatus.CANCELLED - ) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_CLOSED', - message: 'Una campaña completada o cancelada ya no puede modificarse', - }); - } - } - - private assertTargetPlanningEditable(target: SurveyCampaignTarget): void { - if ( - target.status === SurveyTargetStatus.SUBMITTED || - target.status === SurveyTargetStatus.COMPLETED - ) { - throw new ConflictException({ - code: 'SURVEY_TARGET_PLANNING_LOCKED', - message: 'Un objetivo enviado o completado ya no puede replanificarse', - }); - } - } - - private campaignConflict(): ConflictException { - return new ConflictException({ - code: 'SURVEY_CAMPAIGN_CODE_EXISTS', - message: 'Ya existe una campaña con ese código', - }); - } - - private auditCampaign(campaign: SurveyCampaignView): Record { - const { targets: _targets, ...summary } = campaign; - return summary as unknown as Record; - } -} diff --git a/api-v3/test/unit/dashboard-service.test.ts b/api-v3/test/unit/dashboard-service.test.ts index 3281fef..f030d9b 100644 --- a/api-v3/test/unit/dashboard-service.test.ts +++ b/api-v3/test/unit/dashboard-service.test.ts @@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert'; import test from 'node:test'; import { DashboardService } from '../../src/dashboard/dashboard.service'; -test('DashboardService returns numeric counts and recent activity consistently', async () => { +test('DashboardService returns F4 operational counts and recent activity consistently', async () => { const recent = [{ id: 'event-id', occurredAt: new Date('2026-08-13T12:00:00.000Z'), @@ -17,9 +17,8 @@ test('DashboardService returns numeric counts and recent activity consistently', return [{ activeUsers: '4', inactiveUsers: '2', activeSessions: '3', totalAssets: '12', assetsNeedValidation: '4', assetsWithoutGeometry: '6', plannedInspections: '2', - openFindings: '8', awaitingCompanyResponse: '5', overdueCompanyResponses: '2', - companyResponsesDueNext7Days: '1', awaitingVerificationSchedule: '4', - overdueControls: '2', controlsNext30Days: '3', + openFindings: '8', findingsWithoutControlDate: '5', overdueControls: '2', controlsNext30Days: '3', + reportsWorking: '1', reportsOfficialized: '2', sealedActsWithoutReport: '4', }]; } if (sql.includes('FROM inspection_findings finding')) return []; @@ -32,20 +31,20 @@ test('DashboardService returns numeric counts and recent activity consistently', const result = await new DashboardService(dataSource as never).summary(); assert.deepEqual(result.counts, { - activeUsers: 4, - inactiveUsers: 2, - activeSessions: 3, totalAssets: 12, assetsNeedValidation: 4, assetsWithoutGeometry: 6, plannedInspections: 2, + activeUsers: 4, + inactiveUsers: 2, + activeSessions: 3, openFindings: 8, - awaitingCompanyResponse: 5, - overdueCompanyResponses: 2, - companyResponsesDueNext7Days: 1, - awaitingVerificationSchedule: 4, + findingsWithoutControlDate: 5, overdueControls: 2, controlsNext30Days: 3, + reportsWorking: 1, + reportsOfficialized: 2, + sealedActsWithoutReport: 4, }); assert.equal(result.recentAudit, recent); assert.deepEqual(result.upcomingControls, []); diff --git a/api-v3/test/unit/f3-2-mobile-multi-acta.test.ts b/api-v3/test/unit/f3-2-mobile-multi-acta.test.ts index 297b136..5369484 100644 --- a/api-v3/test/unit/f3-2-mobile-multi-acta.test.ts +++ b/api-v3/test/unit/f3-2-mobile-multi-acta.test.ts @@ -4,39 +4,46 @@ import test from 'node:test'; const read = (path: string) => readFileSync(path, 'utf8'); -test('F3.2 mantiene múltiples Actas con un solo borrador simultáneo por inspección', () => { +test('F3.2/F4 mantiene múltiples Actas con un solo borrador simultáneo por inspección', () => { const migration = read('src/database/migrations/1789495200000-phase-f1-1-multi-act-inspections.ts'); assert.match(migration, /DROP CONSTRAINT IF EXISTS uq_inspection_acts_visit/); assert.match(migration, /CREATE UNIQUE INDEX uq_inspection_acts_one_draft_per_visit/); assert.match(migration, /WHERE status = 'DRAFT'/); }); -test('F3.2 permite seleccionar el Acta explícitamente al trabajar Hallazgos desde la APK', () => { +test('F3.2/F4 exige seleccionar el Acta explícitamente al crear Hallazgos desde la APK', () => { const controller = read('src/inspection-visits/field-findings.controller.ts'); const service = read('src/inspection-visits/field-findings.service.ts'); const dto = read('src/inspection-visits/dto/create-field-finding.dto.ts'); assert.match(controller, /FieldFindingActQueryDto/); assert.match(controller, /query\.actId/); - assert.match(dto, /actId\?: string/); - assert.match(service, /actSelectionMode: actId \? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT'/); + assert.match(dto, /actId!: string/); + assert.match(dto, /El Acta es obligatoria/); + assert.match(service, /this\.requireDraftAct\(visitId, dto\.actId\)/); assert.match(service, /WHERE id=\$1::uuid AND visit_id=\$2::uuid/); assert.match(service, /FIELD_FINDING_ACT_NOT_DRAFT/); assert.match(service, /FIELD_FINDING_ASSET_NOT_IN_ACT/); }); -test('F3.2 conserva el cierre documental inmutable de cada Acta', () => { +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_ACT_COMPANY_OUTCOME_REQUIRED/); assert.match(closing, /finalSha256 = sha256CanonicalJson\(finalSnapshot\)/); - assert.match(closing, /status = 'CLOSED'/); + assert.match(closing, /status='SEALED'/); + assert.match(closing, /INSPECTION_ACT_LOCK_IS_IMMUTABLE/); assert.match(closing, /ensureFrozenReport/); assert.match(closing, /ensureWordForAct/); }); -test('F3.2 cierra la inspección sólo cuando no quedan borradores ni Actas sin firma de inspector', () => { - const visits = read('src/inspection-visits/inspection-visits.service.ts'); - assert.match(visits, /INSPECTION_VISIT_DRAFT_ACTS_PENDING/); - assert.match(visits, /INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING/); - assert.match(visits, /actsMayCompleteCompanySignatureLater: true/); +test('F4 cierra la inspección sólo cuando todas las Actas no canceladas están selladas y las verificaciones resueltas', () => { + const lifecycle = read('src/inspection-visits/inspection-visit-lifecycle.service.ts'); + const closeBody = lifecycle.slice(lifecycle.indexOf(' async close('), lifecycle.indexOf(' async cancel(')); + assert.match(closeBody, /INSPECTION_REQUIRES_ACT/); + assert.match(closeBody, /INSPECTION_ACTS_NOT_SEALED/); + assert.match(closeBody, /act\.status !== 'SEALED'/); + assert.match(closeBody, /assertVerificationResultsComplete\(manager, id\)/); + assert.match(closeBody, /visit\.status = InspectionVisitStatus\.CLOSED/); + assert.doesNotMatch(closeBody, /actsMayCompleteCompanySignatureLater/); }); diff --git a/api-v3/test/unit/f4-act-web-lifecycle-contract.test.ts b/api-v3/test/unit/f4-act-web-lifecycle-contract.test.ts new file mode 100644 index 0000000..fd6a50e --- /dev/null +++ b/api-v3/test/unit/f4-act-web-lifecycle-contract.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const readWeb = (relative: string) => fs.readFileSync(path.join(root, '..', 'web-v2', 'src', relative), 'utf8'); + +const f4Api = readWeb('lib/inspectionActF4Api.ts'); +const actsPage = readWeb('pages/ActsPage.tsx'); +const actsPanel = readWeb('features/inspections/InspectionActsPanel.tsx'); +const actDetail = readWeb('pages/InspectionActEditorPage.tsx'); +const closurePanel = readWeb('features/inspections/InspectionClosurePanel.tsx'); +const presentation = readWeb('features/inspections/inspectionActPresentation.ts'); + +test('F4 WEB has an explicit LOCKED and SEALED act contract', () => { + assert.match(f4Api, /export type InspectionActStatusF4/); + assert.match(f4Api, /\| 'LOCKED'/); + assert.match(f4Api, /\| 'SEALED'/); + assert.match(f4Api, /lockedAt: string \| null/); + assert.match(f4Api, /lockedSha256: string \| null/); + assert.match(f4Api, /sealedAt: string \| null/); + assert.match(f4Api, /urgency: InspectionActUrgencyF4/); + assert.match(f4Api, /deadlineAt: string \| null/); +}); + +test('F4 document center and inspection detail consume the F4 act client', () => { + assert.match(actsPage, /listInspectionActsGlobalF4/); + assert.match(actsPage, /value: 'LOCKED'/); + assert.match(actsPage, /value: 'SEALED'/); + assert.match(actsPanel, /listInspectionActsF4/); + assert.match(actDetail, /getInspectionActF4/); + assert.match(actDetail, /act\?\.status === 'SEALED'/); +}); + +test('F4 closure UI treats sealing as the definitive act state', () => { + assert.match(closurePanel, /getInspectionClosureF4/); + assert.match(closurePanel, /act\.status === 'SEALED'/); + assert.match(closurePanel, /ACTA SELLADA/); + assert.match(closurePanel, /manifestación de la empresa puede completarse/); + assert.match(closurePanel, /Pendiente de evento válido/); + assert.doesNotMatch(closurePanel, /reabrir ni cerrar el acta/); +}); + +test('F4 presentation keeps new lifecycle labels while retaining explicit legacy readability', () => { + assert.match(presentation, /LOCKED: 'Bloqueada/); + assert.match(presentation, /SEALED: 'Sellada'/); + assert.match(presentation, /READY: 'Lista para cerrar · legado'/); + assert.match(presentation, /CLOSED: 'Cerrada · legado'/); +}); diff --git a/api-v3/test/unit/f4-android-act-contract.test.ts b/api-v3/test/unit/f4-android-act-contract.test.ts new file mode 100644 index 0000000..f50fcd8 --- /dev/null +++ b/api-v3/test/unit/f4-android-act-contract.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const android = (...segments: string[]) => path.join(root, '..', 'android-app', 'app', 'src', 'main', 'java', 'com', 'korexlabs', 'dhinspeccion', ...segments); +const read = (...segments: string[]) => fs.readFileSync(android(...segments), 'utf8'); + +const mobileCore = read('data', 'DhMobile.kt'); +const mobileActs = read('data', 'MobileActs.kt'); +const viewModel = read('MainViewModel.kt'); + +test('F4 Android inspection contract has no title or planned end field', () => { + const summary = mobileCore.slice(mobileCore.indexOf('data class VisitSummary('), mobileCore.indexOf('data class VisitMeta(')); + const detail = mobileCore.slice(mobileCore.indexOf('data class VisitDetail('), mobileCore.indexOf('// ---------- Field inventory ----------')); + + assert.doesNotMatch(summary, /\btitle\s*:/); + assert.doesNotMatch(summary, /\bplannedEndAt\b/); + assert.doesNotMatch(detail, /\btitle\s*:/); + assert.doesNotMatch(detail, /\bplannedEndAt\b/); + assert.match(summary, /plannedStartAt/); + assert.match(detail, /plannedStartAt/); +}); + +test('F4 Android uses only lock and seal endpoints for the active Act lifecycle', () => { + assert.match(mobileActs, /inspection-acts\/\{actId\}\/lock/); + assert.match(mobileActs, /inspection-acts\/\{actId\}\/seal/); + assert.doesNotMatch(mobileActs, /inspection-acts\/\{actId\}\/ready/); + assert.doesNotMatch(mobileActs, /inspection-acts\/\{actId\}\/reopen/); + assert.doesNotMatch(mobileActs, /inspection-acts\/\{actId\}\/close["']/); + assert.doesNotMatch(mobileActs, /suspend fun prepare\(/); + assert.doesNotMatch(mobileActs, /suspend fun reopen\(/); + assert.doesNotMatch(mobileActs, /suspend fun closeAct\(/); +}); + +test('F4 Android ViewModel calls lock and seal directly', () => { + assert.match(viewModel, /actsRepository\.lock\(actId\)/); + assert.match(viewModel, /actsRepository\.seal\(actId\)/); + assert.doesNotMatch(viewModel, /actsRepository\.prepare\(/); + assert.doesNotMatch(viewModel, /actsRepository\.reopen\(/); + assert.doesNotMatch(viewModel, /actsRepository\.closeAct\(/); +}); diff --git a/api-v3/test/unit/f4-document-lifecycle-database-contract.test.ts b/api-v3/test/unit/f4-document-lifecycle-database-contract.test.ts new file mode 100644 index 0000000..a233155 --- /dev/null +++ b/api-v3/test/unit/f4-document-lifecycle-database-contract.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + +const migration = read('src/database/migrations/1790000600000-f4-align-document-lifecycle-constraints.ts'); +const closing = read('src/inspection-closing/inspection-closing.service.ts'); +const reports = read('src/inspection-reports/inspection-reports.service.ts'); +const acts = read('src/inspection-acts/inspection-acts.service.ts'); +const delivery = read('src/inspection-reports/inspection-document-delivery.service.ts'); + +test('F4 PostgreSQL accepts the active Act and report lifecycle states emitted by services', () => { + assert.match(migration, /status IN \('WORKING','OFFICIALIZED','FROZEN','CANCELLED'\)/); + assert.match(migration, /status IN \('DRAFT','LOCKED','SEALED','READY','CLOSED','CANCELLED','RECTIFIED'\)/); + assert.match(reports, /'WORKING'/); + assert.match(closing, /status='LOCKED'/); + assert.match(closing, /status='SEALED'/); +}); + +test('F4 PostgreSQL accepts the ACT-NNNNN-DD-MM-YY code generated by the active service', () => { + assert.match(acts, /`ACT-\$\{String\(actNumber\)\.padStart\(5, '0'\)\}-\$\{dateRow\.date_part\}`/); + assert.match(migration, /ACT-\[0-9\]\{5\}-\[0-9\]\{2\}-\[0-9\]\{2\}-\[0-9\]\{2\}/); + assert.match(migration, /ACTA-\[0-9\]\{4\}-\[0-9\]\{6\}/); +}); + +test('F4 PostgreSQL accepts LOCKED and SEALED in the immutable Act version ledger', () => { + assert.match(migration, /event IN \('CREATED','UPDATED','LOCKED','SEALED','READY','REOPENED','CLOSED','CANCELLED'\)/); + assert.match(closing, /InspectionActVersionEvent\.LOCKED/); + assert.match(closing, /InspectionActVersionEvent\.SEALED/); +}); + +test('F4 delivery constraint accepts INSPECTOR and retires DIRECTOR from the active contract', () => { + const up = migration.slice(migration.indexOf(' public async up('), migration.indexOf(' public async down(')); + assert.match(delivery, /'COMPANY' \| 'OFFICE' \| 'INSPECTOR'/); + assert.match(delivery, /recipientKind:'INSPECTOR'/); + assert.match(up, /recipient_kind IN \('COMPANY','OFFICE','INSPECTOR'\)/); + assert.doesNotMatch(up, /recipient_kind IN \('COMPANY','OFFICE','DIRECTOR'\)/); +}); + +test('F4 signature trigger binds signatures to the immutable LOCKED snapshot', () => { + const up = migration.slice(migration.indexOf(' public async up('), migration.indexOf(' public async down(')); + assert.match(up, /dhv2_guard_act_signature_ready/); + assert.match(up, /current_status <> 'LOCKED'/); + assert.match(up, /current_sha IS DISTINCT FROM NEW\.prepared_sha256/); + assert.match(up, /OLD\.status IN \('SEALED','CLOSED','RECTIFIED'\)/); +}); + +test('F4 SEALED rows require closure metadata at database level', () => { + const up = migration.slice(migration.indexOf(' public async up('), migration.indexOf(' public async down(')); + assert.match(up, /status NOT IN \('SEALED','CLOSED'\)/); + assert.match(up, /closed_at IS NOT NULL/); + assert.match(up, /closed_by IS NOT NULL/); + assert.match(up, /closure_sha256 ~ '\^\[0-9a-f\]\{64\}\$'/); +}); + +test('F4 lifecycle rollback translates new states and codes before restoring legacy CHECKs', () => { + const down = migration.slice(migration.indexOf(' public async down(')); + const guardRestore = down.indexOf("OLD.status IN ('CLOSED','RECTIFIED')"); + const codeTranslation = down.indexOf("SET code='ACTA-'"); + const actTranslation = down.indexOf('SET status=CASE'); + assert.ok(guardRestore >= 0); + assert.ok(codeTranslation > guardRestore, 'Debe restaurarse primero la guarda D5 para traducir códigos F4'); + assert.ok(actTranslation > codeTranslation, 'El código debe traducirse antes de convertir SEALED a CLOSED'); + assert.match(down, /WHEN status='LOCKED' THEN 'READY'/); + assert.match(down, /WHEN status='SEALED' THEN 'CLOSED'/); + assert.match(down, /WHEN event='LOCKED' THEN 'READY'/); + assert.match(down, /WHEN event='SEALED' THEN 'CLOSED'/); + assert.match(down, /SET status='FROZEN'/); + assert.match(down, /recipient_kind IN \('COMPANY','OFFICE','DIRECTOR','INSPECTOR'\)/); +}); diff --git a/api-v3/test/unit/f4-final-review-contract.test.ts b/api-v3/test/unit/f4-final-review-contract.test.ts new file mode 100644 index 0000000..46095c3 --- /dev/null +++ b/api-v3/test/unit/f4-final-review-contract.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + +const migration = read('src/database/migrations/1790000700000-f4-align-asset-version-function-change.ts'); +const workflow = read('src/inspection-reports/inspection-report-workflow.service.ts'); +const word = read('src/inspection-reports/inspection-report-word.service.ts'); +const controller = read('src/inspection-reports/inspection-reports.controller.ts'); +const compose = read('../docker-compose.yml'); +const webApi = read('../web-v2/src/lib/reportWorkflowApi.ts'); +const reportPage = read('../web-v2/src/pages/ReportDetailPage.tsx'); + +test('F4 physically permits FUNCTION_CHANGED asset versions with a reversible downgrade', () => { + const up = migration.slice(migration.indexOf(' public async up('), migration.indexOf(' public async down(')); + const down = migration.slice(migration.indexOf(' public async down(')); + assert.match(up, /FUNCTION_CHANGED/); + assert.match(down, /SET change_type='UPDATED'[\s\S]*WHERE change_type='FUNCTION_CHANGED'/); +}); + +test('F4 GEDO and follow-up uploads stay under the persisted asset-media volume', () => { + assert.match(workflow, /\?\? '\/app\/storage\/asset-media\/inspection-reports'/); + assert.match(compose, /INSPECTION_REPORT_UPLOAD_ROOT: \/app\/storage\/asset-media\/inspection-reports/); + assert.match(compose, /dhv2_asset_media:\/app\/storage\/asset-media/); +}); + +test('F4 narrative edits invalidate the current generated Word without overwriting its historical file', () => { + const update = workflow.slice(workflow.indexOf(' async updateNarrative('), workflow.indexOf(' async officialize(')); + assert.match(update, /word_status=CASE[\s\S]*THEN 'PENDING'/); + assert.match(update, /word_stored_name=CASE[\s\S]*THEN NULL/); + assert.match(update, /word_sha256=CASE[\s\S]*THEN NULL/); + assert.match(word, /built\.sha256\.slice\(0, 16\)/); +}); + +test('F4 exposes the immutable official GEDO PDF through a protected integrity-checked download', () => { + assert.match(controller, /@Get\(':id\/gedo-pdf'\)[\s\S]*@RequirePermissions\('inspection_reports\.read'\)/); + assert.match(controller, /this\.workflow\.officialPdfContent\(id\)/); + assert.match(workflow, /async officialPdfContent\(/); + assert.match(workflow, /fileStat\.size !== row\.sizeBytes/); + assert.match(workflow, /createHash\('sha256'\)\.update\(buffer\)\.digest\('hex'\)/); + assert.match(webApi, /inspectionReportGedoPdfDownloadUrl/); + assert.match(reportPage, /Descargar PDF oficial/); +}); diff --git a/api-v3/test/unit/f4-inspection-legacy-fields.test.ts b/api-v3/test/unit/f4-inspection-legacy-fields.test.ts new file mode 100644 index 0000000..90a7b63 --- /dev/null +++ b/api-v3/test/unit/f4-inspection-legacy-fields.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { getMetadataArgsStorage, type QueryRunner } from 'typeorm'; +import { InspectionVisit } from '../../src/database/entities/inspection-visit.entity'; +import { F4RemoveInspectionLegacyFields1790000500000 } from '../../src/database/migrations/1790000500000-f4-remove-inspection-legacy-fields'; + +const root = process.cwd(); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + +test('F4: InspectionVisit no vuelve a mapear title ni plannedEndAt', () => { + const metadata = getMetadataArgsStorage(); + const columns = metadata.columns + .filter((column) => column.target === InspectionVisit) + .map((column) => column.propertyName); + const indexes = metadata.indices + .filter((index) => index.target === InspectionVisit) + .map((index) => index.name); + + assert.equal(columns.includes('title'), false); + assert.equal(columns.includes('plannedEndAt'), false); + assert.equal(columns.includes('plannedStartAt'), true); + assert.equal(indexes.includes('idx_inspection_visits_planned_dates'), false); + assert.equal(indexes.includes('idx_inspection_visits_planned_start_at'), true); +}); + +test('F4: los consumidores backend activos no consultan campos físicos legacy de la Inspección', () => { + const consumers = [ + read('src/inspection-acts/inspection-acts.service.ts'), + read('src/inspection-findings/inspection-findings.service.ts'), + read('src/inspection-findings/inspection-finding-worklist.service.ts'), + read('src/inspection-verifications/inspection-verifications.service.ts'), + read('src/asset-master/assets.service.ts'), + ]; + + for (const consumer of consumers) { + assert.doesNotMatch(consumer, /visit\.title/); + assert.doesNotMatch(consumer, /verification_target\.title/); + assert.doesNotMatch(consumer, /\bvisitTitle\b/); + assert.doesNotMatch(consumer, /\bplanned_end_at\b/); + } +}); + +test('F4: el contrato WEB de Inspecciones no reintroduce title, visitTitle ni plannedEndAt', () => { + const webApi = read('../web-v2/src/lib/api.ts'); + const start = webApi.indexOf('export type InspectionVisitStatus'); + const end = webApi.indexOf('let csrfToken: string | null'); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + const inspectionContract = webApi.slice(start, end); + const visitListStart = inspectionContract.indexOf('export interface InspectionVisitListItem'); + const visitListEnd = inspectionContract.indexOf('export type InspectionVerificationOutcome'); + assert.notEqual(visitListStart, -1); + assert.notEqual(visitListEnd, -1); + const visitListContract = inspectionContract.slice(visitListStart, visitListEnd); + + assert.doesNotMatch(visitListContract, /\btitle\s*:/); + assert.doesNotMatch(visitListContract, /\bplannedEndAt\b/); + assert.doesNotMatch(inspectionContract, /\bvisitTitle\b/); + assert.doesNotMatch(inspectionContract, /\bplannedEndAt\b/); +}); + +test('F4: el contrato Android de Inspecciones no reintroduce title ni plannedEndAt', () => { + const android = read('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt'); + const summaryStart = android.indexOf('data class VisitSummary('); + const summaryEnd = android.indexOf('data class VisitMeta('); + const detailStart = android.indexOf('data class VisitDetail('); + const detailEnd = android.indexOf('// ---------- Field inventory ----------'); + assert.notEqual(summaryStart, -1); + assert.notEqual(summaryEnd, -1); + assert.notEqual(detailStart, -1); + assert.notEqual(detailEnd, -1); + + const visitContracts = `${android.slice(summaryStart, summaryEnd)}\n${android.slice(detailStart, detailEnd)}`; + assert.doesNotMatch(visitContracts, /\bval title\s*:/); + assert.doesNotMatch(visitContracts, /\bplannedEndAt\b/); +}); + +test('F4: la migración respalda datos antes de retirar columnas legacy', async () => { + const statements: string[] = []; + const queryRunner = { + query: async (sql: string) => { + statements.push(sql.replace(/\s+/g, ' ').trim()); + return []; + }, + } as unknown as QueryRunner; + + await new F4RemoveInspectionLegacyFields1790000500000().up(queryRunner); + const sql = statements.join('\n'); + + assert.match(sql, /CREATE TABLE IF NOT EXISTS f4_inspection_visit_legacy_fields_backup/i); + assert.match(sql, /INSERT INTO f4_inspection_visit_legacy_fields_backup/i); + assert.match(sql, /SELECT id, title, planned_end_at, CURRENT_TIMESTAMP FROM inspection_visits/i); + assert.match(sql, /DROP COLUMN IF EXISTS planned_end_at/i); + assert.match(sql, /DROP COLUMN IF EXISTS title/i); + assert.match(sql, /DROP CONSTRAINT IF EXISTS chk_inspection_visits_planned_dates/i); + assert.match(sql, /CREATE INDEX IF NOT EXISTS idx_inspection_visits_planned_start_at/i); + assert.doesNotMatch(sql, /CREATE INDEX IF NOT EXISTS idx_inspection_visits_planned_dates/i); + + const backupAt = sql.indexOf('INSERT INTO f4_inspection_visit_legacy_fields_backup'); + const dropAt = sql.indexOf('DROP COLUMN IF EXISTS planned_end_at'); + assert.ok(backupAt >= 0 && dropAt > backupAt, 'el respaldo debe ocurrir antes de eliminar columnas'); +}); + +test('F4: el rollback restaura valores históricos exactos y cubre filas nuevas', async () => { + const statements: string[] = []; + const queryRunner = { + query: async (sql: string) => { + statements.push(sql.replace(/\s+/g, ' ').trim()); + return []; + }, + } as unknown as QueryRunner; + + await new F4RemoveInspectionLegacyFields1790000500000().down(queryRunner); + const sql = statements.join('\n'); + + assert.match(sql, /ADD COLUMN IF NOT EXISTS title varchar\(200\)/i); + assert.match(sql, /ADD COLUMN IF NOT EXISTS planned_end_at timestamptz/i); + assert.match(sql, /UPDATE inspection_visits visit SET title = backup\.title, planned_end_at = backup\.planned_end_at FROM f4_inspection_visit_legacy_fields_backup backup WHERE backup\.visit_id = visit\.id/i); + assert.match(sql, /UPDATE inspection_visits SET title = code WHERE title IS NULL/i); + assert.match(sql, /ALTER COLUMN title SET NOT NULL/i); + assert.match(sql, /ADD CONSTRAINT chk_inspection_visits_planned_dates CHECK/i); + assert.match(sql, /CREATE INDEX IF NOT EXISTS idx_inspection_visits_planned_dates/i); + assert.match(sql, /DROP TABLE IF EXISTS f4_inspection_visit_legacy_fields_backup/i); + + const restoreAt = sql.indexOf('UPDATE inspection_visits visit SET title = backup.title'); + const backupDropAt = sql.indexOf('DROP TABLE IF EXISTS f4_inspection_visit_legacy_fields_backup'); + assert.ok(restoreAt >= 0 && backupDropAt > restoreAt, 'el respaldo sólo se elimina después de restaurar los datos'); +}); diff --git a/api-v3/test/unit/f4-multi-act-lifecycle.test.ts b/api-v3/test/unit/f4-multi-act-lifecycle.test.ts new file mode 100644 index 0000000..60ae06e --- /dev/null +++ b/api-v3/test/unit/f4-multi-act-lifecycle.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + +const closing = read('src/inspection-closing/inspection-closing.service.ts'); +const lifecycle = read('src/inspection-visits/inspection-visit-lifecycle.service.ts'); +const acts = read('src/inspection-acts/inspection-acts.service.ts'); +const findings = read('src/inspection-findings/inspection-findings.service.ts'); +const fieldFindings = read('src/inspection-visits/field-findings.service.ts'); +const actEntity = read('src/database/entities/inspection-act.entity.ts'); + +test('F4 keeps finalized acts irreversible', () => { + const reopenBody = closing.slice(closing.indexOf(' async reopen('), closing.indexOf(' async signInspector(')); + assert.match(reopenBody, /INSPECTION_ACT_LOCK_IS_IMMUTABLE/); + assert.match(reopenBody, /throw new ConflictException/); + assert.doesNotMatch(reopenBody, /UPDATE inspection_acts/); + assert.doesNotMatch(reopenBody, /InspectionActStatus\.DRAFT/); +}); + +test('F4 permits multiple acts per inspection but only one simultaneous draft', () => { + assert.match(actEntity, /uq_inspection_acts_one_draft_per_visit/); + assert.match(actEntity, /where: "status = 'DRAFT'"/); + assert.doesNotMatch(actEntity, /uq_inspection_acts_visit['"]/); + + const createBody = acts.slice(acts.indexOf(' async create('), acts.indexOf(' async update(')); + const singleDraftGuard = acts.slice( + acts.indexOf(' private async assertVisitHasNoDraftAct('), + acts.indexOf(' private async assertVisitAssets('), + ); + assert.match(createBody, /this\.assertVisitOpen\(visit\)/); + assert.match(createBody, /this\.assertVisitHasNoDraftAct\(manager, visitId\)/); + assert.match(singleDraftGuard, /WHERE visit_id = \$1 AND status = 'DRAFT'/); + assert.doesNotMatch(singleDraftGuard, /LOCKED|SEALED/); +}); + +test('F4 act locking is independent from inspection-wide verification completion', () => { + const prepareBody = closing.slice(closing.indexOf(' async prepare('), closing.indexOf(' async reopen(')); + assert.doesNotMatch(prepareBody, /inspection_finding_verification_visits/); + assert.doesNotMatch(prepareBody, /INSPECTION_VERIFICATION_RESULTS_REQUIRED/); + assert.match(prepareBody, /status='LOCKED'/); +}); + +test('F4 blocks act header and inventory mutations once the act leaves draft', () => { + const updateBody = acts.slice(acts.indexOf(' async update('), acts.indexOf(' async cancel(')); + const cancelBody = acts.slice(acts.indexOf(' async cancel('), acts.indexOf(' private actSelect(')); + assert.match(updateBody, /this\.assertActDraft\(act\)/); + assert.match(cancelBody, /this\.assertActDraft\(act\)/); +}); + +test('F4 blocks finding creation and editing once the act is locked', () => { + const editableGuard = findings.slice( + findings.indexOf(' private assertActEditable('), + findings.indexOf(' private assertFindingOpen('), + ); + assert.match(editableGuard, /act\.status !== InspectionActStatus\.DRAFT/); + assert.match(editableGuard, /visit\.status !== InspectionVisitStatus\.IN_PROGRESS/); + assert.match(editableGuard, /INSPECTION_FINDING_ACT_NOT_EDITABLE/); + + const createBody = findings.slice(findings.indexOf(' async create('), findings.indexOf(' async update(')); + const updateBody = findings.slice(findings.indexOf(' async update('), findings.indexOf(' async close(')); + assert.match(createBody, /this\.assertActEditable\(act, visit\)/); + assert.match(updateBody, /this\.assertActEditable\(act, visit\)/); +}); + +test('F4 field finding flow only targets a draft act of the same in-progress inspection', () => { + const createBody = fieldFindings.slice(fieldFindings.indexOf(' async create('), fieldFindings.indexOf(' private async requireGate(')); + const draftGuard = fieldFindings.slice( + fieldFindings.indexOf(' private async requireDraftAct('), + fieldFindings.lastIndexOf('\n}'), + ); + assert.match(createBody, /this\.requireDraftAct\(visitId, dto\.actId\)/); + assert.match(createBody, /this\.findings\.create\(act\.id, payload, principal, request\)/); + assert.match(fieldFindings, /row\.visitStatus !== 'IN_PROGRESS'/); + assert.match(draftGuard, /act\.status !== 'DRAFT'/); + assert.match(draftGuard, /FIELD_FINDING_ACT_NOT_DRAFT/); + 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', () => { + const closeBody = closing.slice(closing.indexOf(' async close('), closing.indexOf(' async signatureContent(')); + assert.match(closeBody, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/); + assert.match(closeBody, /companyOutcomes\.length !== 1/); + assert.match(closeBody, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/); + assert.match(closeBody, /ausencia documentada/); + assert.doesNotMatch(closeBody, /INSPECTION_ACT_COMPANY_MANIFESTATION_PENDING/); + assert.match(closeBody, /status='SEALED'/); +}); + +test('F4 inspection close is the aggregate gate for every non-cancelled act and verification result', () => { + const closeBody = lifecycle.slice(lifecycle.indexOf(' async close('), lifecycle.indexOf(' async cancel(')); + assert.match(closeBody, /WHERE visit_id=\$1 AND status<>'CANCELLED'/); + assert.match(closeBody, /acts\.length === 0/); + assert.match(closeBody, /INSPECTION_REQUIRES_ACT/); + assert.match(closeBody, /acts\.filter\(\(act\) => act\.status !== 'SEALED'\)/); + assert.match(closeBody, /INSPECTION_ACTS_NOT_SEALED/); + assert.match(closeBody, /pendingActs: pending\.map/); + assert.match(closeBody, /assertVerificationResultsComplete\(manager, id\)/); + assert.match(closeBody, /InspectionVisitStatus\.CLOSED/); +}); + +test('F4 prevents cancelling an inspection that already has any sealed act', () => { + const cancelBody = lifecycle.slice(lifecycle.indexOf(' async cancel('), lifecycle.indexOf(' private async lockVisit(')); + assert.match(cancelBody, /WHERE visit_id=\$1 AND status='SEALED'/); + assert.match(cancelBody, /INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL/); +}); diff --git a/api-v3/test/unit/f4-premerge-review-contract.test.ts b/api-v3/test/unit/f4-premerge-review-contract.test.ts new file mode 100644 index 0000000..a6a5700 --- /dev/null +++ b/api-v3/test/unit/f4-premerge-review-contract.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + +const migration = read('src/database/migrations/1790000600000-f4-align-document-lifecycle-constraints.ts'); +const closing = read('src/inspection-closing/inspection-closing.service.ts'); +const administration = read('src/act-administration/act-administration.service.ts'); +const briefing = read('src/act-administration/field-briefing.service.ts'); + +test('F4 report status default is WORKING physically and rollback restores FROZEN', () => { + const up = migration.slice(migration.indexOf(' public async up('), migration.indexOf(' public async down(')); + const down = migration.slice(migration.indexOf(' public async down(')); + assert.match(up, /ALTER COLUMN status SET DEFAULT 'WORKING'/); + assert.match(down, /ALTER COLUMN status SET DEFAULT 'FROZEN'/); +}); + +test('F4 allows a documented company absence to satisfy the terminal manifestation required for sealing', () => { + const closeStart = closing.indexOf(' async close('); + const closeEnd = closing.indexOf(' async signatureContent(', closeStart); + const close = closing.slice(closeStart, closeEnd); + assert.match(close, /companyOutcomes\.length !== 1/); + assert.match(close, /ausencia documentada/); + assert.doesNotMatch(close, /INSPECTION_ACT_COMPANY_MANIFESTATION_PENDING/); + assert.doesNotMatch(close, /companyOutcome\.status === InspectionActSignatureStatus\.ABSENT/); +}); + +test('F4 SEALED Acts remain visible in administrative follow-up and field briefing', () => { + assert.match(administration, /WHERE ia\.status IN \('SEALED', 'CLOSED', 'RECTIFIED'\)/); + assert.match(administration, /\['SEALED', 'CLOSED', 'RECTIFIED'\]\.includes\(act\.status\)/); + assert.match(briefing, /WHERE act\.status IN \('SEALED', 'CLOSED', 'RECTIFIED'\)/); +}); diff --git a/api-v3/test/unit/inspection-acts-dto.test.ts b/api-v3/test/unit/inspection-acts-dto.test.ts index 14297c4..dc06787 100644 --- a/api-v3/test/unit/inspection-acts-dto.test.ts +++ b/api-v3/test/unit/inspection-acts-dto.test.ts @@ -5,18 +5,21 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { CancelInspectionActDto } from '../../src/inspection-acts/dto/cancel-inspection-act.dto'; import { CreateInspectionActDto } from '../../src/inspection-acts/dto/create-inspection-act.dto'; +import { InspectionActUrgency } from '../../src/database/entities'; const ASSET_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5'; -test('inspection act DTO accepts content and selected visit assets', async () => { +test('inspection act DTO accepts urgency, content and selected visit assets', async () => { const dto = plainToInstance(CreateInspectionActDto, { occurredAt: '2026-08-14T15:30:00.000Z', + urgency: InspectionActUrgency.NON_URGENT, title: ' Verificación de instalación ', summary: ' Se inspeccionó el activo seleccionado. ', observations: ' Sin novedades. ', assetIds: [ASSET_ID], }); assert.deepEqual(await validate(dto), []); + assert.equal(dto.urgency, InspectionActUrgency.NON_URGENT); assert.equal(dto.title, 'Verificación de instalación'); assert.equal(dto.summary, 'Se inspeccionó el activo seleccionado.'); assert.equal(dto.observations, 'Sin novedades.'); @@ -25,6 +28,7 @@ test('inspection act DTO accepts content and selected visit assets', async () => test('inspection act DTO rejects duplicate assets', async () => { const dto = plainToInstance(CreateInspectionActDto, { occurredAt: '2026-08-14T15:30:00.000Z', + urgency: InspectionActUrgency.NON_URGENT, title: 'Acta', summary: 'Contenido', assetIds: [ASSET_ID, ASSET_ID], diff --git a/api-v3/test/unit/inspection-closing-authorization.test.ts b/api-v3/test/unit/inspection-closing-authorization.test.ts index 66b8037..3fb20a5 100644 --- a/api-v3/test/unit/inspection-closing-authorization.test.ts +++ b/api-v3/test/unit/inspection-closing-authorization.test.ts @@ -12,14 +12,14 @@ function permissionFor(controller: object, method: string): string[] { return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; } -test('D5 closing endpoints separate read, prepare, sign and close permissions', () => { +test('F4 closing endpoints separate read, lock, sign and seal permissions without reopen', () => { assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'get'), ['inspection_closure.read']); assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'responsible'), ['inspection_closure.prepare']); - assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'ready'), ['inspection_closure.prepare']); - assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'reopen'), ['inspection_closure.prepare']); + assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'lock'), ['inspection_closure.prepare']); + assert.equal('reopen' in InspectionClosingController.prototype, false); assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'inspectorSignature'), ['inspection_closure.sign']); assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companySignature'), ['inspection_closure.sign']); assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companyOutcome'), ['inspection_closure.sign']); - assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'close'), ['inspection_closure.close']); + assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'seal'), ['inspection_closure.close']); assert.deepEqual(permissionFor(InspectionSignatureContentController.prototype, 'content'), ['inspection_closure.read']); }); diff --git a/api-v3/test/unit/inspection-findings-authorization.test.ts b/api-v3/test/unit/inspection-findings-authorization.test.ts index b4b4257..25e2737 100644 --- a/api-v3/test/unit/inspection-findings-authorization.test.ts +++ b/api-v3/test/unit/inspection-findings-authorization.test.ts @@ -13,40 +13,16 @@ function permissionFor(controller: object, method: string): string[] { return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; } -test('finding endpoints separate catalog, content, follow-up and closure permissions', () => { - assert.deepEqual(permissionFor(FindingCatalogController.prototype, 'list'), [ - 'finding_catalog.read', - ]); - for (const method of [ - 'adminList', - 'createCategory', - 'updateCategory', - 'createItem', - 'updateItem', - ]) { - assert.deepEqual(permissionFor(FindingCatalogController.prototype, method), [ - 'finding_catalog.manage', - ]); +test('F4 finding endpoints separate catalog, content and closure while mutable follow-up is retired', () => { + assert.deepEqual(permissionFor(FindingCatalogController.prototype, 'list'), ['finding_catalog.read']); + for (const method of ['adminList', 'createCategory', 'updateCategory', 'createItem', 'updateItem']) { + assert.deepEqual(permissionFor(FindingCatalogController.prototype, method), ['finding_catalog.manage']); } - assert.deepEqual(permissionFor(InspectionActFindingsController.prototype, 'list'), [ - 'inspection_findings.read', - ]); - assert.deepEqual(permissionFor(InspectionActFindingsController.prototype, 'create'), [ - 'inspection_findings.create', - ]); - assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'list'), [ - 'inspection_findings.read', - ]); - assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'get'), [ - 'inspection_findings.read', - ]); - assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'update'), [ - 'inspection_findings.update', - ]); - assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'updateFollowUp'), [ - 'inspection_findings.follow_up', - ]); - assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'close'), [ - 'inspection_findings.close', - ]); + assert.deepEqual(permissionFor(InspectionActFindingsController.prototype, 'list'), ['inspection_findings.read']); + assert.deepEqual(permissionFor(InspectionActFindingsController.prototype, 'create'), ['inspection_findings.create']); + assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'list'), ['inspection_findings.read']); + assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'get'), ['inspection_findings.read']); + assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'update'), ['inspection_findings.update']); + assert.equal('updateFollowUp' in InspectionFindingsController.prototype, false); + assert.deepEqual(permissionFor(InspectionFindingsController.prototype, 'close'), ['inspection_findings.close']); }); diff --git a/api-v3/test/unit/inspection-findings-office-workflow.test.ts b/api-v3/test/unit/inspection-findings-office-workflow.test.ts index b07b812..b14d607 100644 --- a/api-v3/test/unit/inspection-findings-office-workflow.test.ts +++ b/api-v3/test/unit/inspection-findings-office-workflow.test.ts @@ -13,9 +13,9 @@ test('D5.3.13 separates company response due dates from verification due dates', assert.match(source, /verificationOverdue/); }); -test('D5.3.13 exposes a global office queue and controlled finding closure', () => { +test('D5.3.13/F4 exposes the global worklist and controlled finding closure', () => { assert.match(controller, /@Get\(\)/); - assert.match(controller, /this\.findings\.listGlobal\(query\)/); + assert.match(controller, /this\.worklist\.list\(query\)/); assert.match(controller, /@Patch\(':id\/close'\)/); assert.match(source, /finding\.status = InspectionFindingStatus\.CLOSED/); assert.match(source, /finding\.closureNotes = dto\.closureNotes/); diff --git a/api-v3/test/unit/inspection-visits-authorization.test.ts b/api-v3/test/unit/inspection-visits-authorization.test.ts index 450d2a7..b8b1c4c 100644 --- a/api-v3/test/unit/inspection-visits-authorization.test.ts +++ b/api-v3/test/unit/inspection-visits-authorization.test.ts @@ -10,7 +10,7 @@ function permissionFor(method: string): string[] { return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; } -test('inspection visit endpoints separate reading, planning, assignment and execution', () => { +test('F4 inspection endpoints separate reading, planning, assignment and execution', () => { assert.deepEqual(permissionFor('list'), ['inspections.read']); assert.deepEqual(permissionFor('get'), ['inspections.read']); assert.deepEqual(permissionFor('assignees'), ['inspections.assign']); @@ -18,7 +18,9 @@ test('inspection visit endpoints separate reading, planning, assignment and exec assert.deepEqual(permissionFor('update'), ['inspections.manage']); assert.deepEqual(permissionFor('replaceAssets'), ['inspections.manage']); assert.deepEqual(permissionFor('replaceTeam'), ['inspections.assign']); - assert.deepEqual(permissionFor('changeStatus'), ['inspections.manage']); + assert.deepEqual(permissionFor('plan'), ['inspections.manage']); + assert.deepEqual(permissionFor('unplan'), ['inspections.manage']); assert.deepEqual(permissionFor('start'), ['inspections.execute']); assert.deepEqual(permissionFor('close'), ['inspections.execute']); + assert.deepEqual(permissionFor('cancel'), ['inspections.manage']); }); diff --git a/api-v3/test/unit/mobile-inspector-integration.test.ts b/api-v3/test/unit/mobile-inspector-integration.test.ts index 40917e1..b08e035 100644 --- a/api-v3/test/unit/mobile-inspector-integration.test.ts +++ b/api-v3/test/unit/mobile-inspector-integration.test.ts @@ -11,19 +11,19 @@ function policyCalls(value: string): number { return value.match(/assertMobileInspector\(principal\);/g)?.length ?? 0; } -test('D5.1 applies the mobile-inspector policy to every field operation', async () => { - const [visits, acts, findings, evidence, closing] = await Promise.all([ - source('inspection-visits/inspection-visits.service.ts'), +test('D5.1/F4 applies the mobile-inspector policy to every field operation', async () => { + const [lifecycle, acts, findings, evidence, closing] = await Promise.all([ + source('inspection-visits/inspection-visit-lifecycle.service.ts'), source('inspection-acts/inspection-acts.service.ts'), source('inspection-findings/inspection-findings.service.ts'), source('inspection-findings/inspection-evidence.service.ts'), source('inspection-closing/inspection-closing.service.ts'), ]); - assert.equal(policyCalls(visits), 2); + assert.equal(policyCalls(lifecycle), 2); assert.equal(policyCalls(acts), 3); assert.equal(policyCalls(findings), 2); assert.equal(policyCalls(evidence), 1); - assert.equal(policyCalls(closing), 7); + assert.equal(policyCalls(closing), 6); assert.match(evidence, /purpose === InspectionEvidencePurpose\.OBSERVATION/); }); @@ -34,9 +34,7 @@ test('D5.1 leaves company follow-up outside the field-operation gate', async () }); test('D5.1 migration grants operational permissions only to inspector', async () => { - const migration = await source( - 'database/migrations/1787331600000-phase-d5-1-mobile-inspection-policy.ts', - ); + const migration = await source('database/migrations/1787331600000-phase-d5-1-mobile-inspection-policy.ts'); assert.match(migration, /role\.code <> 'inspector'/); assert.match(migration, /role\.code = 'inspector'/); assert.doesNotMatch(migration, /inspection_findings\.follow_up/); diff --git a/api-v3/test/unit/phase-d5-3-15-document-center.test.ts b/api-v3/test/unit/phase-d5-3-15-document-center.test.ts index 52ab6ed..4b327ce 100644 --- a/api-v3/test/unit/phase-d5-3-15-document-center.test.ts +++ b/api-v3/test/unit/phase-d5-3-15-document-center.test.ts @@ -6,26 +6,31 @@ import { resolve } from 'node:path'; const reportService = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-reports.service.ts'), 'utf8'); const actService = readFileSync(resolve(process.cwd(), 'src/inspection-acts/inspection-acts.service.ts'), 'utf8'); const migration = readFileSync(resolve(process.cwd(), 'src/database/migrations/1788026400000-phase-d5-3-15-document-center.ts'), 'utf8'); +const f4Lifecycle = readFileSync(resolve(process.cwd(), 'src/database/migrations/1790000600000-f4-align-document-lifecycle-constraints.ts'), 'utf8'); -test('D5.3.15 exposes global acts and pending report emission', () => { +test('D5.3.15/F4 exposes global acts and report emission after an Act is SEALED', () => { assert.match(actService, /async listGlobal\(query: ListInspectionActsQueryDto\)/); assert.match(reportService, /async listPending\(query: ListInspectionReportsQueryDto\)/); - assert.match(reportService, /act\.status = 'CLOSED'/); + assert.match(reportService, /act\.status = 'SEALED'/); assert.match(reportService, /report\.id IS NULL/); + assert.match(reportService, /act\.closure_sha256 IS NOT NULL/); }); -test('D5.3.15 freezes a report from the final closed act snapshot', () => { +test('D5.3.15/F4 freezes the immutable sealed Act snapshot as the INF source', () => { assert.match(reportService, /assertMobileInspector\(principal\)/); assert.match(reportService, /final_snapshot AS "finalSnapshot"/); - assert.match(reportService, /DocumentSequenceType\.REPORT/); + assert.match(reportService, /const reportNumber = act\.actNumber/); + assert.match(reportService, /act\.code\.replace\(\/\^ACT-\/, 'INF-'\)/); assert.match(reportService, /sha256CanonicalJson\(frozenSnapshot\)/); + assert.match(reportService, /InspectionReportStatus\.WORKING/); assert.match(reportService, /INSPECTION_REPORT_GENERATED/); }); -test('D5.3.15 migration creates independent report numbering and protected frozen content', () => { +test('D5.3.15 frozen source guarantees survive while F4 expands report workflow states', () => { assert.match(migration, /CREATE TABLE inspection_reports/); assert.match(migration, /UNIQUE \(report_year, report_number\)/); assert.match(migration, /dhv2_guard_inspection_report_frozen/); assert.match(migration, /inspection_reports\.read/); assert.match(migration, /inspection_reports\.generate/); + assert.match(f4Lifecycle, /status IN \('WORKING','OFFICIALIZED','FROZEN','CANCELLED'\)/); }); diff --git a/api-v3/test/unit/phase-d5-3-16-verification-planning.test.ts b/api-v3/test/unit/phase-d5-3-16-verification-planning.test.ts index 2e2bcda..c80118c 100644 --- a/api-v3/test/unit/phase-d5-3-16-verification-planning.test.ts +++ b/api-v3/test/unit/phase-d5-3-16-verification-planning.test.ts @@ -7,10 +7,11 @@ const service = readFileSync(resolve(process.cwd(), 'src/inspection-verification const migration = readFileSync(resolve(process.cwd(), 'src/database/migrations/1788112800000-phase-d5-3-16-verification-planning.ts'), 'utf8'); const visitService = readFileSync(resolve(process.cwd(), 'src/inspection-visits/inspection-visits.service.ts'), 'utf8'); -test('D5.3.16 builds the verification queue from the second finding deadline', () => { - assert.match(service, /company_response_received_on IS NOT NULL/); +test('D5.3.16/F4 builds the verification queue from the next control date and unresolved latest result', () => { + assert.doesNotMatch(service, /company_response_received_on IS NOT NULL/); assert.match(service, /finding\.next_control_on IS NOT NULL/); - assert.match(service, /verification_visit\.id IS NULL/); + assert.match(service, /latest_verification\.outcome/); + assert.match(service, /<> 'RESOLVED'/); assert.match(service, /overdueUnplanned/); }); diff --git a/api-v3/test/unit/phase-d5-3-17-verification-execution.test.ts b/api-v3/test/unit/phase-d5-3-17-verification-execution.test.ts index 5c5919a..8d00165 100644 --- a/api-v3/test/unit/phase-d5-3-17-verification-execution.test.ts +++ b/api-v3/test/unit/phase-d5-3-17-verification-execution.test.ts @@ -6,6 +6,7 @@ import { resolve } from 'node:path'; const verificationService = readFileSync(resolve(process.cwd(), 'src/inspection-verifications/inspection-verifications.service.ts'), 'utf8'); const evidenceService = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-evidence.service.ts'), 'utf8'); const closingService = readFileSync(resolve(process.cwd(), 'src/inspection-closing/inspection-closing.service.ts'), 'utf8'); +const lifecycleService = readFileSync(resolve(process.cwd(), 'src/inspection-visits/inspection-visit-lifecycle.service.ts'), 'utf8'); const findingsService = readFileSync(resolve(process.cwd(), 'src/inspection-findings/inspection-findings.service.ts'), 'utf8'); const migration = readFileSync(resolve(process.cwd(), 'src/database/migrations/1788199200000-phase-d5-3-17-verification-execution.ts'), 'utf8'); @@ -33,10 +34,12 @@ test('D5.3.17 links verification photos to the verification visit', () => { assert.match(evidenceService, /INSPECTION_VERIFICATION_PHOTO_REQUIRED/); }); -test('D5.3.17 closes a verification act only after every linked finding has a result', () => { - assert.match(closingService, /INSPECTION_VERIFICATION_RESULTS_REQUIRED/); - assert.match(closingService, /verificationResults/); - assert.match(closingService, /DH-ACT-CLOSURE-V2/); +test('F4 requires every linked verification result before closing the inspection, not before locking one act', () => { + assert.doesNotMatch(closingService, /assertVerificationResultsComplete/); + assert.match(lifecycleService, /assertVerificationResultsComplete\(manager, id\)/); + assert.match(lifecycleService, /INSPECTION_VERIFICATION_RESULTS_REQUIRED/); + assert.match(lifecycleService, /FROM inspection_finding_verification_visits/); + assert.match(lifecycleService, /WHERE visit_id=\$1/); }); test('D5.3.17 moves verified conforming findings into an office ready-to-close queue', () => { diff --git a/api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts b/api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts index 9a9b157..61a5fb1 100644 --- a/api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts +++ b/api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts @@ -11,19 +11,20 @@ const controller = read('src/inspection-reports/inspection-reports.controller.ts const builder = read('src/inspection-reports/inspection-report-word-builder.ts'); const wordService = read('src/inspection-reports/inspection-report-word.service.ts'); -test('D5.3.20 freezes one report automatically in the same act-closing transaction', () => { +test('D5.3.20/F4 freezes one report from each sealed act and generates its Word artifact', () => { assert.match(closing, /await this\.reports\.ensureFrozenReport\(manager, actId, principal, request\)/); assert.match(closing, /await this\.reports\.ensureWordForAct\(actId\)/); assert.match(reports, /async ensureFrozenReport\(/); assert.match(reports, /SELECT id FROM inspection_reports WHERE act_id/); + assert.match(reports, /act\.status = 'SEALED'/); }); -test('D5.3.20 stores an integrity-checked Word artifact without changing the frozen report', () => { +test('D5.3.20 stores an integrity-checked Word artifact without changing the frozen source snapshot', () => { assert.match(migration, /ADD COLUMN word_status varchar\(24\)/); assert.match(migration, /word_sha256 char\(64\)/); assert.match(migration, /word_generated_at timestamptz/); assert.match(wordService, /createHash\('sha256'\)/); - assert.match(wordService, /word_status = 'READY'/); + assert.match(wordService, /word_status\s*=\s*'READY'/); assert.match(controller, /@Get\(':id\/word'\)/); }); @@ -35,7 +36,8 @@ test('D5.3.20 builds OOXML Word from the frozen act snapshot without external do assert.doesNotMatch(builder, /nodemailer|libreoffice|pandoc/i); }); -test('D5.3.20 keeps frozen report generation act-scoped and leaves email delivery for a configured phase', () => { - assert.match(closing, /findingsRemainOpen: true/); +test('F4 keeps report generation act-scoped while findings and inspection lifecycle remain independent', () => { + assert.match(closing, /findingsRemainIndependent: true/); + assert.match(closing, /inspectionRemainsIndependent: true/); assert.doesNotMatch(migration, /smtp|email_outbox|mail_delivery/i); }); diff --git a/api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts b/api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts index 43bbdac..f5fc184 100644 --- a/api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts +++ b/api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts @@ -1,7 +1,64 @@ -import assert from 'node:assert/strict'; import test from 'node:test'; import {readFileSync} from 'node:fs'; import {resolve} from 'node:path'; -const read=(path:string)=>readFileSync(resolve(process.cwd(),path),'utf8'); const migration=read('src/database/migrations/1788544800000-phase-d5-3-21-document-delivery.ts'); const delivery=read('src/inspection-reports/inspection-document-delivery.service.ts'); const smtp=read('src/inspection-reports/smtp-delivery.service.ts'); const pdf=read('src/inspection-reports/inspection-act-pdf-builder.ts'); const controller=read('src/inspection-reports/document-delivery.controller.ts'); const reports=read('src/inspection-reports/inspection-reports.service.ts'); -test('D5.3.21 stores institutional and company recipients without hardcoding addresses',()=>{assert.match(migration,/institutional_delivery_settings/);assert.match(migration,/notification_email varchar\(320\)/);assert.match(delivery,/office_email/);assert.match(delivery,/director_email/);assert.doesNotMatch(delivery,/@gmail\.com|@hotmail\.com|@mendoza\.gov/i);}); -test('D5.3.21 creates an immutable act PDF and separates its delivery from the report Word',()=>{assert.match(migration,/inspection_act_pdf_artifacts/);assert.match(migration,/ACT_PDF/);assert.match(migration,/REPORT_WORD/);assert.match(pdf,/%PDF-1\.4/);assert.match(delivery,/this\.pdf\.ensure/);assert.match(delivery,/this\.word\.ensure/);}); -test('D5.3.21 keeps an auditable retryable outbox with explicit waiting states',()=>{assert.match(migration,/WAITING_RECIPIENT/);assert.match(migration,/WAITING_TRANSPORT/);assert.match(migration,/WAITING_ARTIFACT/);assert.match(delivery,/export interface DeliveryRow/);assert.match(controller,/Promise/);assert.match(controller,/outbox\/:id\/retry/);assert.match(controller,/retry-pending/);}); -test('D5.3.21 only attempts SMTP when transport and recipients are configured',()=>{assert.match(smtp,/SMTP_HOST/);assert.match(smtp,/MAIL_FROM/);assert.match(smtp,/STARTTLS/);assert.match(delivery,/this\.smtp\.configured\(\)/);assert.match(delivery,/WAITING_TRANSPORT/);}); -test('D5.3.21 delivery stays act-scoped after F1.1 enables multiple acts',()=>{assert.match(reports,/await this\.delivery\.dispatchForAct\(actId\)/); const acts=read('src/inspection-acts/inspection-acts.service.ts');assert.match(acts,/assertVisitHasNoDraftAct/);assert.match(acts,/INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/);}); +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const migration = read('src/database/migrations/1788544800000-phase-d5-3-21-document-delivery.ts'); +const f4DirectorRemoval = read('src/database/migrations/1790000400000-f4-remove-director-report-legacy.ts'); +const delivery = read('src/inspection-reports/inspection-document-delivery.service.ts'); +const smtp = read('src/inspection-reports/smtp-delivery.service.ts'); +const pdf = read('src/inspection-reports/inspection-act-pdf-builder.ts'); +const controller = read('src/inspection-reports/document-delivery.controller.ts'); +const reports = read('src/inspection-reports/inspection-reports.service.ts'); + +test('D5.3.21/F4 stores configurable recipients without hardcoding addresses and retires Director delivery', () => { + assert.match(migration, /institutional_delivery_settings/); + assert.match(migration, /notification_email varchar\(320\)/); + assert.match(delivery, /office_email/); + assert.match(delivery, /'COMPANY' \| 'OFFICE' \| 'INSPECTOR'/); + assert.match(delivery, /recipient_kind<>'DIRECTOR'/); + assert.match(f4DirectorRemoval, /DELETE FROM inspection_document_deliveries/); + assert.match(f4DirectorRemoval, /recipient_kind = 'DIRECTOR'/); + assert.doesNotMatch(delivery, /@gmail\.com|@hotmail\.com|@mendoza\.gov/i); +}); + +test('D5.3.21 creates an immutable act PDF and separates its delivery from the editable INF Word', () => { + assert.match(migration, /inspection_act_pdf_artifacts/); + assert.match(migration, /ACT_PDF/); + assert.match(migration, /REPORT_WORD/); + assert.match(pdf, /%PDF-1\.4/); + assert.match(delivery, /this\.pdf\.ensure/); + assert.match(delivery, /this\.word\.ensure/); + assert.match(delivery, /INF editable/); +}); + +test('D5.3.21 keeps an auditable retryable outbox with explicit waiting states', () => { + assert.match(migration, /WAITING_RECIPIENT/); + assert.match(migration, /WAITING_TRANSPORT/); + assert.match(migration, /WAITING_ARTIFACT/); + assert.match(delivery, /export interface DeliveryRow/); + assert.match(controller, /Promise/); + assert.match(controller, /outbox\/:id\/retry/); + assert.match(controller, /retry-pending/); +}); + +test('F4 SMTP can be administered in database while retaining environment fallback', () => { + assert.match(smtp, /system_smtp_settings/); + assert.match(smtp, /async saveSettings/); + assert.match(smtp, /source:'DATABASE'/); + assert.match(smtp, /resolveEnvironment/); + assert.match(smtp, /SMTP_HOST/); + assert.match(smtp, /MAIL_FROM/); + assert.match(smtp, /STARTTLS/); + assert.match(smtp, /SMTP_SETTINGS_MASTER_KEY/); + assert.match(delivery, /await this\.smtp\.configured\(\)/); + assert.match(delivery, /WAITING_TRANSPORT/); +}); + +test('D5.3.21/F4 delivery stays act-scoped after multiple acts per inspection', () => { + assert.match(reports, /await this\.delivery\.dispatchForAct\(actId\)/); + const acts = read('src/inspection-acts/inspection-acts.service.ts'); + assert.match(acts, /assertVisitHasNoDraftAct/); + assert.match(acts, /INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/); +}); diff --git a/api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts b/api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts index 4b95d1c..9893110 100644 --- a/api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts +++ b/api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts @@ -1,56 +1,84 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import test from 'node:test'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); -const migration = read('src/database/migrations/1788631200000-phase-d5-3-22-director-report-review.ts'); -const review = read('src/inspection-reports/inspection-report-review.service.ts'); -const controller = read('src/inspection-reports/inspection-report-review.controller.ts'); +const root = process.cwd(); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + +const legacyMigration = read('src/database/migrations/1788631200000-phase-d5-3-22-director-report-review.ts'); +const f4Removal = read('src/database/migrations/1790000400000-f4-remove-director-report-legacy.ts'); +const workflow = read('src/inspection-reports/inspection-report-workflow.service.ts'); const word = read('src/inspection-reports/inspection-report-word.service.ts'); +const officializeDto = read('src/inspection-reports/dto/officialize-inspection-report.dto.ts'); +const delivery = read('src/inspection-reports/inspection-document-delivery.service.ts'); const acts = read('src/inspection-acts/inspection-acts.service.ts'); const findings = read('src/inspection-findings/inspection-findings.service.ts'); -test('D5.3.22 keeps report corrections as immutable numbered Word revisions', () => { - assert.match(migration, /inspection_report_revisions/); - assert.match(migration, /UNIQUE \(report_id, revision_number\)/); - assert.match(migration, /source IN \('AUTO','DIRECTOR_UPLOAD'\)/); +test('D5.3.22 revision ledger is retained as historical INF versioning', () => { + assert.match(legacyMigration, /inspection_report_revisions/); + assert.match(legacyMigration, /UNIQUE \(report_id, revision_number\)/); + assert.match(legacyMigration, /current_revision_number/); + assert.match(word, /inspection_report_revisions/); + assert.match(word, /current_revision_number/); assert.match(word, /'AUTO'/); - assert.match(review, /'DIRECTOR_UPLOAD'/); - assert.match(review, /current_revision_number = \$2/); }); -test('D5.3.22 reserves revision approval and final signature to the Director role', () => { - assert.match(migration, /inspection_reports\.revise/); - assert.match(migration, /inspection_reports\.review/); - assert.match(migration, /inspection_reports\.sign_final/); - assert.match(migration, /\('director', 'inspection_reports\.sign_final'\)/); - assert.doesNotMatch(migration, /\('admin', 'inspection_reports\.sign_final'\)/); - assert.match(review, /role\.code = 'director'/); - assert.match(controller, /@RequirePermissions\('inspection_reports\.sign_final'\)/); +test('F4 explicitly retires Director review approval and final-signature state', () => { + assert.match(f4Removal, /DELETE FROM inspection_document_deliveries/); + assert.match(f4Removal, /recipient_kind = 'DIRECTOR'/); + assert.match(f4Removal, /DELETE FROM role_permissions/); + assert.match(f4Removal, /DELETE FROM permissions/); + assert.match(f4Removal, /inspection_reports\.revise/); + assert.match(f4Removal, /inspection_reports\.review/); + assert.match(f4Removal, /inspection_reports\.sign_final/); + assert.match(f4Removal, /DROP COLUMN IF EXISTS approved_revision_id/); + assert.match(f4Removal, /DROP COLUMN IF EXISTS approved_by/); + assert.match(f4Removal, /DROP COLUMN IF EXISTS approved_at/); + assert.match(f4Removal, /DROP COLUMN IF EXISTS review_note/); + assert.match(f4Removal, /DROP COLUMN IF EXISTS signed_at/); + assert.match(f4Removal, /DROP COLUMN IF EXISTS review_status/); + assert.doesNotMatch(f4Removal, /DROP COLUMN IF EXISTS current_revision_number/); }); -test('D5.3.22 approves exactly the latest revision before allowing a final signature', () => { - assert.match(review, /revision_number = \$2/); - assert.match(review, /approved_revision_id = \$2/); - assert.match(review, /review_status = 'APPROVED'/); - assert.match(review, /INSPECTION_REPORT_NOT_APPROVED/); - assert.match(review, /review_status = 'SIGNED'/); +test('F4 removes active Director endpoints but preserves historical signature evidence', () => { + assert.equal(fs.existsSync(path.join(root, 'src/inspection-reports/inspection-report-review.service.ts')), false); + assert.equal(fs.existsSync(path.join(root, 'src/inspection-reports/inspection-report-review.controller.ts')), false); + assert.doesNotMatch(f4Removal, /DROP TABLE IF EXISTS inspection_report_signatures/); + assert.match(f4Removal, /inspection_report_signatures también se conserva como archivo/); }); -test('D5.3.22 binds the final Director signature to report and revision hashes', () => { - assert.match(migration, /inspection_report_signatures/); - assert.match(review, /DH-INSPECTION-REPORT-SIGNATURE-V1/); - assert.match(review, /reportFrozenSha256/); - assert.match(review, /revisionSha256/); - assert.match(review, /sha256CanonicalJson\(payload\)/); - assert.match(review, /INSPECTION_REPORT_ALREADY_SIGNED/); +test('F4 replaces Director approval with editable INF then immutable GEDO IF officialization', () => { + assert.match(workflow, /InspectionReportStatus\.WORKING/); + assert.match(workflow, /status='OFFICIALIZED'/); + assert.match(workflow, /gedo_if_identifier/); + assert.match(workflow, /gedo_officialized_at/); + assert.match(workflow, /INSPECTION_REPORT_ALREADY_OFFICIALIZED/); + assert.match(officializeDto, /gedoIfIdentifier!/); + assert.match(officializeDto, /gedoOfficializedAt!/); + assert.match(officializeDto, /@MinLength\(2\)/); + assert.match(officializeDto, /@IsISO8601/); }); -test('D5.3.22 changes only the report review layer and preserves inspection immutability', () => { +test('F4 delivery has no Director recipient while preserving company office and inspector copies', () => { + assert.match(delivery, /'COMPANY' \| 'OFFICE' \| 'INSPECTOR'/); + assert.match(delivery, /recipient_kind<>'DIRECTOR'/); + assert.doesNotMatch(delivery, /recipientKind:'DIRECTOR'/); +}); + +test('F4 Director-retirement rollback reconstructs schema and RBAC contract without inventing deleted data', () => { + assert.match(f4Removal, /ADD COLUMN IF NOT EXISTS review_status/); + assert.match(f4Removal, /chk_inspection_reports_review_status/); + assert.match(f4Removal, /fk_inspection_reports_approved_by/); + assert.match(f4Removal, /fk_inspection_reports_approved_revision/); + assert.match(f4Removal, /INSERT INTO permissions/); + assert.match(f4Removal, /INSERT INTO role_permissions/); +}); + +test('F4 document workflow changes do not reopen acts or mutate finding history', () => { assert.match(acts, /assertVisitHasNoDraftAct/); assert.match(acts, /INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/); assert.match(findings, /INSPECTION_FINDING_RESPONSE_IMMUTABLE/); - assert.doesNotMatch(migration, /ALTER TABLE inspection_acts/); - assert.doesNotMatch(migration, /ALTER TABLE inspection_findings/); + assert.doesNotMatch(f4Removal, /ALTER TABLE inspection_acts/); + assert.doesNotMatch(f4Removal, /ALTER TABLE inspection_findings/); }); diff --git a/api-v3/test/unit/phase-d5-4-contextual-finding-catalog.test.ts b/api-v3/test/unit/phase-d5-4-contextual-finding-catalog.test.ts index 0369f07..2a0c907 100644 --- a/api-v3/test/unit/phase-d5-4-contextual-finding-catalog.test.ts +++ b/api-v3/test/unit/phase-d5-4-contextual-finding-catalog.test.ts @@ -40,7 +40,6 @@ test('D5.4 stores suggested and actual severity independently on the 1 to 10 sca assert.match(createFindingDto, /severity\?: number/); assert.match(findingsService, /suggestedSeverity: catalog\?\.suggestedSeverity/); assert.match(findingsService, /severity: dto\.severity \?\? catalog\?\.suggestedSeverity/); - assert.match(closingService, /finding\.suggested_severity AS "suggestedSeverity"/); assert.match(closingService, /finding\.severity/); assert.match(wordBuilder, /'Gravedad'/); }); diff --git a/api-v3/test/unit/phase-d5-5-web-planning-checklist.test.ts b/api-v3/test/unit/phase-d5-5-web-planning-checklist.test.ts index 90d58ea..89b011c 100644 --- a/api-v3/test/unit/phase-d5-5-web-planning-checklist.test.ts +++ b/api-v3/test/unit/phase-d5-5-web-planning-checklist.test.ts @@ -45,22 +45,22 @@ test('D5.5 treats manual additions as preventive and requires an auditable reaso assert.match(dto, /@MinLength\(10\)/); }); -test('D5.5 keeps verification visits inside the same Area Operator planning contract and start remains APK-only', () => { +test('D5.5/F4 keeps verification visits inside the Area Operator contract and start remains APK-only', () => { const verification = read('src/inspection-verifications/inspection-verifications.service.ts'); - const visits = read('src/inspection-visits/inspection-visits.service.ts'); + const lifecycle = read('src/inspection-visits/inspection-visit-lifecycle.service.ts'); assert.match(verification, /operationalAreaId: areaId/); assert.match(verification, /operatorCompanyId: companyId/); assert.match(verification, /planning_source = 'VERIFICATION'/); - assert.match(visits, /assertMobileInspector\(principal\)/); - assert.match(visits, /INSPECTION_CHECKLIST_REQUIRED/); + assert.match(lifecycle, /assertMobileInspector\(principal\)/); + assert.match(lifecycle, /INSPECTION_CHECKLIST_REQUIRED/); }); -test('D5.5 confirmation requires a reviewed current checklist and keeps visit start under mobile inspector policy', () => { - const visits = read('src/inspection-visits/inspection-visits.service.ts'); +test('D5.5/F4 requires a current checklist before planning or start and keeps start under mobile policy', () => { + const lifecycle = read('src/inspection-visits/inspection-visit-lifecycle.service.ts'); const policy = read('src/inspection-operations/mobile-inspector-policy.ts'); - assert.match(visits, /INSPECTION_CHECKLIST_REQUIRED/); - assert.match(visits, /checklistGeneratedAt/); - assert.match(visits, /assertMobileInspector\(principal\)/); + assert.match(lifecycle, /INSPECTION_CHECKLIST_REQUIRED/); + assert.match(lifecycle, /checklistGeneratedAt/); + assert.match(lifecycle, /assertMobileInspector\(principal\)/); assert.match(policy, /transport !== 'bearer'/); assert.match(policy, /includes\('inspector'\)/); }); diff --git a/api-v3/test/unit/phase-d5-6-4-quick-inspection-planning.test.ts b/api-v3/test/unit/phase-d5-6-4-quick-inspection-planning.test.ts index c7f2698..52d9f6b 100644 --- a/api-v3/test/unit/phase-d5-6-4-quick-inspection-planning.test.ts +++ b/api-v3/test/unit/phase-d5-6-4-quick-inspection-planning.test.ts @@ -6,14 +6,18 @@ import test from 'node:test'; const root = process.cwd(); const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); -test('D5.6.4 generates inspection codes server-side with an annual transactional sequence', () => { +test('D5.6.4/F4 generates inspection codes server-side with an annual transactional sequence', () => { const generator = read('src/inspection-visits/inspection-visit-code.ts'); - assert.match(generator, /INS-\$\{year\}-\$\{String\(sequence\)\.padStart\(6, '0'\)\}/); assert.match(generator, /pg_advisory_xact_lock/); assert.match(generator, /America\/Argentina\/Mendoza/); + assert.match(generator, /padStart\(5, '0'\)/); + assert.match(generator, /return `INSP-/); + assert.match(generator, /dateRow\.day/); + assert.match(generator, /dateRow\.month/); + assert.match(generator, /dateRow\.short_year/); }); -test('D5.6.4 creates one inspection from Area Operator start and lead inspector without manual title or end date', () => { +test('D5.6.4/F4 creates one inspection from Area Operator, start and lead inspector without legacy title or end date', () => { const dto = read('src/inspection-visits/dto/create-inspection-visit.dto.ts'); const service = read('src/inspection-visits/inspection-visits.service.ts'); assert.doesNotMatch(dto, /code!/); @@ -23,8 +27,9 @@ test('D5.6.4 creates one inspection from Area Operator start and lead inspector assert.match(dto, /operatorCompanyId!/); assert.match(dto, /plannedStartAt!/); assert.match(dto, /leadInspectorUserId!/); - assert.match(service, /title: code/); - assert.match(service, /plannedEndAt: null/); + assert.match(service, /const code = await nextInspectionVisitCode\(manager, plannedStartAt\)/); + assert.doesNotMatch(service, /title: code/); + assert.doesNotMatch(service, /plannedEndAt/); }); test('D5.6.4 assigns the lead inspector and generates the checklist in the same creation transaction', () => { @@ -34,10 +39,10 @@ test('D5.6.4 assigns the lead inspector and generates the checklist in the same assert.match(service, /await this\.generateChecklistInternal\(manager, visit, principal\.userId\)/); }); -test('D5.6.4 uses the same inspection numbering and start-only planning for verification visits', () => { +test('D5.6.4/F4 uses the same inspection numbering and start-only planning for verification visits', () => { const dto = read('src/inspection-verifications/dto/plan-verification-visit.dto.ts'); const service = read('src/inspection-verifications/inspection-verifications.service.ts'); assert.doesNotMatch(dto, /plannedEndAt/); assert.match(service, /nextInspectionVisitCode\(manager, plannedStartAt\)/); - assert.match(service, /plannedEndAt: null/); + assert.doesNotMatch(service, /plannedEndAt/); }); diff --git a/api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts b/api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts index 361e6d5..424f7f2 100644 --- a/api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts +++ b/api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts @@ -6,13 +6,16 @@ import test from 'node:test'; const root = process.cwd(); const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); -test('D5.6 immutable act closure remains intact after the multi-act transition', () => { +test('D5.6 immutable act closure survives F4 as LOCKED then SEALED', () => { const acts = read('src/inspection-acts/inspection-acts.service.ts'); const closing = read('src/inspection-closing/inspection-closing.service.ts'); assert.match(acts, /INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/); - assert.match(closing, /final_snapshot = \$2/); - assert.match(closing, /final_sha256 = \$3/); - assert.match(closing, /InspectionActVersionEvent\.CLOSED/); + assert.match(closing, /status='LOCKED'/); + assert.match(closing, /final_snapshot=\$2/); + assert.match(closing, /final_sha256=\$3/); + assert.match(closing, /status='SEALED'/); + assert.match(closing, /InspectionActVersionEvent\.SEALED/); + assert.match(closing, /INSPECTION_ACT_LOCK_IS_IMMUTABLE/); }); test('D5.6 preserves findings responses evidence and verification history as non destructive follow-up', () => { @@ -36,17 +39,20 @@ test('D5.6 keeps Inventory temporal context and contextual finding applicability assert.match(findings, /finding_catalog_asset_overrides/); }); -test('D5.6 preserves reviewed web planning and APK only visit start', () => { +test('F4 preserves reviewed web planning and APK-only inspection execution', () => { const visits = read('src/inspection-visits/inspection-visits.service.ts'); + const lifecycle = read('src/inspection-visits/inspection-visit-lifecycle.service.ts'); const policy = read('src/inspection-operations/mobile-inspector-policy.ts'); assert.match(visits, /INSPECTION_CHECKLIST_REQUIRED/); assert.match(visits, /inspection_visit_checklist_items/); - assert.match(visits, /assertMobileInspector\(principal\)/); + assert.match(lifecycle, /async start\(/); + assert.match(lifecycle, /assertMobileInspector\(principal\)/); + assert.match(lifecycle, /async close\(/); assert.match(policy, /transport !== 'bearer'/); assert.match(policy, /includes\('inspector'\)/); }); -test('D5.6 closes the act then freezes the report and produces its Word artifact', () => { +test('D5.6 report generation remains attached to sealing each act', () => { const closing = read('src/inspection-closing/inspection-closing.service.ts'); const word = read('src/inspection-reports/inspection-report-word.service.ts'); assert.match(closing, /ensureFrozenReport\(manager, actId, principal, request\)/); @@ -55,27 +61,34 @@ test('D5.6 closes the act then freezes the report and produces its Word artifact assert.match(word, /inspection_report_revisions/); }); -test('D5.6 leaves report revision approval and final signature exclusively under Director validation', () => { - const review = read('src/inspection-reports/inspection-report-review.service.ts'); - assert.match(review, /await this\.assertDirector\(principal\.userId/); - assert.match(review, /review_status = 'APPROVED'/); - assert.match(review, /INSPECTION_REPORT_NOT_APPROVED/); - assert.match(review, /inspection_report_signatures/); - assert.match(review, /Director de Hidrocarburos/); +test('F4 replaces Director approval with editable INF then immutable GEDO IF officialization', () => { + const removedDirectorReview = path.join(root, 'src/inspection-reports/inspection-report-review.service.ts'); + const workflow = read('src/inspection-reports/inspection-report-workflow.service.ts'); + assert.equal(fs.existsSync(removedDirectorReview), false); + assert.match(workflow, /InspectionReportStatus\.WORKING/); + assert.match(workflow, /status='OFFICIALIZED'/); + assert.match(workflow, /gedo_if_identifier/); + assert.match(workflow, /INSPECTION_REPORT_ALREADY_OFFICIALIZED/); + assert.doesNotMatch(workflow, /Director de Hidrocarburos/); }); -test('D5.6 preserves auditable document delivery while allowing SMTP to remain pending', () => { +test('F4 keeps auditable delivery, removes Director recipient and makes SMTP administrable', () => { const delivery = read('src/inspection-reports/inspection-document-delivery.service.ts'); const smtp = read('src/inspection-reports/smtp-delivery.service.ts'); - assert.match(delivery, /WAITING_TRANSPORT/); assert.match(delivery, /ACT_PDF/); assert.match(delivery, /REPORT_WORD/); - assert.match(smtp, /SMTP_HOST/); - assert.match(smtp, /MAIL_FROM/); + assert.match(delivery, /recipient_kind<>'DIRECTOR'/); + assert.match(delivery, /'COMPANY' \| 'OFFICE' \| 'INSPECTOR'/); + assert.match(smtp, /system_smtp_settings/); + assert.match(smtp, /async saveSettings/); + assert.match(smtp, /source:'DATABASE'/); + assert.match(smtp, /resolveEnvironment/); + assert.match(smtp, /SMTP_SETTINGS_MASTER_KEY/); }); -test('D5.6 remains a historical closure baseline while Phase F introduces an explicit new migration', () => { +test('D5.6 remains a historical closure baseline while Phase F introduces explicit migrations', () => { const migrations = fs.readdirSync(path.join(root, 'src/database/migrations')); assert.equal(migrations.some((name) => /phase-d5-6/i.test(name)), false); assert.equal(migrations.some((name) => /phase-f1-1-multi-act-inspections/i.test(name)), true); + assert.equal(migrations.some((name) => /f4-/i.test(name)), true); }); diff --git a/api-v3/test/unit/phase-f1-1-multi-act-operating-model.test.ts b/api-v3/test/unit/phase-f1-1-multi-act-operating-model.test.ts index f563b06..56bfb93 100644 --- a/api-v3/test/unit/phase-f1-1-multi-act-operating-model.test.ts +++ b/api-v3/test/unit/phase-f1-1-multi-act-operating-model.test.ts @@ -6,7 +6,7 @@ import test from 'node:test'; const root = process.cwd(); const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); -test('F1.1 migration allows many acts per inspection while keeping only one draft at a time', () => { +test('F1.1/F4 allows many acts per inspection while keeping only one draft at a time', () => { const migration = read('src/database/migrations/1789495200000-phase-f1-1-multi-act-inspections.ts'); assert.match(migration, /DROP CONSTRAINT IF EXISTS uq_inspection_acts_visit/); assert.match(migration, /CREATE UNIQUE INDEX uq_inspection_acts_one_draft_per_visit/); @@ -15,7 +15,7 @@ test('F1.1 migration allows many acts per inspection while keeping only one draf assert.match(migration, /CREATE INDEX idx_inspection_reports_visit_id/); }); -test('F1.1 preserves exactly one frozen report per act instead of per inspection', () => { +test('F1.1/F4 preserves exactly one frozen report per act instead of per inspection', () => { const reportEntity = read('src/database/entities/inspection-report.entity.ts'); const reports = read('src/inspection-reports/inspection-reports.service.ts'); assert.match(reportEntity, /@Index\('uq_inspection_reports_act', \['actId'\], \{ unique: true \}\)/); @@ -24,43 +24,40 @@ test('F1.1 preserves exactly one frozen report per act instead of per inspection assert.match(reports, /WHERE act_id = \$1/); }); -test('F1.1 closes an act without closing its parent inspection', () => { +test('F4 seals one act without closing its parent inspection', () => { const closing = read('src/inspection-closing/inspection-closing.service.ts'); const closeBody = closing.slice(closing.indexOf(' async close('), closing.indexOf(' async signatureContent(')); assert.match(closeBody, /UPDATE inspection_acts/); + assert.match(closeBody, /status='SEALED'/); assert.doesNotMatch(closeBody, /UPDATE inspection_visits/); - assert.match(closeBody, /visitRemainsIndependent: true/); + assert.match(closeBody, /inspectionRemainsIndependent: true/); assert.match(closeBody, /ensureFrozenReport\(manager, actId, principal, request\)/); }); -test('F1.1 exposes an APK-only explicit inspection close and permits pending company signature', () => { - const controller = read('src/inspection-visits/inspection-visits.controller.ts'); - const visits = read('src/inspection-visits/inspection-visits.service.ts'); - assert.match(controller, /@Post\(':id\/close'\)/); - assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/); - assert.match(visits, /async close\(/); - assert.match(visits, /assertMobileInspector\(principal\)/); - assert.match(visits, /INSPECTION_VISIT_DRAFT_ACTS_PENDING/); - assert.match(visits, /INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING/); - assert.match(visits, /pendingCompanySignatures/); - assert.match(visits, /actsMayCompleteCompanySignatureLater: true/); +test('F4 closes an inspection only after every non-cancelled act is sealed', () => { + const lifecycle = read('src/inspection-visits/inspection-visit-lifecycle.service.ts'); + const closeBody = lifecycle.slice(lifecycle.indexOf(' async close('), lifecycle.indexOf(' async cancel(')); + assert.match(closeBody, /status<>'CANCELLED'/); + assert.match(closeBody, /acts\.filter\(\(act\) => act\.status !== 'SEALED'\)/); + assert.match(closeBody, /INSPECTION_REQUIRES_ACT/); + assert.match(closeBody, /INSPECTION_ACTS_NOT_SEALED/); + assert.match(closeBody, /assertVerificationResultsComplete\(manager, id\)/); + assert.match(closeBody, /visit\.status = InspectionVisitStatus\.CLOSED/); }); -test('F1.1 lets the company sign after the inspection closes and records conformity or dissent', () => { +test('F4 requires exactly one terminal company outcome before an act can be sealed', () => { const closing = read('src/inspection-closing/inspection-closing.service.ts'); - const dto = read('src/inspection-closing/dto/create-company-signature.dto.ts'); - const signatureEntity = read('src/database/entities/inspection-act-signature.entity.ts'); - assert.match(closing, /InspectionVisitStatus\.IN_PROGRESS, InspectionVisitStatus\.CLOSED/); + const closeBody = closing.slice(closing.indexOf(' async close('), closing.indexOf(' async signatureContent(')); + assert.match(closeBody, /companyOutcomes\.length !== 1/); + assert.match(closeBody, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/); + assert.match(closeBody, /ausencia documentada/); + assert.doesNotMatch(closeBody, /INSPECTION_ACT_COMPANY_MANIFESTATION_PENDING/); assert.match(closing, /InspectionCompanySignatureManifestation\.CONFORMITY/); assert.match(closing, /InspectionCompanySignatureManifestation\.DISSENT/); - assert.match(dto, /@ValidateIf/); - assert.match(dto, /@MinLength\(10\)/); - assert.match(signatureEntity, /CONFORMITY = 'CONFORMITY'/); - assert.match(signatureEntity, /DISSENT = 'DISSENT'/); - assert.match(signatureEntity, /companyStatement/); + assert.match(closing, /InspectionActSignatureStatus\.REFUSED/); }); -test('F1.1 keeps field-created Inventory linked only to the current draft act', () => { +test('F1.1/F4 keeps field-created Inventory linked only to the current draft act', () => { const link = read('src/inspection-operations/field-discovery-inspection-link.service.ts'); assert.match(link, /AND status = 'DRAFT'/); assert.match(link, /if \(act\)/); diff --git a/api-v3/test/unit/survey-execution-authorization.test.ts b/api-v3/test/unit/survey-execution-authorization.test.ts index 9a7f7c4..59be86c 100644 --- a/api-v3/test/unit/survey-execution-authorization.test.ts +++ b/api-v3/test/unit/survey-execution-authorization.test.ts @@ -1,18 +1,24 @@ -import 'reflect-metadata'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import test from 'node:test'; -import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; -import { SurveyExecutionController } from '../../src/survey-execution/survey-execution.controller'; -function permissionFor(method: string): string[] { - const controller = SurveyExecutionController.prototype; - const handler = controller[method as keyof typeof controller]; - return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; -} +const root = process.cwd(); +const migration = fs.readFileSync( + path.join(root, 'src/database/migrations/1790000300000-f4-remove-survey-subsystem.ts'), + 'utf8', +); -test('survey report endpoints separate reading, capture and review', () => { - assert.deepEqual(permissionFor('get'), ['surveys.read_reports']); - assert.deepEqual(permissionFor('save'), ['surveys.capture']); - assert.deepEqual(permissionFor('submit'), ['surveys.capture']); - assert.deepEqual(permissionFor('review'), ['surveys.review']); +test('F4 removes the Survey execution controller from the active API', () => { + assert.equal(fs.existsSync(path.join(root, 'src/survey-execution/survey-execution.controller.ts')), false); + assert.equal(fs.existsSync(path.join(root, 'src/survey-execution/survey-execution.service.ts')), false); +}); + +test('F4 removes Survey execution and review permissions from active authorization', () => { + assert.match(migration, /'surveys\.execute'/); + assert.match(migration, /'surveys\.read_reports'/); + assert.match(migration, /'surveys\.capture'/); + assert.match(migration, /'surveys\.review'/); + assert.match(migration, /DELETE FROM role_permissions/); + assert.match(migration, /DELETE FROM permissions/); }); diff --git a/api-v3/test/unit/survey-execution-dto.test.ts b/api-v3/test/unit/survey-execution-dto.test.ts index fa7a655..06ec6fe 100644 --- a/api-v3/test/unit/survey-execution-dto.test.ts +++ b/api-v3/test/unit/survey-execution-dto.test.ts @@ -1,48 +1,30 @@ -import 'reflect-metadata'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import test from 'node:test'; -import { validate } from 'class-validator'; -import { ReviewSurveyReportDto } from '../../src/survey-execution/dto/review-survey-report.dto'; -import { SaveSurveyReportDto } from '../../src/survey-execution/dto/save-survey-report.dto'; -const MEDIA_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5'; +const root = process.cwd(); +const migration = fs.readFileSync( + path.join(root, 'src/database/migrations/1790000300000-f4-remove-survey-subsystem.ts'), + 'utf8', +); -test('field report DTO accepts outcome, GPS and protected media references', async () => { - const dto = Object.assign(new SaveSurveyReportDto(), { - outcome: 'CONFIRMED', - observedAt: '2026-08-14T18:00:00.000Z', - latitude: -32.8895, - longitude: -68.8458, - accuracyM: 4.25, - notes: 'Activo verificado en campo.', - mediaIds: [MEDIA_ID], - }); - assert.deepEqual(await validate(dto), []); +test('F4 removes Survey execution DTOs together with the retired endpoint surface', () => { + assert.equal(fs.existsSync(path.join(root, 'src/survey-execution/dto/save-survey-report.dto.ts')), false); + assert.equal(fs.existsSync(path.join(root, 'src/survey-execution/dto/review-survey-report.dto.ts')), false); }); -test('field report DTO rejects invalid coordinates and repeated evidence', async () => { - const dto = Object.assign(new SaveSurveyReportDto(), { - outcome: 'CONFIRMED', - observedAt: 'not-a-date', - latitude: -95, - longitude: -68.8458, - accuracyM: 4.25, - mediaIds: [MEDIA_ID, MEDIA_ID], - }); - const properties = new Set((await validate(dto)).map((error) => error.property)); - assert.equal(properties.has('observedAt'), true); - assert.equal(properties.has('latitude'), true); - assert.equal(properties.has('mediaIds'), true); +test('F4 drops Survey report tables without CASCADE so unexpected dependencies stop migration', () => { + assert.match(migration, /DROP TABLE IF EXISTS survey_target_report_versions/); + assert.match(migration, /DROP TABLE IF EXISTS survey_target_report_media/); + assert.match(migration, /DROP TABLE IF EXISTS survey_target_reports/); + assert.doesNotMatch(migration, /DROP TABLE IF EXISTS survey_target_reports CASCADE/); }); -test('review DTO only accepts explicit approval or rejection decisions', async () => { - const dto = Object.assign(new ReviewSurveyReportDto(), { - decision: 'APPROVE', - notes: null, - }); - assert.deepEqual(await validate(dto), []); - - dto.decision = 'MAYBE' as typeof dto.decision; - const properties = new Set((await validate(dto)).map((error) => error.property)); - assert.equal(properties.has('decision'), true); +test('F4 rollback can reconstruct the historical Survey report integrity rules', () => { + assert.match(migration, /CREATE TABLE IF NOT EXISTS survey_target_reports/); + assert.match(migration, /outcome IN \('CONFIRMED','CHANGES_RECORDED','NOT_LOCATED'\)/); + assert.match(migration, /latitude BETWEEN -90 AND 90/); + assert.match(migration, /longitude BETWEEN -180 AND 180/); + assert.match(migration, /CREATE TABLE IF NOT EXISTS survey_target_report_versions/); }); diff --git a/api-v3/test/unit/survey-planning-authorization.test.ts b/api-v3/test/unit/survey-planning-authorization.test.ts index 63949ae..92682a0 100644 --- a/api-v3/test/unit/survey-planning-authorization.test.ts +++ b/api-v3/test/unit/survey-planning-authorization.test.ts @@ -1,31 +1,29 @@ -import 'reflect-metadata'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import test from 'node:test'; -import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator'; -import { - SurveyCampaignsController, - SurveyCampaignTargetsController, -} from '../../src/survey-planning/survey-campaigns.controller'; -function permissionFor(controller: object, method: string): string[] { - const handler = controller[method as keyof typeof controller]; - return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[]; -} +const root = process.cwd(); +const migration = fs.readFileSync( + path.join(root, 'src/database/migrations/1790000300000-f4-remove-survey-subsystem.ts'), + 'utf8', +); -test('campaign endpoints separate reading, management and assignment', () => { - const controller = SurveyCampaignsController.prototype; - assert.deepEqual(permissionFor(controller, 'list'), ['surveys.read']); - assert.deepEqual(permissionFor(controller, 'get'), ['surveys.read']); - assert.deepEqual(permissionFor(controller, 'assignees'), ['surveys.assign']); - assert.deepEqual(permissionFor(controller, 'create'), ['surveys.manage']); - assert.deepEqual(permissionFor(controller, 'update'), ['surveys.manage']); - assert.deepEqual(permissionFor(controller, 'changeStatus'), ['surveys.manage']); - assert.deepEqual(permissionFor(controller, 'addTarget'), ['surveys.manage']); +test('F4 removes Survey planning controllers from the active API', () => { + assert.equal(fs.existsSync(path.join(root, 'src/survey-planning/survey-campaigns.controller.ts')), false); + assert.equal(fs.existsSync(path.join(root, 'src/survey-planning/survey-campaigns.service.ts')), false); }); -test('target planning, assignment and execution use distinct permissions', () => { - const controller = SurveyCampaignTargetsController.prototype; - assert.deepEqual(permissionFor(controller, 'update'), ['surveys.manage']); - assert.deepEqual(permissionFor(controller, 'assign'), ['surveys.assign']); - assert.deepEqual(permissionFor(controller, 'changeStatus'), ['surveys.execute']); +test('F4 removes Survey planning permissions and their role bindings', () => { + assert.match(migration, /'surveys\.read'/); + assert.match(migration, /'surveys\.manage'/); + assert.match(migration, /'surveys\.assign'/); + assert.match(migration, /DELETE FROM role_permissions/); + assert.match(migration, /DELETE FROM permissions/); +}); + +test('F4 rollback retains the former role-permission map only as a reversible migration path', () => { + assert.match(migration, /const rolePermissionValues/); + assert.match(migration, /WITH mapping\(role_code,permission_code\)/); + assert.match(migration, /INSERT INTO role_permissions/); }); diff --git a/api-v3/test/unit/survey-planning-dto.test.ts b/api-v3/test/unit/survey-planning-dto.test.ts index 4df9a52..7bea745 100644 --- a/api-v3/test/unit/survey-planning-dto.test.ts +++ b/api-v3/test/unit/survey-planning-dto.test.ts @@ -1,35 +1,31 @@ -import 'reflect-metadata'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import test from 'node:test'; -import { validate } from 'class-validator'; -import { AddSurveyTargetDto } from '../../src/survey-planning/dto/add-survey-target.dto'; -import { CreateSurveyCampaignDto } from '../../src/survey-planning/dto/create-survey-campaign.dto'; -const ASSET_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5'; +const root = process.cwd(); +const migration = fs.readFileSync( + path.join(root, 'src/database/migrations/1790000300000-f4-remove-survey-subsystem.ts'), + 'utf8', +); -test('campaign DTO accepts dynamic asset scope and ISO planning dates', async () => { - const dto = Object.assign(new CreateSurveyCampaignDto(), { - code: 'REL-2026-001', - name: 'Campaña operativa', - description: null, - plannedStartAt: '2026-08-15T12:00:00.000Z', - plannedEndAt: '2026-08-20T12:00:00.000Z', - scopeAssetId: ASSET_ID, - coordinatorUserId: null, - }); - assert.deepEqual(await validate(dto), []); +test('F4 removes Survey planning DTOs from the active code surface', () => { + assert.equal(fs.existsSync(path.join(root, 'src/survey-planning/dto/create-survey-campaign.dto.ts')), false); + assert.equal(fs.existsSync(path.join(root, 'src/survey-planning/dto/add-survey-target.dto.ts')), false); }); -test('target DTO references an existing Maestro asset instead of copying it', async () => { - const dto = Object.assign(new AddSurveyTargetDto(), { - assetId: ASSET_ID, - assignedUserId: null, - dueAt: null, - instructions: null, - }); - assert.deepEqual(await validate(dto), []); +test('F4 drops Survey planning tables after removing their permissions', () => { + assert.match(migration, /DELETE FROM permissions/); + assert.match(migration, /DROP TABLE IF EXISTS survey_campaign_targets/); + assert.match(migration, /DROP TABLE IF EXISTS survey_campaigns/); + assert.doesNotMatch(migration, /DROP TABLE IF EXISTS survey_campaigns CASCADE/); +}); - dto.assetId = 'not-a-uuid'; - const properties = new Set((await validate(dto)).map((error) => error.property)); - assert.equal(properties.has('assetId'), true); +test('F4 rollback can reconstruct historical Survey planning dates and asset references', () => { + assert.match(migration, /CREATE TABLE IF NOT EXISTS survey_campaigns/); + assert.match(migration, /planned_start_at timestamptz/); + assert.match(migration, /planned_end_at timestamptz/); + assert.match(migration, /scope_asset_id uuid REFERENCES assets\(id\) ON DELETE RESTRICT/); + assert.match(migration, /CREATE TABLE IF NOT EXISTS survey_campaign_targets/); + assert.match(migration, /asset_id uuid NOT NULL REFERENCES assets\(id\) ON DELETE RESTRICT/); }); diff --git a/docker-compose.yml b/docker-compose.yml index 12ab715..0864444 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,9 +49,13 @@ services: ASSET_MEDIA_ROOT: /app/storage/asset-media ASSET_IMPORT_ROOT: /app/storage/asset-media/imports INSPECTION_EVIDENCE_ROOT: /app/storage/asset-media/inspection-findings + INSPECTION_SIGNATURE_ROOT: /app/storage/asset-media/inspection-signatures + INSPECTION_REPORT_UPLOAD_ROOT: /app/storage/asset-media/inspection-reports INSPECTION_REPORT_WORD_ROOT: /app/storage/asset-media/inspection-reports-word INSPECTION_REPORT_REVISION_ROOT: /app/storage/asset-media/inspection-report-revisions INSPECTION_ACT_PDF_ROOT: /app/storage/asset-media/inspection-acts-pdf + COMPANY_SIGNATURE_PUBLIC_BASE_URL: ${COMPANY_SIGNATURE_PUBLIC_BASE_URL:-} + SMTP_SETTINGS_MASTER_KEY: ${SMTP_SETTINGS_MASTER_KEY:-} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_SECURE: ${SMTP_SECURE:-false} diff --git a/scripts/check-f3-1-web-contract.sh b/scripts/check-f3-1-web-contract.sh old mode 100644 new mode 100755 index 83acb70..f9567f8 --- a/scripts/check-f3-1-web-contract.sh +++ b/scripts/check-f3-1-web-contract.sh @@ -8,18 +8,21 @@ PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx" for file in "$LAYOUT" "$APP" "$PAGE"; do test -f "$file" || { - echo "F3.1 WEB contract: falta $file" + echo "F4 WEB contract: falta $file" exit 1 } done if grep -Fq "label: 'Relevamientos'" "$LAYOUT"; then - echo "F3.1 WEB contract: Relevamientos no debe volver al menú principal" + echo "F4 WEB contract: Relevamientos no debe volver al menú principal" + exit 1 +fi + +if grep -Fq 'path="/relevamiento/*"' "$APP"; then + echo "F4 WEB contract: Survey/Relevamiento no debe volver a exponer rutas activas" exit 1 fi -grep -Fq 'path="/relevamiento/*"' "$APP" -grep -Fq 'Navigate to="/inventarios" replace' "$APP" grep -Fq 'path="/inventarios/nuevo"' "$APP" grep -Fq 'InventoryCreatePage' "$APP" @@ -28,10 +31,10 @@ for kind in AREA YACIMIENTO INSTALACION SUBINSTALACION; do done if grep -Fq "kind: 'EQUIPO'" "$PAGE"; then - echo "F3.1 WEB contract: el alta guiada no debe restaurar EQUIPO como quinto nivel estructural" + echo "F4 WEB contract: el alta guiada no debe restaurar EQUIPO como quinto nivel estructural" exit 1 fi grep -Fq 'getInventoryFamilyFindings' "$PAGE" -echo 'F3.1 WEB contract: OK' +echo 'F4 WEB contract: OK' diff --git a/web-v2/src/app/App.tsx b/web-v2/src/app/App.tsx index 9e8e693..8b62df0 100644 --- a/web-v2/src/app/App.tsx +++ b/web-v2/src/app/App.tsx @@ -5,6 +5,7 @@ import { AppLayout } from '../layout/AppLayout'; import { OperationalContextProvider } from '../context/OperationalContext'; import { AuditPage } from '../pages/AuditPage'; import { ChangePasswordPage } from '../pages/ChangePasswordPage'; +import { CompanySignaturePage } from '../pages/CompanySignaturePage'; import { DashboardPage } from '../pages/DashboardPage'; import { LoginPage } from '../pages/LoginPage'; import { NewUserPage } from '../pages/NewUserPage'; @@ -17,10 +18,11 @@ import { AssetEditorPage } from '../pages/AssetEditorPage'; import { InventoryCreatePage } from '../pages/InventoryCreatePage'; import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage'; import { AssetTypesPage } from '../pages/AssetTypesPage'; +import { InventoryFunctionsPage } from '../pages/InventoryFunctionsPage'; import { HistoryPage } from '../pages/HistoryPage'; import { TemporalAssetsPage } from '../pages/TemporalAssetsPage'; import { InspectionVisitsPage } from '../pages/InspectionVisitsPage'; -import { InspectionVisitEditorPage } from '../pages/InspectionVisitEditorPage'; +import { InspectionVisitEditorF4Page } from '../pages/InspectionVisitEditorF4Page'; import { InspectionActEditorPage } from '../pages/InspectionActEditorPage'; import { FindingCatalogPage } from '../pages/FindingCatalogPage'; import { FindingDetailPage } from '../pages/FindingDetailPage'; @@ -40,6 +42,7 @@ const DocumentDeliveryPage = lazy(() => import('../pages/DocumentDeliveryPage'). export function App() { return Cargando módulo…}> } /> + } /> }> } /> }> @@ -55,14 +58,12 @@ export function App() { }>} /> }>} />} /> } /> - } /> - } /> }> } /> - } /> + } /> } /> - }>} /> + }>} /> }> } /> } /> @@ -81,6 +82,7 @@ export function App() { }>} /> }>} /> }>} /> + }>} /> }>} /> }>} /> } /> diff --git a/web-v2/src/components/Icon.tsx b/web-v2/src/components/Icon.tsx index bcc1fc3..cb66ed2 100644 --- a/web-v2/src/components/Icon.tsx +++ b/web-v2/src/components/Icon.tsx @@ -1,7 +1,7 @@ export type IconName = | 'home' | 'map' | 'layers' | 'calendar' | 'clipboard' | 'alert' | 'history' | 'users' | 'shield' | 'audit' | 'logout' | 'menu' - | 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload' | 'mail'; + | 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload' | 'mail' | 'lock'; export function Icon({ name, size = 18 }: { name: IconName; size?: number }) { const paths: Record = { @@ -25,6 +25,7 @@ export function Icon({ name, size = 18 }: { name: IconName; size?: number }) { key: <>, upload: <>, mail: <>, + lock: <>, }; return (
TRAZABILIDAD TEMPORAL

Historial del registro

{total} versión{total === 1 ? '' : 'es'}
-

Cada cambio conserva una copia completa e inmutable del registro, sus atributos y su ubicación.

- {error && {error}} - {loading ? :
{versions.map((version) => )}
} - {!loading && total > versions.length &&

Se muestran las 10 versiones más recientes. El historial completo está disponible en la sección Historial.

} - {(detailLoading || detail) && setDetail(null)} />} - ; + return
+ {functionEligible && } +
+
TRAZABILIDAD TEMPORAL

Historial del registro

{total} versión{total === 1 ? '' : 'es'}
+

Cada cambio conserva una copia completa e inmutable del registro, sus atributos, su ubicación y, cuando corresponde, su función operativa.

+ {error && {error}} + {loading ? :
{versions.map((version) => )}
} + {!loading && total > versions.length &&

Se muestran las 10 versiones más recientes. El historial completo está disponible en la sección Historial.

} + {(detailLoading || detail) && setDetail(null)} />} +
+
; } diff --git a/web-v2/src/features/assets/InventoryFunctionPanel.tsx b/web-v2/src/features/assets/InventoryFunctionPanel.tsx new file mode 100644 index 0000000..73a48af --- /dev/null +++ b/web-v2/src/features/assets/InventoryFunctionPanel.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from 'react'; +import type { FormEvent } from 'react'; +import { Link } from 'react-router'; +import { useAuth } from '../../auth/AuthContext'; +import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; +import { SearchableSelect } from '../../components/SearchableSelect'; +import { formatDate } from '../../lib/format'; +import { + changeInventoryFunction, + getInventoryFunctionHistory, + listInventoryFunctions, +} from '../../lib/inventoryFunctionApi'; +import type { + InventoryFunction, + InventoryFunctionHistory, +} from '../../lib/inventoryFunctionApi'; + +function localDateTime(value: Date): string { + const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + +export function InventoryFunctionPanel({ assetId }: { assetId: string }) { + const { hasPermission } = useAuth(); + const canChange = hasPermission('assets.update'); + const canManageCatalog = hasPermission('asset_types.manage'); + const [catalog, setCatalog] = useState([]); + const [history, setHistory] = useState(null); + const [functionId, setFunctionId] = useState(''); + const [effectiveAt, setEffectiveAt] = useState(''); + const [reason, setReason] = useState(''); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const load = () => { + setLoading(true); + setError(''); + Promise.all([ + listInventoryFunctions(), + getInventoryFunctionHistory(assetId), + ]) + .then(([loadedCatalog, loadedHistory]) => { + setCatalog(loadedCatalog); + setHistory(loadedHistory); + setFunctionId(loadedHistory.currentFunction?.functionId ?? ''); + }) + .catch((requestError) => setError(errorMessage(requestError))) + .finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, [assetId]); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!functionId || functionId === history?.currentFunction?.functionId) return; + setBusy(true); + setError(''); + setSuccess(''); + try { + const updated = await changeInventoryFunction(assetId, { + functionId, + effectiveAt: effectiveAt ? new Date(effectiveAt).toISOString() : undefined, + reason: reason.trim() || null, + }); + setHistory(updated); + setFunctionId(updated.currentFunction?.functionId ?? ''); + setEffectiveAt(''); + setReason(''); + setSuccess('Cambio de función registrado. La función anterior permanece en el historial del Inventario.'); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setBusy(false); + } + }; + + if (loading) return
; + if (!history) return {error || 'No se pudo cargar la función operativa.'}; + + const current = history.currentFunction; + + return
+
+
+ FUNCIÓN OPERATIVA +

Función de la Estación / Subestación

+

La identidad del Inventario no cambia. Cada mutación de función conserva fecha, usuario y antecedente para la consulta histórica.

+
+ {canManageCatalog && Administrar catálogo} +
+ + {error && {error}} + {success && {success}} + +
+
+ Función vigente + {current?.functionName ?? 'Sin función asignada'} + {current ? `${current.functionCode} · desde ${formatDate(current.validFrom)}` : 'Debe seleccionarse una función del catálogo.'} +
+
+ Cambios registrados + {history.history.length} + Historial temporal del mismo Inventario. +
+
+ + {canChange &&
+
+

Cambiar función

+

Ejemplo: Bombeo Mecánico AIB → Bombeo Mecánico Rotaflex. No se crea una Estación nueva.

+
+
+ + +
+