Compare commits

...
Author SHA1 Message Date
admin 5986474a35 ci: validate authoritative inventory model 2026-09-11 01:28:46 -03:00
admin f33c2b8df8 fix(api): scope checklist by Yacimiento ancestry 2026-09-11 01:20:31 -03:00
admin 4d7f5f7df4 test: resolve planning Company from Yacimientos 2026-09-11 01:16:39 -03:00
admin 6e0adb7a07 test: align operational context with Yacimiento ownership 2026-09-11 01:16:28 -03:00
admin 90098d1fd5 test: resolve planning operators from authoritative Yacimientos 2026-09-11 01:16:06 -03:00
admin 3c838942c4 test: plan inspections from authoritative Yacimiento context 2026-09-11 01:15:50 -03:00
admin 548d00662d test: align manual inventory contracts with authoritative model 2026-09-11 01:15:35 -03:00
admin 729b90bfbf fix(api): keep Yacimiento relationships out of generic edits 2026-09-11 01:15:15 -03:00
admin 1b5cdbf57b fix(web): make authoritative level selection non-null 2026-09-11 01:08:44 -03:00
admin a98dfb955d fix(api): validate inspection operator against Yacimiento 2026-09-11 01:06:59 -03:00
admin 99f068b416 fix(api): derive planning operators from Yacimientos 2026-09-11 01:06:36 -03:00
admin 718e08be13 feat(web): capture Yacimiento company and concession 2026-09-11 01:06:18 -03:00
admin b88147d376 feat(web): expose Yacimiento company and concession options 2026-09-11 01:05:56 -03:00
admin 439809abc7 feat(api): make Yacimiento own company and concession 2026-09-11 01:05:42 -03:00
admin 98b9af7344 feat(api): require Yacimiento company and concession relationships 2026-09-11 01:05:00 -03:00
admin 18e16c2ec2 feat(web): use authoritative inventory configuration page 2026-09-11 01:02:38 -03:00
admin cc75c76860 feat(web): present authoritative inventory relationships 2026-09-11 01:02:15 -03:00
admin 474e8d4310 test: lock authoritative inventory SQL model 2026-09-11 00:59:56 -03:00
admin 06923d2849 feat(api): make Yacimiento company and concession editable 2026-09-11 00:59:28 -03:00
admin e5c71a0c16 feat(api): accept Yacimiento concession type 2026-09-11 00:59:18 -03:00
admin c1f51eb81f feat(api): expose Yacimiento concession relationship 2026-09-11 00:58:56 -03:00
admin 68d1af63ff feat(db): apply authoritative inventory relationships 2026-09-11 00:58:36 -03:00
admin fe6be39302 fix: compose authoritative inventory payload safely 2026-09-11 00:51:51 -03:00
admin 72a4f70177 feat: add authoritative inventory payload chunk 5 2026-09-11 00:50:59 -03:00
admin a98c9020b0 feat: add authoritative inventory payload chunk 4 2026-09-11 00:50:40 -03:00
admin be543de927 feat: add authoritative inventory payload chunk 3 2026-09-11 00:50:17 -03:00
admin d2e82719f6 feat: add authoritative inventory payload chunk 2 2026-09-11 00:49:58 -03:00
admin fb6b2388c3 feat: add authoritative inventory payload chunk 1 2026-09-11 00:49:37 -03:00
admin 583fd58e1c feat: add authoritative inventory reference data 2026-09-11 00:48:57 -03:00
admin 67928b48f8 chore: revert accidental placeholder 2026-09-11 00:45:08 -03:00
admin 0e959708cf noop 2026-09-11 00:44:56 -03:00
admin 8bee2c852a chore(db): reset total de datos vivos
Deja únicamente el usuario admin y la estructura mínima del producto; elimina todos los datos vivos para la carga definitiva.
2026-09-11 00:29:21 -03:00
admin 0a9cfdffb1 chore(db): add full live data reset 2026-09-11 00:21:41 -03:00
admin dde3f32021 chore(db): limpiar jerarquía operativa para nueva carga
Limpia Departamento, Área, Yacimiento, Instalación y Subinstalación en el entorno vivo, preservando usuarios, Empresas/Operadoras y catálogos estructurales.
2026-09-11 00:09:29 -03:00
admin 7ae79af595 chore(db): scope hierarchy reset to live production 2026-09-11 00:00:23 -03:00
admin f2754d87ed chore(db): reset operational hierarchy data 2026-09-10 23:59:32 -03:00
admin dea9358451 fix(android): Acta → Hallazgo → Inventario en campo
Simplifica el flujo operativo móvil, corrige el alta desde el Yacimiento congelado, permite crear Actas antes de seleccionar Inventario y precarga Marca, Modelo, Capacidad, Número de serie y Función en Instalaciones/Subinstalaciones. Android 0.16.0.
2026-09-10 23:13:06 -03:00
admin 9fffafc447 Merge pull request #42 from enlineawork/fix/inspection-context-preventive-scope
fix: congelar contexto de inspección y corregir preventivos
2026-09-10 21:46:06 -03:00
admin 466fb29089 test(api): align inventory search contract with preventive scope 2026-09-10 21:38:16 -03:00
admin b23d7736c0 test(api): cover immutable context and scoped preventive inventory 2026-09-10 21:34:55 -03:00
admin 5b1593ed78 test(api): reject inspection context changes after creation 2026-09-10 21:34:42 -03:00
admin ff7706497d test(api): align F6.1 with immutable inspection context 2026-09-10 21:34:28 -03:00
admin cb002c60f9 fix(web): freeze inspection context and scope preventive selector 2026-09-10 21:34:09 -03:00
admin 74b91d0c8a feat(web): load preventive inventory from frozen scope 2026-09-10 21:33:01 -03:00
admin 53c8f2b4ab feat(db): enforce immutable inspection context 2026-09-10 21:32:49 -03:00
admin b7baea652b fix(api): freeze inspection context after creation 2026-09-10 21:32:39 -03:00
admin 9e6d9e45cb feat(api): register preventive candidate endpoint 2026-09-10 21:32:29 -03:00
admin 82b587ea6a feat(api): expose scoped preventive candidates 2026-09-10 21:32:18 -03:00
admin 88e7e8de65 feat(api): scope preventive candidates to inspection yacimiento 2026-09-10 21:32:12 -03:00
admin b265769047 Merge pull request #41 from enlineawork/fix/field-briefing-planned-date
fix: corregir fecha del briefing de campo
2026-09-10 21:07:28 -03:00
admin 9a40f5cbd0 test(api): cover pg Date in field briefing 2026-09-10 20:59:53 -03:00
admin ce579d1667 fix(api): normalize field briefing planned date 2026-09-10 20:59:42 -03:00
admin bc6d4604e3 Merge pull request #40 from enlineawork/fix/csrf-layout-document-pdf-projection
fix: CSRF de planificación, shell simple y proyección PDF
2026-09-10 20:31:17 -03:00
admin 5c69892815 test: keep isolated F6.2 contract independent from docs 2026-09-10 20:26:31 -03:00
admin c1eeb5f078 test: protect CSRF fix, simplified shell and PDF projection 2026-09-10 20:20:47 -03:00
admin 324a73d35c docs: define provisional PDF contract for Actas and INF 2026-09-10 20:20:33 -03:00
admin 97f34a471f docs: align document model with multi-Act inspections and provisional PDFs 2026-09-10 20:20:16 -03:00
admin a5d954b4c7 feat: show provisional Informe PDF projection 2026-09-10 20:19:59 -03:00
admin a80ef4477c feat: show provisional Acta PDF projection 2026-09-10 20:19:28 -03:00
admin b21810f166 feat: expose provisional PDF data contract for Actas and Informes 2026-09-10 20:18:44 -03:00
admin 64486f40f6 ux: stop persisting hidden operational filters 2026-09-10 20:16:59 -03:00
admin a407bf46c5 ux: remove duplicated global operational context bar 2026-09-10 20:16:40 -03:00
admin bfb7eed608 fix: use authenticated CSRF-aware request for inspection planning 2026-09-10 20:16:21 -03:00
admin c74fd80620 Merge pull request #39 from enlineawork/feat/structured-inventory-navigation
feat: navegación estructurada de Inventarios
2026-09-10 20:00:28 -03:00
admin 9bd1adbdb5 fix: keep inventory item type presentation generic 2026-09-10 19:54:47 -03:00
admin cc8d650aa4 feat: present inventory levels as dedicated sections 2026-09-10 19:54:19 -03:00
admin 9d0d1f33ce feat: add structured inventory navigation 2026-09-10 19:53:52 -03:00
admin e5f1615665 F6.1 hotfix · Operadora inmediata y cancelación con motivo (#38)
Corrige la UX de planificación para precargar fecha/hora, resolver y autoseleccionar la Operadora vigente del Área, y expone cancelación auditada con motivo obligatorio en DRAFT/PLANNED/IN_PROGRESS, manteniendo el bloqueo por Acta sellada.
2026-09-10 18:26:23 -03:00
admin 7be5d89204 F6.1 hotfix · jerarquía de Inspecciones y scope Yacimiento (#37)
Corrige creación de Inspección, separa Departamento→Área→Yacimiento, elimina el UPDATE oculto de checklist en lectura, integra Preparación para campo como etapa de Inspección, limita operaciones Android al Yacimiento, preserva scope en edición, auto-incluye Inventarios accionables, valida Operadora a plannedStartAt y recompila Android 0.15.2/vc24 con el ícono exacto de presentación.
2026-09-10 16:06:48 -03:00
admin ce2a85a26a F6.1 · reset y precarga territorial de presentación (#36)
Precarga únicamente la fuente aprobada Tablas de yacimiento y areas(1).xlsx con jerarquía Departamento → Área → Yacimiento, concesiones y Operadoras. Corrige además el CHECK legacy F4 para permitir contexto físico de Área sin snapshot obligatorio de Empresa. El reset preserva usuarios/RBAC y catálogos/configuración técnica, y valida cardinalidades exactas antes de completar.
2026-09-09 21:16:29 -03:00
admin 20ffa9e4b3 F6.1 · APK 0.15.1 con launcher Mendoza Gobierno (#35)
Prepara la APK de presentación con launcher propio basado en el escudo institucional de Gobierno de Mendoza, versionName 0.15.1 / versionCode 23, contratos de metadata actualizados y sin cambios funcionales de backend o WEB.
2026-09-09 20:22:53 -03:00
admin 509bc0cde1 F6.1 · cierre de presentación y barrera de release (#34)
Cierra F6.1 como candidata verificable de presentación: metadata WEB alineada, contratos transversales de aceptación, Android CI/RC endurecida con lint/tests/builds/checksum, correcciones de carreras de permisos GPS y cámara opcional, auditoría de dependencias productivas y parches de runtime, más documentación de release y recorrido de demo.
2026-09-09 18:35:28 -03:00
admin e549a44ae5 fix(F6.1): unificar contexto operativo Área–Operadora (#33)
Cierra inconsistencias residuales del contexto operativo F6.1. Inventario queda ligado al Área física; la Operadora vigente se resuelve por relaciones temporales. Incluye correcciones pre-merge de búsquedas WEB, alta contextual, merge de duplicados y barreras CI/runtime. Producción permanece sin desplegar.
2026-09-09 17:43:07 -03:00
admin 1dd424698a test(f6.1): require build-safe active operator fallback 2026-09-09 17:36:04 -03:00
admin d9cdb48a26 fix(f6.1): make active operator selection build-safe 2026-09-09 17:35:42 -03:00
admin bec92f0c6d test(f6.1): cover inventory merge physical candidate search 2026-09-09 17:32:16 -03:00
admin 3dab0938d6 fix(f6.1): merge inventory candidates by physical context 2026-09-09 17:31:54 -03:00
admin f93e8240ad test(f6.1): cover pre-merge operator consistency blockers 2026-09-09 17:27:48 -03:00
admin 257ee20148 fix(f6.1): derive creation operator from active area relation 2026-09-09 17:27:20 -03:00
admin 264ab70214 fix(f6.1): search preventive inventory by physical area 2026-09-09 17:26:05 -03:00
admin 3903ae22e6 fix(f6.1): reconcile field inventory by physical area 2026-09-09 17:24:45 -03:00
admin c454eb3255 test(f6.1): block legacy operator snapshot search contracts 2026-09-09 15:29:06 -03:00
admin a7cc9e0886 fix(f6.1): reject legacy operator snapshot tree filters 2026-09-09 15:28:22 -03:00
admin 7d475cdc6f fix(f6.1): reject legacy operator snapshot asset filters 2026-09-09 15:28:09 -03:00
admin 8b6608ecae test(f6.1): cover historical context and web operator separation 2026-09-09 15:23:39 -03:00
admin 5b237a4d4e fix(f6.1): keep operator editable only at inventory creation 2026-09-09 15:23:09 -03:00
admin 16ca67cb76 fix(f6.1): keep act lists and snapshots on visit context 2026-09-09 15:20:42 -03:00
admin ca1fddc834 fix(f6.1): separate physical context from area operator in web 2026-09-09 15:18:28 -03:00
admin b713be4d15 fix(f6.1): keep reports on frozen inspection context 2026-09-09 15:16:45 -03:00
admin 20ecbb2872 fix(f6.1): keep finding worklist on historical visit context 2026-09-09 15:15:07 -03:00
admin 62f612dd24 ci(f6.1): require real Nest startup and health check 2026-09-09 15:13:32 -03:00
admin fc978ad74e ci(f6.1): run Android on operational API contract changes 2026-09-09 15:12:55 -03:00
admin 4f1f26ad93 test(f6.1): lock operational context invariants 2026-09-09 15:12:43 -03:00
admin ec8a9be99e test(f6.1): enforce operator relation without inventory rewrite 2026-09-09 15:12:27 -03:00
admin 5738306a14 fix(f6.1): resolve verification operator from temporal area relation 2026-09-09 15:09:04 -03:00
admin 33c0c6b8f6 fix(f6.1): preserve inventory snapshots when operator changes 2026-09-09 15:07:52 -03:00
admin 44f6002869 fix(f6.1): resolve temporal operator from area relation history 2026-09-09 15:07:17 -03:00
admin 517f934d5e fix(f6.1): keep operator snapshot immutable on inventory updates 2026-09-09 15:06:54 -03:00
admin e4acb7bebf fix(f6.1): block operator changes through inventory context 2026-09-09 15:06:42 -03:00
admin f7145427c0 F6.1 · Auditoría de consistencia y manuales (#32)
Consolida invariantes F6.1, corrige terminología de contexto operativo, agrega contratos transversales y crea manuales de programador/usuario. Deploy permanece pausado.
2026-09-09 14:58:46 -03:00
admin 433220ee3a docs(code): documentar apertura móvil canónica 2026-09-09 14:52:57 -03:00
admin ba38dc7dda docs: enlazar manuales y contrato F6.1 2026-09-09 14:52:26 -03:00
admin 51ace362d7 docs: registrar auditoría de consistencia F6.1 2026-09-09 14:52:03 -03:00
admin 3a149691e2 docs: crear manual de usuario 2026-09-09 14:50:08 -03:00
admin 294abc4cf3 docs: crear manual del programador 2026-09-09 14:49:21 -03:00
admin 90443b3961 docs: evitar duplicación del modelo F6 2026-09-09 14:48:30 -03:00
admin 60c6d97f1a docs: evitar duplicación del modelo F6 2026-09-09 14:48:20 -03:00
admin ae7326f20a docs: consolidar modelo canónico de Inventarios F6.1 2026-09-09 14:47:52 -03:00
admin 5a9c4ed5ef test: fijar invariantes transversales F6.1 2026-09-09 14:47:19 -03:00
admin 0959e85487 fix: alinear terminología del contexto de inspección 2026-09-09 14:44:57 -03:00
admin a4fad611d4 docs(code): fijar invariante multi-Acta 2026-09-09 14:44:26 -03:00
admin d28186a567 docs(code): documentar jerarquía canónica de Inventario 2026-09-09 14:44:06 -03:00
admin 25a63cd3d4 F6.1 · Hallazgos en Yacimientos + abrir Inspección desde APK (#31)
Hallazgos directos en Yacimiento, Instalación y Subinstalación con OTROS siempre disponible. Apertura de inspección desde Android mediante Área → Operadora vigente, autoasignación y lifecycle canónico.
2026-09-09 14:17:08 -03:00
admin 3d8ace5529 F6.1 · actualizar contrato de APK de prueba 2026-09-09 14:11:01 -03:00
admin 47203ff0da F6.1 · contrato de catálogo contextual en Yacimientos 2026-09-09 14:10:50 -03:00
admin b701becab7 F6.1 · usar catálogo contextual por tipo en Yacimientos 2026-09-09 14:10:20 -03:00
admin 9983201de7 F6.1 · actualizar contrato de health metadata 2026-09-09 14:04:26 -03:00
admin 4fc05b73b1 F6.1 · artefacto APK 0.15.0 2026-09-09 14:00:34 -03:00
admin 40cfeff42e F6.1 · Android 0.15.0 2026-09-09 14:00:24 -03:00
admin e3f89f4d5a F6.1 · metadata API 2026-09-09 14:00:11 -03:00
admin 7284c252ff F6.1 · version API 0.29.0-1 2026-09-09 14:00:01 -03:00
admin 7baf306a23 F6.1 · contratos Hallazgos y apertura móvil 2026-09-09 13:59:49 -03:00
admin 27a51c2fe2 F6.1 · tratar Yacimiento como objetivo de Hallazgos 2026-09-09 13:59:33 -03:00
admin b326eb737d F6.1 · permitir Hallazgos sobre Yacimientos 2026-09-09 13:59:07 -03:00
admin 6a5db887c0 F6.1 · abrir inspecciones desde APK 2026-09-09 13:58:23 -03:00
admin baf63a861c F6.1 · usar home móvil con apertura de inspecciones 2026-09-09 13:57:57 -03:00
admin d61e4ad04d F6.1 · pantalla APK para abrir inspección en campo 2026-09-09 13:57:34 -03:00
admin 37885c0454 F6.1 · cliente Android para abrir inspecciones 2026-09-09 13:57:08 -03:00
admin b6707cef43 F6.1 · contrato para abrir inspección desde APK 2026-09-09 13:56:28 -03:00
admin 74fc08bf24 F6 hotfix · deploy espera versión candidata (#30)
Endurece el healthcheck del watcher para esperar status ok, versión exacta candidata y database ok antes de aceptar el recreate.
2026-09-09 11:34:37 -03:00
admin ad7ec2ca2b fix(deploy): wait for exact candidate health version 2026-09-09 11:28:53 -03:00
admin d938db7913 F6 · Modelo definitivo de Inventarios + APK Android (#29)
Jerarquía física definitiva, compatibilidades N↔N de clasificaciones, Hallazgos y campos técnicos por familia, navegación WEB/Android y APK F6 0.14.0-debug.
2026-09-09 11:19:12 -03:00
admin 0f4bb13fc7 docs(f6): documentar modelo definitivo 2026-09-09 09:11:16 -03:00
admin f0db23bc9b docs(f6): ampliar contrato definitivo 2026-09-09 09:11:08 -03:00
admin 0b0851d9ec docs(f6): fijar contrato definitivo de Inventarios 2026-09-09 09:11:00 -03:00
admin 7338f277ad F5.1 · Inventario limpio, jerarquía manual y Hallazgos filtrados (#28)
Reset controlado de datos operativos e históricos, nueva jerarquía manual Departamento → Área → Yacimiento → Instalación → Subinstalación, filtros y administración inline de Hallazgos, y corrección de alta con Área sin Operadora.
2026-09-09 08:55:01 -03:00
admin 3d83545033 F5 hotfix · restaurar provider interno del dossier
Restaura únicamente InventoryFunctionService como provider interno requerido por el dossier fusionado, sin reabrir el catálogo de funciones, y agrega regresión de arranque.
2026-09-09 07:04:46 -03:00
admin 2b87e07fd2 F5 · Android 0.13.0 de prueba y compatibilidad de familias
Integra el corte Android F5 validado por CI, con artifact 0.13.0-debug y compatibilidad correcta de familias Otro / no catalogado en Inventario de campo.
2026-09-09 06:50:06 -03:00
admin 35d4630581 F5 · Inventario operativo, territorio y catálogo autorizado (#25)
* fix(web): simplify inventory administration menu

* fix(web): remove legacy imports and function catalog routes

* fix(web): remove redundant inspections lifecycle legend

* feat(inventory): distinguish physical instances from structural records

* fix(dashboard): align inventory and act follow-up metrics

* fix(web): align dashboard summary contract

* fix(web): clarify dashboard act and report concepts

* feat(inventory): mark field-created records as real instances

* feat(inventory): map physical instance flag on asset entity

* fix(inventory): keep field yacimientos structural

* feat(inventory): classify future concrete instances at database level

* fix(inventory): count only installation and subinstallation instances

* feat(inventory): add authoritative F5 source snapshot

* feat(inventory): preload authoritative territory model

* feat(inventory): preload authoritative technical catalog

* fix(findings): use only authoritative F5 family catalog

* fix(inventory): preserve non-hierarchical operator snapshot compatibility

* feat(inventory): add inventory-only asset filter

* feat(inventory): add inventory-only tree filter

* feat(inventory): add inventory browser query contract

* feat(inventory): add area-owned inventory browser

* feat(inventory): expose area-owned inventory browser

* refactor(inventory): remove function catalog and add inventory browser

* fix(inventory): make operator relation temporal and non-owning

* feat(web): add inventory browser API client

* feat(inventory): extend inventory browser filters

* feat(inventory): add real inventory list endpoint logic

* feat(inventory): expose real inventory list

* feat(web): add real inventory list client

* refactor(web): make inventory hierarchy area-owned

* fix(web): show only real inventory instances

* fix(web): style act follow-up tabs and F5 inventory context

* fix(web): load F5 flow styles

* fix(inventory): apply area-owned operational guard on F5 up

* fix(inventory): treat company on asset as non-owning creation snapshot

* fix(inventory): resolve field inventory by area hierarchy, not company ownership

* fix(inventory): preserve custom catalog and apply authoritative universal findings

* fix(inventory): harden authoritative catalog migration checks

* fix(inventory): make authoritative territory preload safely reversible

* feat(inventory): allow independent company master creation

* fix(inventory): make guided creation area-owned and support companies

* feat(web): expose independent company master in inventory setup

* feat(web): create companies independently from physical inventory hierarchy

* fix(inventory): merge by physical area and preserve sealed documents

* test(inventory): lock F5 authoritative model and merge invariants

* feat(inventory): add family administration DTOs

* feat(inventory): administer installation and subinstallation classifications

* feat(inventory): expose family classification administration

* feat(web): add inventory classification administration API

* fix(web): configure finding applicability by inventory classification

* fix(web): redefine inventory configuration around hierarchy classifications and columns

* chore(release): identify F5 inventory model

* chore(release): bump API for F5 inventory model

* test(release): expect F5 health metadata

* chore(release): align WEB package with F5 inventory cut

* chore(release): expose F5 WEB phase

* test(dashboard): expect inspector activity and act follow-up metrics

* test(dashboard): route F5 summary query mocks explicitly

* ci: rehearse all migrations on clean PostGIS before merge

* ci: prove F5 migrations revert and reapply cleanly

* test(f5): align operational navigation contract

* test(f5): align operator lifecycle with area-owned inventory

* test(f5): make merge compatibility area-based

* test(f5): distinguish literal and normalized yacimiento counts

* test(f5): model normalized yacimiento collision explicitly

* ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal

* ci(f5): bypass irreversible historical reset in clean rehearsal

* fix(f5): make territory SQL parameter types explicit

* fix(f5): guarantee canonical inventory hierarchy before territory preload

* ci(f5): include canonical hierarchy migration in rollback gate

* fix(f5): type relation backup markers explicitly

* fix(f5): make catalog SQL text parameter types explicit
2026-09-08 23:18:15 -03:00
admin 1dc3282055 F4 hotfix · metadata de health
Corrige la fase publicada por el health de la API para reflejar F4 y agrega una prueba de regresión sin cambiar la versión funcional.
2026-09-08 16:18:58 -03:00
admin 7875fea3ca test(health): pin F4 phase metadata 2026-09-08 16:14:10 -03:00
admin 0d5bdd25ce fix(health): report F4 phase 2026-09-08 16:14:04 -03:00
admin a75e9bdcb0 F4 hotfix · paridad preflight deploy/CI
Alinea el preflight real del auto-deploy VPS con la barrera Docker de GitHub CI, montando contratos cross-tree en read-only y agregando una guardia de paridad.
2026-09-08 16:04:44 -03:00
admin 3027e9a8c8 ci: guard deploy preflight contract mounts 2026-09-08 15:59:00 -03:00
admin 09a190532c fix(deploy): mirror F4 contract mounts in VPS preflight 2026-09-08 15:58:46 -03:00
admin 8a8ab47453 F4 · Saneamiento backend y flujo documental
Integra el saneamiento F4 validado por CI completa: flujo Acta → INF → GEDO/IF, lifecycle documental, contratos WEB/Android, migraciones reversibles, seguimiento append-only y cierre de observaciones de revisión.
2026-09-08 15:53:41 -03:00
admin 9b86f49c9e ci: mount compose contract read-only in preflight 2026-09-08 15:27:33 -03:00
admin 8584249eb2 fix: sanitize GEDO download filename 2026-09-08 15:23:03 -03:00
github-actions[bot] 5dc7ea037d fix: close final F4 review gaps 2026-09-08 18:22:02 +00:00
admin 969ffddc76 chore: stage final F4 review fixes 2026-09-08 15:21:54 -03:00
admin 4c47916bd1 test: update multi-act company outcome semantics 2026-09-08 15:10:18 -03:00
admin 2121df0b55 test: align F4 sealing contract with documented absence 2026-09-08 15:10:01 -03:00
admin fcd64d5f43 chore: remove one-shot F4 patch workflow 2026-09-08 15:07:03 -03:00
github-actions[bot] 844c1a950f fix: close F4 pre-merge review gaps 2026-09-08 18:06:16 +00:00
admin e1e44e6352 chore: stage one-shot F4 pre-merge corrections 2026-09-08 15:06:07 -03:00
admin 94a8d5c92f ci: mount web and android contracts read-only in preflight 2026-09-08 14:51:19 -03:00
admin 3bfa3bce1b fix(f4): require current checklist before inspection execution 2026-09-08 14:43:02 -03:00
admin 4df8f40dd4 test(f4): align legacy contracts with lifecycle model 2026-09-08 14:39:55 -03:00
admin 7b6460dff2 fix(f4): name recurrence response contracts for api declarations 2026-09-08 13:55:11 -03:00
admin 30353c338b fix(f4): expose recurrence state and close ci type gaps 2026-09-08 13:52:24 -03:00
admin 95a41f39d7 fix(f4): align verification and web contracts with legacy cleanup 2026-09-08 13:44:51 -03:00
admin 100d457484 test(f4): guard Android inspection contract from legacy fields 2026-09-08 11:42:48 -03:00
admin 4d0ed8b046 test(f4): guard all active inspection consumers from legacy fields 2026-09-08 11:41:42 -03:00
admin a510d309bc test(f4): prove legacy field rollback preserves data 2026-09-08 10:30:53 -03:00
admin 9606168ac6 fix(f4): make inspection legacy field removal data reversible 2026-09-08 10:30:27 -03:00
admin 0442511065 test(f4): guard android lock seal lifecycle contract 2026-09-08 10:29:42 -03:00
admin 27d14d2d4e refactor(f4): remove legacy mobile act lifecycle aliases 2026-09-08 10:29:24 -03:00
admin fa64ba7727 fix(f4): align embedded INF status in act contract 2026-09-08 10:27:32 -03:00
admin d0b805b281 test(f4): guard locked sealed web act lifecycle 2026-09-08 10:26:51 -03:00
admin b3125b51ad fix(f4): use locked sealed lifecycle in act detail 2026-09-08 10:26:13 -03:00
admin b67db745e4 fix(f4): present locked and sealed act lifecycle 2026-09-08 10:25:46 -03:00
admin f0ffde975b refactor(f4): decouple findings panel from legacy act type 2026-09-08 10:25:10 -03:00
admin d8b02bfaf1 fix(f4): use act lifecycle contract inside inspections 2026-09-08 10:24:39 -03:00
admin 7a406df1ad fix(f4): show locked and sealed acts in document center 2026-09-08 10:24:19 -03:00
admin 8c89cd05c9 feat(f4): add explicit act lifecycle web contract 2026-09-08 10:23:49 -03:00
admin f7a4e7f228 fix(f4): align web inspection legacy contract 2026-09-08 10:18:42 -03:00
admin 7b9d2b7903 test(f4): guard inspection legacy fields across backend and web 2026-09-08 10:11:33 -03:00
admin 53db8b93bb fix(f4): preserve finding verification date contract 2026-09-08 10:10:45 -03:00
admin 61f77505df fix(f4): remove legacy inspection titles from findings 2026-09-08 10:08:39 -03:00
admin c28e5b060d test(f4): lock field finding and sequential multi-act contracts 2026-09-08 10:01:38 -03:00
admin 2c1fdf7fb2 test(f4): make lifecycle source boundaries deterministic 2026-09-08 10:00:24 -03:00
admin afb6feb4c1 test(f4): harden multi-act immutability gates 2026-09-08 09:58:12 -03:00
admin 7174d379e5 test: cover F4 inspector delivery database contract 2026-09-08 09:38:52 -03:00
admin ff7a99725e fix: align F4 document delivery recipient constraint 2026-09-08 09:38:18 -03:00
admin 6f100bf9ba test: align document center baseline with F4 report lifecycle 2026-09-08 09:35:57 -03:00
admin 2968f83b36 test: guard active Act queries from removed Inspection fields 2026-09-08 09:32:56 -03:00
admin cac3161cbb fix: restore area filter after Inspection title cleanup 2026-09-08 09:31:42 -03:00
admin 97296ba37b fix: remove active Inspection title reads from Acts service 2026-09-08 09:29:56 -03:00
admin 80275969c9 test: cover F4 Act code database constraint 2026-09-08 09:27:58 -03:00
admin 345ef76820 fix: align F4 Act code constraint with generated codes 2026-09-08 09:27:29 -03:00
admin 40e33f8312 test: lock F4 document lifecycle database contract 2026-09-08 09:25:14 -03:00
admin 57739dcb3e fix: make F4 lifecycle constraint rollback executable 2026-09-08 09:24:50 -03:00
admin 271290abe4 fix: align F4 document lifecycle database constraints 2026-09-08 09:23:55 -03:00
admin 5393a40e8b test: tighten F4 Director retirement and retained INF history 2026-09-08 09:20:33 -03:00
admin 3a1379d49a fix: fully retire Director report permissions in F4 2026-09-08 09:20:14 -03:00
admin ad44dc7ef6 test: assert Survey planning DTOs are retired in F4 2026-09-08 09:18:19 -03:00
admin 248fb6285b test: assert Survey planning authorization is retired in F4 2026-09-08 09:18:04 -03:00
admin 792e21e669 test: assert Survey execution DTOs are retired in F4 2026-09-08 09:17:48 -03:00
admin 9fab773090 test: assert Survey execution authorization is retired in F4 2026-09-08 09:17:30 -03:00
admin 38742bf140 test: align mobile multi-act lifecycle with F4 sealing 2026-09-08 09:16:23 -03:00
admin 2018e1aeba test: align document delivery contract with F4 recipients and SMTP 2026-09-08 09:15:44 -03:00
admin c11808f560 test: align automatic report contract with F4 sealing 2026-09-08 09:15:26 -03:00
admin 28ec002710 test: retire Director review contract under F4 2026-09-08 09:15:06 -03:00
admin 90851e6e7d test: preserve D5 guarantees under F4 document workflow 2026-09-08 09:11:40 -03:00
admin 0e2b524a26 test: lock F4 multi-act lifecycle invariants 2026-09-08 09:08:58 -03:00
admin 5bd392dcb4 test: move verification completion gate to inspection close 2026-09-08 09:08:42 -03:00
admin 232614ccf0 test: align multi-act contract with F4 lifecycle 2026-09-08 09:08:23 -03:00
admin b24c3787c9 fix: require verification results when closing inspection 2026-09-08 09:08:00 -03:00
admin 71d0884557 fix: decouple act locking from visit verification completion 2026-09-08 09:07:05 -03:00
admin 2506bece76 test(f4): guard retired inspection fields 2026-09-08 08:52:09 -03:00
admin a9fc9f3e8e refactor(f4): remove inspection legacy type shims 2026-09-08 08:50:42 -03:00
admin f70962a819 refactor(f4): remove inspection legacy persistence shim 2026-09-08 08:50:27 -03:00
admin e6ae2807bc refactor(f4): align inspection entity with retired columns 2026-09-08 08:46:15 -03:00
admin 5404409042 refactor(f4): retire legacy inspection fields 2026-09-08 08:45:54 -03:00
admin 49b321ca87 fix(f4): remove inspection title from asset dossier 2026-09-08 08:26:31 -03:00
admin 048b7b90af fix(f4): remove inspection title from Android contract 2026-09-08 08:16:29 -03:00
admin c53997a41c fix(f4): align finding worklist web contract 2026-09-08 08:11:46 -03:00
admin 459ad03326 fix(f4): decouple finding worklist from inspection title 2026-09-08 08:11:33 -03:00
admin a816f4cfdf fix(f4): keep legacy deadline storage compatible pending notification definition 2026-09-08 07:51:33 -03:00
admin 98545820ac refactor(f4): model nonurgent deadline on notification date 2026-09-08 07:50:39 -03:00
admin a9b2ed4b4b fix(f4): decouple GEDO officialization from notification deadline 2026-09-08 07:49:29 -03:00
admin 5386ccf9b5 refactor(f4): remove inspection title from verification contracts 2026-09-08 07:46:34 -03:00
admin 0df7935b54 refactor(f4): remove inspection title from reports UI 2026-09-08 07:43:52 -03:00
admin 2d2a268220 refactor(f4): remove inspection title from report web contract 2026-09-08 07:43:09 -03:00
admin b021189a94 refactor(f4): remove inspection title from report contracts 2026-09-08 07:42:32 -03:00
admin ce0bbf1d94 refactor(f4): align inspection wrapper with canonical contract 2026-09-08 07:41:24 -03:00
admin 3f0a1e5441 refactor(f4): remove legacy inspection lifecycle and public title/end date 2026-09-08 07:39:38 -03:00
admin bd767b7e32 fix(f4): hide SMTP controls outside system admin 2026-09-08 07:35:08 -03:00
admin d08162fca8 fix(f4): restrict SMTP administration to system admin 2026-09-08 07:32:39 -03:00
admin 2826960d71 db(f4): drop legacy director report workflow 2026-09-08 07:11:12 -03:00
admin 0718414fdb refactor(api): remove legacy director report fields 2026-09-08 07:10:53 -03:00
admin a4a57d3109 chore(web): remove legacy survey redirects 2026-09-08 07:05:23 -03:00
admin e3b24cba3d F4: remove obsolete Survey subsystem schema and permissions 2026-09-08 06:44:06 -03:00
admin cc8c5d9392 F4: remove Survey report media entity 2026-09-08 06:43:31 -03:00
admin 27f52a05c8 F4: remove Survey report version entity 2026-09-08 06:43:21 -03:00
admin b5b6942e39 F4: remove Survey target report entity 2026-09-08 06:43:07 -03:00
admin 0609b7faab F4: remove Survey campaign target entity 2026-09-08 06:42:54 -03:00
admin 0aa4e06d17 F4: remove Survey campaign entity 2026-09-08 06:42:44 -03:00
admin d2ae9f9bd4 F4: remove Survey entities from ORM registry 2026-09-08 06:42:35 -03:00
admin e3c347dbb8 F4: restore web API client exactly after accidental overwrite 2026-09-08 06:32:48 -03:00
admin 116dd24b7b F4: remove accidental api placeholder before exact restore 2026-09-08 06:28:19 -03:00
admin c2c935fd85 noop 2026-09-08 06:28:08 -03:00
admin 068cf8ae3a F4: remove legacy survey report DTO 2026-09-08 06:27:35 -03:00
admin fbf426ed90 F4: remove legacy survey review DTO 2026-09-08 06:27:24 -03:00
admin 46c2abb4d1 F4: remove legacy survey execution service 2026-09-08 06:27:10 -03:00
admin 50728e83ba F4: remove legacy survey execution module 2026-09-08 06:27:00 -03:00
admin cbf8f0c738 F4: remove obsolete survey execution controller 2026-09-07 23:16:42 -03:00
admin e2049b084f F4: remove survey planning DTOs 2026-09-07 23:16:33 -03:00
admin 73eb6fc192 F4: remove survey planning DTOs 2026-09-07 23:16:24 -03:00
admin 89f48dd82d F4: remove survey planning DTOs 2026-09-07 23:16:14 -03:00
admin 5b22dde1c6 F4: remove survey planning DTOs 2026-09-07 23:16:07 -03:00
admin 3d3d89a76a F4: remove survey planning DTOs 2026-09-07 23:15:59 -03:00
admin 29864fb2ae F4: remove survey planning DTOs 2026-09-07 23:15:51 -03:00
admin dea007581c F4: remove survey planning DTOs 2026-09-07 23:15:41 -03:00
admin de87952743 F4: remove survey planning DTOs 2026-09-07 23:15:29 -03:00
admin 0bab3e5b70 F4: remove obsolete survey planning service 2026-09-07 23:15:19 -03:00
admin 17e2e1902b F4: remove obsolete survey planning module 2026-09-07 23:15:10 -03:00
admin 3e2b021943 F4: remove obsolete survey campaigns controller 2026-09-07 23:15:02 -03:00
admin 830addb7b5 F4: make SMTP configurable from Superadmin and remove Director 2026-09-07 23:14:15 -03:00
admin 2319d06685 F4: add document delivery client without Director 2026-09-07 23:13:41 -03:00
admin 8d962e783d F4: add Superadmin SMTP settings client 2026-09-07 23:13:24 -03:00
admin b69b773094 F4: present locked and sealed Act states 2026-09-07 23:11:24 -03:00
admin 5996c3819a F4: remove obsolete generic-status inspection editor 2026-09-07 23:10:48 -03:00
admin 7ac42fa05d F4: route inspections through clean planning editor 2026-09-07 23:10:36 -03:00
admin 3180101ef7 F4: add clean inspection planning editor 2026-09-07 23:10:01 -03:00
admin 572c8a4af1 F4: add explicit inspection lifecycle client 2026-09-07 23:09:05 -03:00
admin 91480779dd F4: expose explicit inspection unplan action 2026-09-07 23:08:55 -03:00
admin 0a6500fbff F4: add explicit inspection unplan lifecycle action 2026-09-07 23:08:34 -03:00
admin 69bc720899 F4: activate inspection visit compatibility layer 2026-09-07 23:06:21 -03:00
admin ce33b32820 F4: isolate technical inspection checklist and public visit model 2026-09-07 23:06:12 -03:00
admin 212f4dcee5 F4: remove obsolete finding company-response workspace 2026-09-07 23:01:56 -03:00
admin 1775392eb1 F4: make findings worklist technical-only 2026-09-07 23:00:01 -03:00
admin 92cf0dee97 F4: add technical findings worklist client 2026-09-07 22:59:24 -03:00
admin 2e5c4c35aa F4: register technical findings worklist 2026-09-07 22:59:03 -03:00
admin 1192ed2cdd F4: route global findings to technical worklist 2026-09-07 22:58:49 -03:00
admin 4db3797afc F4: add technical finding worklist service 2026-09-07 22:58:32 -03:00
admin 7a910d7505 F4: align dashboard with technical findings and INF workflow 2026-09-07 22:57:00 -03:00
admin c999354645 F4: add dashboard contract without finding response states 2026-09-07 22:56:33 -03:00
admin bc7cd48436 F4: align dashboard counters with technical and INF workflows 2026-09-07 22:56:20 -03:00
admin 44eccaa36c F4: align verification planning UI with technical workflow 2026-09-07 22:55:02 -03:00
admin 82d8af2728 F4: decouple verification planning from company response 2026-09-07 22:53:41 -03:00
admin 408fd5990d F4: align report list with INF GEDO workflow 2026-09-07 22:48:18 -03:00
admin c715029dbc F4: add report list contracts to web client 2026-09-07 22:47:43 -03:00
admin f83ab824ff F4: replace director report review UI with INF workflow 2026-09-07 22:46:53 -03:00
admin 513fe6121b F4: add web client for report workflow 2026-09-07 22:45:57 -03:00
admin fb12ce3cff F4: add function catalog to administration navigation 2026-09-07 22:44:00 -03:00
admin 558fdbc85d F4: route inventory function catalog admin 2026-09-07 22:43:43 -03:00
admin 4a86dd15d9 F4: expose function changes in inventory history 2026-09-07 22:43:25 -03:00
admin 86e692c537 F4: add inventory function catalog admin 2026-09-07 22:41:59 -03:00
admin f8e866a140 F4: add inventory function change panel 2026-09-07 22:41:32 -03:00
admin 30f543ad08 F4 findings: center detail on immutable description and verification 2026-09-07 22:16:05 -03:00
admin 747ad21262 F4 inventory: version function changes in dossier history 2026-09-07 22:11:10 -03:00
admin a9a6b2fa1f F4 inventory: include function in temporal snapshots 2026-09-07 22:09:50 -03:00
admin 4caf6216de F4 WEB: add Inventory function API client 2026-09-07 21:45:09 -03:00
admin f20cc28045 F4: pass secure SMTP and remote signature settings to API 2026-09-07 21:43:38 -03:00
admin 3b4310b63a F4: document remote signature and SMTP secure settings 2026-09-07 21:43:15 -03:00
admin 335d327984 F4 WEB: expose public remote Act signature route 2026-09-07 21:42:56 -03:00
admin f4e5480ac8 F4 WEB: add public secure Act signature page 2026-09-07 21:42:26 -03:00
admin bdcf193a90 F4: wire remote company signature workflow 2026-09-07 21:39:05 -03:00
admin e577e4c976 F4: export SMTP delivery for remote signature invitations 2026-09-07 21:38:56 -03:00
admin 343dbe086f F4: expose secure remote company signature endpoints 2026-09-07 21:38:40 -03:00
admin a178f64763 F4: implement secure remote company signature workflow 2026-09-07 21:38:23 -03:00
admin badb79be5b F4: add remote company signature invitation DTOs 2026-09-07 21:37:13 -03:00
admin 161f4a9252 F4: add secure remote company signature invitations 2026-09-07 21:36:15 -03:00
admin 73c69c7a62 F4 Android: redesign Act screen for DRAFT LOCKED SEALED 2026-09-07 21:34:45 -03:00
admin ef084dd899 F4 Android: align ViewModel with urgency lock and seal 2026-09-07 21:33:53 -03:00
admin eeb35da02c F4: generate Act PDF only from SEALED Acts 2026-09-07 21:32:50 -03:00
admin 0db4567289 F4: include editable INF fields in generated Word 2026-09-07 21:31:57 -03:00
admin 53702009e3 F4: rebuild editable INF Word from sealed Act snapshot 2026-09-07 21:31:35 -03:00
admin f5ab6ce885 F4: rebuild Act PDF from locked and sealed snapshot 2026-09-07 21:30:43 -03:00
admin 1bed1cae98 F4 Android: keep branch compiling while UI migrates to lock/seal 2026-09-07 21:28:53 -03:00
admin d9698f11f0 F4: remove legacy per-Finding company follow-up endpoint 2026-09-07 21:26:55 -03:00
admin d0c55b8b39 F4 Android: align Act contracts with lock and seal 2026-09-07 21:25:53 -03:00
admin 009f4d10cf F4: keep function history in its temporal ledger 2026-09-07 21:22:53 -03:00
admin c30f167731 F4: audit recurrence through finding update ledger 2026-09-07 21:21:46 -03:00
admin 8b033dc297 F4: wire finding recurrence workflow 2026-09-07 21:21:14 -03:00
admin 236605a7d7 F4: expose finding recurrence endpoints 2026-09-07 21:21:04 -03:00
admin af6256ce0f F4: implement finding recurrence workflow 2026-09-07 21:20:56 -03:00
admin 0a8cb8f8e5 F4: add finding recurrence DTO 2026-09-07 21:20:31 -03:00
admin 5e14ffda17 F4: expose explicit inspection lifecycle endpoints 2026-09-07 21:19:42 -03:00
admin fcf52c60ff F4: wire explicit inspection lifecycle 2026-09-07 21:19:22 -03:00
admin 577e267f49 F4: implement explicit inspection lifecycle 2026-09-07 21:19:12 -03:00
admin 83ab2d4060 F4: add explicit inspection cancellation DTO 2026-09-07 21:18:43 -03:00
admin 2b63638c35 F4: remove legacy Act inference from finding queries 2026-09-07 21:17:56 -03:00
admin c3c798baed F4: require explicit Act for every field finding 2026-09-07 21:17:46 -03:00
admin a836c7535a F4: include technical function in merged inventory dossier 2026-09-07 21:17:33 -03:00
admin 88179eb67c F4: audit inventory function changes 2026-09-07 21:15:15 -03:00
admin 4d5c83732e F4: version inventory function changes 2026-09-07 21:14:42 -03:00
admin 0833746e1d F4: wire inventory function workflow 2026-09-07 21:14:27 -03:00
admin e02a3d9b58 F4: expose inventory function catalog and history 2026-09-07 21:14:09 -03:00
admin cbe69a8fff F4: implement temporal inventory function changes 2026-09-07 21:13:59 -03:00
admin 8b4cc99e53 F4: add inventory function DTOs 2026-09-07 21:12:57 -03:00
admin 5c1c046be6 F4: add temporal inventory function catalog 2026-09-07 21:12:43 -03:00
admin e25f72ac0a feat(f4): register deadline administration 2026-09-07 21:03:00 -03:00
admin 5e6631554a feat(f4): expose JEFE deadline policy endpoints 2026-09-07 21:02:54 -03:00
admin 9a79b7eb86 feat(f4): implement JEFE deadline policy and calendar administration 2026-09-07 21:02:44 -03:00
admin d071e6be74 feat(f4): add JEFE deadline administration DTOs 2026-09-07 21:02:27 -03:00
admin 2d97398c01 feat(f4): add Superadmin SMTP management endpoints 2026-09-07 21:01:44 -03:00
admin 489fb309b3 feat(f4): add superadmin SMTP settings DTO 2026-09-07 21:01:14 -03:00
admin a7f53f32f0 refactor(f4): remove obsolete Director delivery setting 2026-09-07 21:01:02 -03:00
admin ce3d3cf8b7 feat(f4): load SMTP from encrypted superadmin settings 2026-09-07 20:59:33 -03:00
admin 96093d9ed4 refactor(f4): deliver editable INF Word to inspector 2026-09-07 20:58:49 -03:00
admin a1d994b6d4 feat(f4): register INF workflow service 2026-09-07 20:57:47 -03:00
admin 39846c81a4 feat(f4): expose INF editing, GEDO and follow-up endpoints 2026-09-07 20:57:37 -03:00
admin 31256ea0b5 feat(f4): implement INF editing, GEDO officialization and follow-up 2026-09-07 20:57:22 -03:00
admin ad66bb3e61 feat(f4): add append-only INF follow-up DTO 2026-09-07 20:56:39 -03:00
admin 0c35e801eb feat(f4): add GEDO officialization DTO 2026-09-07 20:56:32 -03:00
admin b8fde4c32d feat(f4): add editable INF narrative DTO 2026-09-07 20:56:27 -03:00
admin 416aac335c refactor(f4): make INF editable and one-to-one with sealed Act 2026-09-07 20:56:11 -03:00
admin 97a6fb029d refactor(f4): expose lock and seal act lifecycle 2026-09-07 20:55:14 -03:00
admin f23a28e438 refactor(f4): replace READY/CLOSED lifecycle with LOCKED/SEALED 2026-09-07 20:54:53 -03:00
admin c348f3162c feat(f4): add lifecycle and document audit actions 2026-09-07 20:52:58 -03:00
admin 56f9c4234b feat(f4): add locked and sealed act version events 2026-09-07 20:52:29 -03:00
admin e7a9ff7dd9 feat(f4): align act numbering and urgency contract 2026-09-07 20:51:11 -03:00
admin 01efa2a8cc feat(f4): allow act urgency update while draft 2026-09-07 20:49:49 -03:00
admin bda4ce224d feat(f4): require explicit act urgency 2026-09-07 20:49:41 -03:00
admin 92240b21c2 feat(f4): adopt institutional INSP numbering 2026-09-07 20:47:33 -03:00
admin 6cc687d5e1 refactor(f4): remove survey runtime modules 2026-09-07 20:46:17 -03:00
admin c2d4f82a3a refactor(f4): remove Director report review service 2026-09-07 20:46:03 -03:00
admin f184c73c9e refactor(f4): remove Director report review endpoints 2026-09-07 20:45:58 -03:00
admin d834bc7b82 refactor(f4): remove Director report review circuit 2026-09-07 20:45:49 -03:00
admin c97e5ae33c feat(f4): add document workflow foundation migration 2026-09-07 20:45:29 -03:00
admin 0f1164fae8 feat(f4): register deadline, follow-up and SMTP entities 2026-09-07 20:45:04 -03:00
admin ab665e51b5 feat(f4): add superadmin SMTP settings entity 2026-09-07 20:44:42 -03:00
admin 13f2dcfa95 feat(f4): add append-only INF follow-up timeline 2026-09-07 20:44:34 -03:00
admin 2fb48ec41e feat(f4): add business-day calendar overrides 2026-09-07 20:44:27 -03:00
admin 52a068c817 feat(f4): add JEFE-managed act deadline policy 2026-09-07 20:44:21 -03:00
admin a9efd71367 feat(f4): extend INF with GEDO officialization 2026-09-07 20:44:12 -03:00
admin 7a41ce25ad feat(f4): add finding recurrence linkage 2026-09-07 20:43:55 -03:00
admin 48bbe293d9 feat(f4): extend act lifecycle and deadline snapshot 2026-09-07 20:43:40 -03:00
admin ce18f5d1a3 F3.2 · Android multi-Acta y cierre operativo
Integra selección/creación explícita de Actas, flujo móvil de firmas/cierre, soporte de Actas READY paralelas, cierre validado por servidor y Actas de verificación sin Hallazgos nuevos.
2026-09-07 17:30:53 -03:00
admin 03a3a66c51 F3.2 Android: permitir preparar Actas de verificación sin Hallazgos nuevos 2026-09-07 16:50:17 -03:00
admin 2d4e8ae2a7 F3.2 Android: permitir Actas READY paralelas y delegar cierre al servidor 2026-09-07 16:43:24 -03:00
admin 3e5dfb78a2 F3.2 Android: corregir Card clickeable de mensajes 2026-09-07 16:35:37 -03:00
admin ecf6f71c58 F3.2 Android: corregir API de PointerInput en firma 2026-09-07 16:34:33 -03:00
admin bd0f22969c F3.2 API: restaurar manifiesto exacto de producción 2026-09-07 16:26:48 -03:00
admin 5f3987747d F3.2 API: conservar dependencias y cambiar sólo versión 2026-09-07 16:26:32 -03:00
admin a208788488 F3.2: agregar CI de integración multi-Acta 2026-09-07 16:25:23 -03:00
admin 2e5183cc3c F3.2 API: actualizar versión de paquete 2026-09-07 16:24:57 -03:00
admin 80ce9ced0f F3.2 API: versionar integración multi-Acta móvil 2026-09-07 16:24:42 -03:00
admin d49fb81bcf F3.2 Android: activar CI y artefacto 0.12.0 2026-09-07 16:24:31 -03:00
admin 9e50967089 F3.2 Android: versionar APK 0.12.0 2026-09-07 16:24:16 -03:00
admin a1450da51e F3.2 Android: integrar Actas en la navegación de inspección 2026-09-07 16:23:10 -03:00
admin 97a774c264 F3.2 Android: implementar gestión y cierre secuencial de Actas 2026-09-07 16:20:08 -03:00
admin c9f1434462 F3.2 Android: agregar pad manuscrito PNG para firmas 2026-09-07 16:18:28 -03:00
admin d14cf737c0 F3.2 Android: incorporar estado multi-Acta y cierre 2026-09-07 16:16:09 -03:00
admin 9f60e671d2 F3.2 Android: agregar cliente de Actas y cierre 2026-09-07 16:14:43 -03:00
admin 28f6fb3684 F3.2 Android: enviar Acta explícita en Hallazgos 2026-09-07 16:13:52 -03:00
admin d7184ed537 F3.2: fijar contrato multi-Acta móvil y cierre secuencial 2026-09-07 16:13:22 -03:00
admin 203443d9c6 F3.2: resolver Hallazgos contra Acta explícita 2026-09-07 16:12:03 -03:00
admin 45ca6a783e F3.2: recibir Acta explícita en Hallazgos de campo 2026-09-07 16:11:20 -03:00
admin d3bfdeac67 F3.2: permitir Acta explícita al crear Hallazgo de campo 2026-09-07 16:11:05 -03:00
admin 9479ae6d1a F3.2: agregar selección explícita de Acta en Hallazgos de campo 2026-09-07 16:10:49 -03:00
admin 4ca65c6a89 F3.1 hotfix · aislar preflight API del WEB (#19)
Separa el contrato visual F3.1 del test API y agrega a CI el mismo preflight Docker aislado que ejecuta el VPS antes de producción.
2026-09-07 15:53:27 -03:00
admin 3495b33157 CI: replicar preflight API aislado del deploy VPS 2026-09-07 15:48:40 -03:00
admin 115a6a2ed8 F3.1 hotfix: ejecutar contrato visual dentro del job WEB 2026-09-07 15:47:30 -03:00
admin 4796581532 F3.1 hotfix: mover contrato visual a CI WEB 2026-09-07 15:47:16 -03:00
admin 1d3979fda3 F3.1 hotfix: aislar tests API del filesystem WEB 2026-09-07 15:47:02 -03:00
admin 72e75fab2f F3.1 · Inventario estructural, catálogo contextual y conciliación cronológica (#18)
Integra F3.1 con jerarquía estructural de Inventario, familias técnicas/Otro, catálogo contextual, merges append-only, perfil de Inspector, entrega documental al Inspector y APK Android 0.11.0. Todas las barreras API/WEB/DB/Docker/Android quedaron verdes antes del merge.
2026-09-07 15:39:26 -03:00
admin 29d541a274 F3.1 Android: disparar build final 0.11.0 2026-09-07 15:29:44 -03:00
admin d9a23c4c80 F3.1 Android: ejecutar validación final de APK 0.11.0 2026-09-07 15:29:13 -03:00
admin f2cb276785 F3.1 CI: validar entrega documental al Inspector 2026-09-07 15:26:14 -03:00
admin 37e4b840d9 F3.1 WEB: mostrar entregas documentales al Inspector 2026-09-07 15:25:40 -03:00
admin 463734f5b0 F3.1: enviar copia del Acta al Inspector responsable 2026-09-07 15:24:27 -03:00
admin f334f2baaa F3.1: agregar Inspector al outbox documental 2026-09-07 15:23:28 -03:00
admin e689ab57ec F3.1 CI: usar nombres reales del merge de catálogo 2026-09-07 15:20:20 -03:00
admin e54fa4503a F3.1 WEB: administrar merge cronológico de Hallazgos de catálogo 2026-09-07 15:15:58 -03:00
admin 810ac860c7 F3.1 WEB: cliente para conciliación del catálogo de Hallazgos 2026-09-07 15:15:19 -03:00
admin a4b5bdd3a9 F3.1 CI: corregir asserts PostgreSQL y cubrir merge de catálogo 2026-09-07 15:12:52 -03:00
admin a02a2dc4a8 F3.1: corregir contrato de contexto móvil estructural 2026-09-07 14:46:48 -03:00
admin 9455254e6a F3.1: estabilizar tipos del dossier fusionado 2026-09-07 14:46:04 -03:00
admin 7329cee080 F3.1 APK: usar catálogo familiar y limitar Hallazgos a niveles técnicos 2026-09-07 14:44:30 -03:00
admin 76b688ac82 F3.1: usar familia técnica y exponer merge de catálogo 2026-09-07 14:43:43 -03:00
admin 5fc239c664 F3.1: registrar resolver y merge del catálogo 2026-09-07 14:43:14 -03:00
admin 6f335ab482 F3.1: implementar merge cronológico del catálogo 2026-09-07 14:42:50 -03:00
admin a521d0fec7 F3.1: resolver Hallazgos por familia técnica 2026-09-07 14:42:09 -03:00
admin 48b6962d93 F3.1: agregar DTO de merge de Hallazgos de catálogo 2026-09-07 14:41:47 -03:00
admin aa26dc2227 F3.1: agregar merge auditable del catálogo de Hallazgos 2026-09-07 14:41:34 -03:00
admin 0e14157a44 F3.1 CI: compilar APK estructural 0.11.0 2026-09-07 14:39:03 -03:00
admin 2acc3e095b F3.1 Android: versionar APK 0.11.0 2026-09-07 14:38:47 -03:00
admin 9d5da0522d F3.1 Android: activar flujo estructural dentro de la inspección 2026-09-07 14:37:59 -03:00
admin e443031bda F3.1 Android: crear flujo estructural intuitivo de inspección 2026-09-07 14:37:30 -03:00
admin 3dba89f55a F3.1 Android: mantener compatibilidad durante reemplazo de Inventario 2026-09-07 14:35:58 -03:00
admin dcc2e56b3e F3.1 Android: clasificar y conciliar altas de campo 2026-09-07 14:31:47 -03:00
admin 51a2789bbd F3.1 Android: agregar familias y merge de Inventario 2026-09-07 14:30:52 -03:00
admin 1212a21ea3 F3.1 APK: usar alta estructural y familia contextual 2026-09-07 14:29:23 -03:00
admin e296f18ed1 F3.1 APK: registrar flujo estructural de campo 2026-09-07 14:29:02 -03:00
admin 782b0150c5 F3.1 APK: alinear alta de campo con estructura y familias 2026-09-07 14:28:39 -03:00
admin f92611eaa1 F3.1 APK: incluir familia técnica en alta de campo 2026-09-07 14:27:45 -03:00
admin e7a3faa912 F3.1: probar perfil, dossier y alta Otro 2026-09-07 14:26:05 -03:00
admin 3478c98f11 F3.1 CI: validar migraciones en PostgreSQL real 2026-09-07 14:25:47 -03:00
admin 9e58a5fa9e F3.1: agregar Otro para Instalación y Subinstalación 2026-09-07 14:24:51 -03:00
admin bd71e74df6 F3.1 WEB: gestionar merge desde el expediente 2026-09-07 14:23:37 -03:00
admin 636431f936 F3.1 WEB: agregar contrato de merge cronológico 2026-09-07 14:22:28 -03:00
admin dafb57d706 F3.1: servir dossier cronológico agregado 2026-09-07 14:20:47 -03:00
admin a450665a6d F3.1: registrar dossier unificado por merge 2026-09-07 14:20:22 -03:00
admin 9645d29ad8 F3.1: unificar dossier cronológico tras merges 2026-09-07 14:19:54 -03:00
admin f5b8ae38e3 F3.1 WEB: editar perfil personal completo del Inspector 2026-09-07 14:16:46 -03:00
admin b996a979d6 F3.1 WEB: agregar icono de email 2026-09-07 14:15:58 -03:00
admin ebc4c303e4 F3.1 WEB: ampliar alta de usuarios e Inspector 2026-09-07 14:15:23 -03:00
admin 5d066d1819 F3.1 WEB: agregar contrato de perfil personal 2026-09-07 14:14:50 -03:00
admin ff5cf080e7 F3.1: persistir perfil personal y exigir email al Inspector 2026-09-07 14:13:43 -03:00
admin 4e2c7f85ef F3.1: ampliar edición del perfil de usuario 2026-09-07 14:12:09 -03:00
admin 9ece0c1100 F3.1: ampliar alta administrativa de usuario 2026-09-07 14:11:47 -03:00
admin 066b387f2b F3.1: agregar datos personales al usuario 2026-09-07 14:11:21 -03:00
admin a3fbc17cb3 F3.1: ampliar perfil personal del inspector 2026-09-07 14:10:53 -03:00
admin fd94200fc5 F3.1: probar merge cronológico sin pérdida de historia 2026-09-07 14:09:24 -03:00
admin 8896e8d183 F3.1: registrar merge cronológico de Inventario 2026-09-07 14:08:55 -03:00
admin 516b310c48 F3.1: exponer merge cronológico en plataforma y APK 2026-09-07 14:08:38 -03:00
admin 08291acc8d F3.1: implementar merge cronológico de Inventario 2026-09-07 14:08:22 -03:00
admin 7d041dd3b2 F3.1: agregar DTO de fusión de Inventario 2026-09-07 14:07:01 -03:00
admin 34dcd68482 F3.1: agregar merge cronológico de Inventario 2026-09-07 14:06:47 -03:00
admin a76da6787c F3.1: corregir build Docker de alta estructural 2026-09-07 14:01:14 -03:00
admin 3ce85d8d2e F3.1 CI: validar Inventario estructural y catálogo 2026-09-07 10:23:20 -03:00
admin e5b522b667 F3.1: versionar WEB 0.21.0-1 2026-09-07 10:22:36 -03:00
admin 31fc355e98 F3.1: alinear versión de paquete API 2026-09-07 10:22:26 -03:00
admin af6ccfee4c F3.1: versionar API 0.24.0-1 2026-09-07 10:22:11 -03:00
admin 8582274643 F3.1: probar estructura de Inventario y catálogo revisado 2026-09-07 10:22:02 -03:00
admin 6102740647 F3.1: permitir consulta contextual desde Inventario 2026-09-07 10:21:12 -03:00
admin dec8908bbe F3.1 WEB: activar alta guiada y retirar rutas de Relevamientos 2026-09-07 10:20:45 -03:00
admin 4902e97b51 F3.1 WEB: retirar Relevamientos de navegación activa 2026-09-07 10:20:14 -03:00
admin d5bd5777f1 F3.1 WEB: crear alta guiada de Inventario 2026-09-07 10:19:40 -03:00
admin f34d6fe521 F3.1 WEB: agregar cliente de Inventario estructural 2026-09-07 10:18:19 -03:00
admin 5d2fec0f25 F3.1: integrar catálogo por familia técnica 2026-09-07 10:18:00 -03:00
admin 0f94a20298 F3.1: exponer Hallazgos por familia técnica 2026-09-07 10:17:34 -03:00
admin f3af297a71 F3.1: consultar Hallazgos asociados por familia 2026-09-07 10:17:19 -03:00
admin 765d48f694 F3.1: exponer padres válidos para cada nivel 2026-09-07 10:16:07 -03:00
admin 0f2885c562 F3.1: listar padres válidos para alta guiada 2026-09-07 10:15:47 -03:00
admin ab3e699926 F3.1: limitar cambio de Asset a familia técnica 2026-09-07 10:13:01 -03:00
admin 90c049a3c6 F3.1: sincronizar catálogo por familia técnica 2026-09-07 10:10:14 -03:00
admin 63528b1eff F3.1: integrar Inventario estructural 2026-09-07 10:09:17 -03:00
admin 344699ac75 F3.1: exponer alta guiada de Inventario 2026-09-07 10:08:59 -03:00
admin 620e6f07c4 F3.1: crear servicio guiado de Inventario estructural 2026-09-07 10:08:47 -03:00
admin 14d9467c0e F3.1: definir alta guiada de estructura de Inventario 2026-09-07 10:07:46 -03:00
admin 4f1e24ef83 F3.1: aceptar familia técnica en altas de Inventario 2026-09-07 10:06:53 -03:00
admin c4796adb39 F3.1: asociar Inventario con familia técnica 2026-09-07 10:06:27 -03:00
admin f387645ff9 F3.1: migrar Inventario estructural y recargar catálogo 2026-09-07 10:06:03 -03:00
admin da2598c03d F3.1: registrar estructura y catálogo desde Excel revisado 2026-09-07 10:01:57 -03:00
admin bdaccb66d0 F2.4 CI: compilar APK 0.10.3 2026-09-07 08:51:33 -03:00
admin f5f6afe797 F2.4 Android: versionar APK 0.10.3 2026-09-07 08:51:16 -03:00
admin c524d2ade6 F2.4 Android: capturar foto GPS por Hallazgo 2026-09-07 08:50:46 -03:00
admin a2ed3cf091 F2.4 Android: gestionar evidencias por Hallazgo 2026-09-07 08:49:30 -03:00
admin c4c6dd6c24 F2.4 Android: agregar evidencias fotográficas al Hallazgo 2026-09-07 08:48:36 -03:00
admin cb69cea3a9 F2.3: alinear versión de paquete API 2026-09-07 08:40:50 -03:00
admin 721a189b4c F2.3: versionar API 0.23.0-1 2026-09-07 08:40:20 -03:00
admin 6895edce20 F2.3: probar contrato de Hallazgos desde Inventario 2026-09-07 08:39:59 -03:00
admin 97f5fbdd9e F2.3 CI: conservar diagnóstico de build 2026-09-07 08:38:17 -03:00
admin 1a89024117 F2.3: estabilizar declaraciones públicas del controller 2026-09-07 08:37:59 -03:00
admin f9fd66d913 F2.3 CI: conservar diagnóstico de tests 2026-09-07 08:36:30 -03:00
admin 2143b9454f F2.3 CI: conservar diagnóstico de TypeScript 2026-09-07 08:35:22 -03:00
admin d765f7d65e F2.3: preservar validación runtime del DTO móvil 2026-09-07 08:34:03 -03:00
admin c5dde72b7f F2.3 CI: validar API y contrato de Hallazgos de campo 2026-09-07 08:32:07 -03:00
admin 3988918023 F2.3 CI: compilar APK 0.10.2 2026-09-07 08:31:51 -03:00
admin acb2488025 F2.3 Android: versionar APK 0.10.2 2026-09-07 08:31:33 -03:00
admin a49171fe31 F2.3 Android: enrutar Hallazgo después del desbloqueo 2026-09-07 08:31:11 -03:00
admin 1f5040f3c5 F2.3 Android: crear pantalla de Hallazgo desde Inventario 2026-09-07 08:30:39 -03:00
admin 958a4d134d F2.3 Android: integrar flujo de Hallazgos al ViewModel 2026-09-07 08:29:52 -03:00
admin e7dd2a7284 F2.3 Android: agregar contrato móvil de Hallazgos 2026-09-07 08:29:13 -03:00
admin dd4199cd91 F2.3: integrar Hallazgos de campo en visitas 2026-09-07 08:26:51 -03:00
admin 299a637901 F2.3: exportar catálogo y servicio de Hallazgos 2026-09-07 08:26:40 -03:00
admin 1334611ebc F2.3: reforzar puerta servidor para Hallazgos de campo 2026-09-07 08:26:28 -03:00
admin f82de44ded F2.3: exponer hallazgos de campo por Inventario 2026-09-07 08:25:28 -03:00
admin dd8ea8539b F2.3: crear servicio de hallazgos desde Inventario 2026-09-07 08:25:14 -03:00
admin 1c5efa8806 F2.3: agregar DTO de hallazgo de campo 2026-09-07 08:24:54 -03:00
admin 29c1abf907 fix: preservar etiquetas técnicas extensas del Excel 2026-09-06 22:48:01 -03:00
admin 7b5050e08e fix: garantizar tipos base Área y Organización 2026-09-06 22:44:30 -03:00
admin 5c0b8ed786 ci: corregir sintaxis del smoke de migraciones 2026-09-06 22:43:02 -03:00
admin ba4752a700 ci: ejecutar migraciones F2.2.1 sobre PostgreSQL real 2026-09-06 22:41:40 -03:00
admin cf3327a4da fix: endurecer migración F2.2.1 contra PostgreSQL real 2026-09-06 22:41:03 -03:00
admin e09930d411 F2.2.1: crear baseline temporal del maestro importado 2026-09-06 21:32:19 -03:00
admin 012703336a ci: validar integración F2.2.1 2026-09-06 21:31:00 -03:00
admin 5f5c790301 ci: validar Android F2.2.1 2026-09-06 21:30:50 -03:00
admin 6ed0ce4731 F2.2.1: identificar health de API 2026-09-06 21:30:31 -03:00
admin f78781ed9a F2.2.1: versionar API 0.22.1-1 2026-09-06 21:30:25 -03:00
admin 60ecfff794 test: validar normalización de planillas F2.2.1 2026-09-06 21:30:03 -03:00
admin 3db62aad77 F2.2.1: importar maestros y catálogo desde planillas oficiales 2026-09-06 21:29:46 -03:00
admin 83efd2cac2 F2.2.1: versionar fuente normalizada de planillas 2026-09-06 21:28:05 -03:00
admin e5b7d72951 android: usar puerta de acceso biométrica 2026-09-06 21:25:21 -03:00
admin f9cc561098 android: agregar ojo y desbloqueo biométrico 2026-09-06 21:25:13 -03:00
admin 7673afcc4f android: agregar biometría y visibilidad de contraseña 2026-09-06 21:24:49 -03:00
admin 30a06f17f3 F2.2: identificar health de API 2026-09-06 16:55:38 -03:00
admin f8da455241 F2.2: versionar API 0.22.0-1 2026-09-06 16:55:32 -03:00
admin 0103422473 android: corregir token de cancelación GPS 2026-09-06 16:49:16 -03:00
admin cd42e55b7b ci: validar backend y web en rama F2.2 2026-09-06 16:42:32 -03:00
admin 205b97f16b ci: compilar APK Android F2.2 2026-09-06 16:41:48 -03:00
admin 118e41c938 android: implementar flujo inspector y alta de Inventario 2026-09-06 16:41:07 -03:00
admin 00ac09f358 android: agregar actividad principal Compose 2026-09-06 16:39:22 -03:00
admin 199129847e android: agregar estado y operaciones de campo 2026-09-06 16:38:52 -03:00
admin 216a813f50 android: implementar contrato API y sesión cifrada 2026-09-06 16:38:21 -03:00
admin ad79fb589d android: agregar tema base 2026-09-06 16:37:08 -03:00
admin 0b2c7f0216 android: agregar recursos de texto 2026-09-06 16:37:01 -03:00
admin 2a7bf54ce1 android: configurar almacenamiento de fotos 2026-09-06 16:36:54 -03:00
admin 692e6119cc android: declarar permisos y FileProvider 2026-09-06 16:36:48 -03:00
admin b3d6ae1326 android: agregar reglas base 2026-09-06 16:36:40 -03:00
admin 0d181b37a0 android: configurar aplicación DH Inspección 2026-09-06 16:36:32 -03:00
admin db3d87727e android: fijar propiedades de build 2026-09-06 16:36:18 -03:00
admin 5b708e433f android: configurar AGP y Kotlin 2026-09-06 16:36:13 -03:00
admin 3d101e239c android: iniciar proyecto nativo F2.2 2026-09-06 16:36:04 -03:00
admin 3a0c37b2a7 F2.2: integrar autenticación móvil segura
F2.2: contrato seguro de autenticación Android
2026-09-06 16:33:48 -03:00
admin 4c01cf6ffe fix: alinear health con API 0.21.0-2 2026-09-05 22:27:29 -03:00
admin 26ec07864a docs: conservar README 2026-09-05 22:21:47 -03:00
admin 65cb28299d docs: mantener README estable 2026-09-05 22:21:39 -03:00
admin f2f9ccaa72 docs: no-op README 2026-09-05 22:21:34 -03:00
admin c7fd1f8583 docs: estabilizar README 2026-09-05 22:21:28 -03:00
admin c50360e732 docs: simplificar README 2026-09-05 22:21:21 -03:00
admin 387357b96c docs: restaurar README operativo 2026-09-05 22:21:13 -03:00
admin eb47a3d66f docs: registrar política operativa de reset seguro 2026-09-05 22:21:04 -03:00
admin 66333fb5dd ops: blindar reset preservando configuración estructural 2026-09-05 22:17:45 -03:00
admin 06f809b4c8 ops: versionar API 0.21.0-2 para reset limpio 2026-09-05 22:15:10 -03:00
admin 343c889250 ops: preparar reset limpio de datos operativos 2026-09-05 22:12:18 -03:00
admin 4fb1c502c8 F2.2: registrar autenticación móvil 2026-09-05 20:20:56 -03:00
admin d895812235 F2.2: exponer endpoints de autenticación Android 2026-09-05 20:20:47 -03:00
admin f680764373 F2.2: implementar autenticación Bearer Android 2026-09-05 20:20:40 -03:00
admin acc17bf291 F2.2: agregar DTO de refresh móvil 2026-09-05 20:20:16 -03:00
admin 1b57dcc850 maintenance: retirar diagnóstico Android temporal 2026-09-05 20:12:53 -03:00
admin 77d750992d maintenance: localizar fuente Android en VPS
Diagnóstico temporal de solo lectura para localizar fuentes/artefactos Android en el VPS y publicar el resultado en deploy-status.
2026-09-05 20:11:19 -03:00
admin b30cc1a294 maintenance: corregir diagnóstico Android de solo lectura 2026-09-05 20:07:58 -03:00
admin a7a6680cf8 maintenance: descubrir fuente Android en VPS sin modificar datos 2026-09-05 20:07:40 -03:00
admin 44570ad457 maintenance: retirar fresh-start temporal
Restaura migration:run y deploy-github estándar y elimina la herramienta fresh-start luego de la limpieza exitosa.
2026-09-05 20:04:46 -03:00
admin 8caed06362 maintenance: retirar herramienta destructiva temporal 2026-09-05 20:01:48 -03:00
admin e3db3d6cd7 maintenance: restaurar deploy estándar tras fresh-start 2026-09-05 20:01:37 -03:00
admin fb7b29cd94 maintenance: retirar hook de fresh-start 2026-09-05 20:01:08 -03:00
admin 0a11a452b5 maintenance: ejecutar fresh-start definitivo
Fresh-start único: backup integral de DB/source/media, limpieza de datos operativos y usuarios no-admin, reset de configuración editable, vaciado de archivos y verificación final.
2026-09-05 19:57:49 -03:00
admin 1d661e2e48 maintenance: respaldar y vaciar volumen de archivos en fresh-start 2026-09-05 19:54:48 -03:00
admin 2ee956665f maintenance: armar ejecución definitiva del fresh-start 2026-09-05 19:54:11 -03:00
admin 0d5db42c77 maintenance: cerrar whitelist del fresh-start
Ajusta el preview para limpiar también Departamentos importados, conservar sólo estructura sembrada y reiniciar configuración institucional editable. Reactiva FK antes de eliminar usuarios.
2026-09-05 19:50:46 -03:00
admin 21127b8cff maintenance: limpiar departamentos importados y resetear configuración editable 2026-09-05 19:47:42 -03:00
admin 50a9dc5183 maintenance: preservar configuración estructural y reforzar FK 2026-09-05 19:46:35 -03:00
admin a69df1d2d6 maintenance: preview seguro de limpieza integral
Agrega un fresh-start protegido y ejecuta únicamente el modo preview durante el paso de migraciones para inventariar la base real antes de borrar datos.
2026-09-05 19:41:46 -03:00
admin 95f34e2f08 maintenance: enlazar preview al paso de migraciones 2026-09-05 19:39:01 -03:00
admin 2f0d0d5ffc maintenance: exponer comando fresh-start 2026-09-05 19:38:08 -03:00
admin d2c79aeddc maintenance: agregar fresh-start protegido en modo preview 2026-09-05 19:37:54 -03:00
admin aef5a3d73d F2.1 · Inventario nacido en campo
Búsqueda y alta contextual de Inventario desde la APK, jerarquía dinámica, código de campo, GPS del dispositivo, fotos/EXIF y garantía de captura previa a emitir Hallazgos.
2026-09-05 18:08:49 -03:00
admin 1a51bdf820 F2.1: validar contexto de tipos de campo 2026-09-05 18:05:35 -03:00
admin 2142331fc0 F2.1: probar contrato de Inventario nacido en campo 2026-09-05 18:05:08 -03:00
admin f88dc8c2e0 F2.1: exigir GPS y foto antes de un Hallazgo 2026-09-05 18:04:20 -03:00
admin 6acbb26a82 F2.1: identificar fase de Inventario de campo 2026-09-05 18:02:50 -03:00
admin 56e5c9b59e F2.1: versionar API 0.21.0-1 2026-09-05 18:02:43 -03:00
admin 685b561821 F2.1: corregir alta contextual y captura de campo 2026-09-05 18:02:16 -03:00
admin ec600a1aff F2.1: marcar altas y selecciones de campo 2026-09-05 18:00:50 -03:00
admin 823e905aa3 F2.1: exportar servicios de Inventario para campo 2026-09-05 18:00:37 -03:00
admin 0b8e673072 F2.1: registrar Inventario de campo 2026-09-05 18:00:19 -03:00
admin 2c45eb7485 F2.1: exponer Inventario de campo a la APK 2026-09-05 18:00:01 -03:00
admin 3f6c537f05 F2.1: servicio móvil de Inventario nacido en campo 2026-09-05 17:47:03 -03:00
admin aed83e9ad7 F2.1: metadatos GPS y EXIF para foto de campo 2026-09-05 17:45:25 -03:00
admin e35ee78b08 F2.1: contrato de alta de Inventario en campo 2026-09-05 17:45:13 -03:00
admin 250540e076 F2.1: consulta móvil de Inventario por contexto 2026-09-05 17:44:58 -03:00
admin c527bc57dc F2.1: persistencia append-only para Inventario nacido en campo 2026-09-05 17:44:47 -03:00
admin 9da2f85c3c Merge pull request #9 from enlineawork/feature/f1-4-dashboard-briefing
F1.4 · Briefing contextual antes de salir
2026-09-05 17:28:39 -03:00
admin 0c309036cb F1.4: identificar briefing contextual en inspección 2026-09-05 17:25:32 -03:00
admin 6bda0f68fc F1.4: versionar dashboard 0.20.0-4 2026-09-05 17:25:26 -03:00
admin 6a86f67eba F1.4: mostrar briefing antes del cierre de planificación 2026-09-05 17:24:56 -03:00
admin 37795e6586 F1.4: integrar pendientes en la planificación de inspección 2026-09-05 17:24:42 -03:00
admin 3cd9cdd104 F1.4: compartir contrato del briefing en la ficha de inspección 2026-09-05 17:24:24 -03:00
admin 205ff1bab8 F1.3 · Vista web de preparación de campo
Completa F1.3 con la vista web imprimible de preparación de campo, selector buscable y lectura Acta → Hallazgos → Inventario.
2026-09-05 17:19:42 -03:00
370 changed files with 34241 additions and 7116 deletions
+11 -1
View File
@@ -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
+143
View File
@@ -0,0 +1,143 @@
name: Android CI / RC
# F6.1 presentation barrier: lint + real tests + debug artifact + release compile.
on:
push:
branches:
- 'main'
- 'release/f6-1*'
paths:
- 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
pull_request:
branches:
- 'main'
paths:
- 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs:
android:
name: Android · lint, tests, debug APK, release compile
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Android SDK
uses: android-actions/setup-android@v3
- name: Android API 36
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- name: Gradle 8.13
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.13'
- name: Validate mobile security and identity contract
run: |
set -Eeuo pipefail
grep -Fq 'applicationId = "com.korexlabs.dhinspeccion"' android-app/app/build.gradle.kts
grep -Fq 'applicationIdSuffix = ".debug"' android-app/app/build.gradle.kts
grep -Fq 'buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")' android-app/app/build.gradle.kts
grep -Fq 'android:allowBackup="false"' android-app/app/src/main/AndroidManifest.xml
grep -Fq 'android:usesCleartextTraffic="false"' android-app/app/src/main/AndroidManifest.xml
- name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug
- name: Print complete lint failures
if: failure()
run: |
report="android-app/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt"
if [ -f "$report" ]; then
echo '========== ANDROID LINT =========='
cat "$report"
fi
- name: Android unit tests
working-directory: android-app
run: gradle --no-daemon :app:testDebugUnitTest
- name: Require real unit-test results
run: |
set -Eeuo pipefail
test_dir="android-app/app/build/test-results/testDebugUnitTest"
test -d "$test_dir"
total="$(grep -h -oE '<testsuite[^>]+tests="[0-9]+"' "$test_dir"/TEST-*.xml 2>/dev/null | sed -E 's/.*tests="([0-9]+)"/\1/' | awk '{sum += $1} END {print sum + 0}')"
test "$total" -gt 0
echo "Android unit tests discovered: $total"
- name: Assemble debug APK
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Compile unsigned release variant
working-directory: android-app
run: gradle --no-daemon :app:assembleRelease
- name: Package RC artifact and checksum
id: package
run: |
set -Eeuo pipefail
version="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)"/\1/p' android-app/app/build.gradle.kts | head -n1)"
code="$(sed -n 's/^[[:space:]]*versionCode = \([0-9][0-9]*\)/\1/p' android-app/app/build.gradle.kts | head -n1)"
test -n "$version"
test -n "$code"
short_sha="${GITHUB_SHA::12}"
mkdir -p android-app/dist
apk="android-app/dist/DH-Inspeccion-${version}-vc${code}-${short_sha}-debug.apk"
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256"
{
echo "phase=F6.1"
echo "version=$version"
echo "versionCode=$code"
echo "commit=$GITHUB_SHA"
echo "artifact=$(basename "$apk")"
echo "applicationId=com.korexlabs.dhinspeccion.debug"
echo "apiBaseUrl=https://dhv2.korexlabs.com/api/v3/"
echo "channel=DEBUG_RC"
} > android-app/dist/release-metadata.txt
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "version_code=$code" >> "$GITHUB_OUTPUT"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
- name: Upload debug RC
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
path: android-app/dist/*
if-no-files-found: error
retention-days: 30
- name: Upload Android diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: android-diagnostics-${{ github.sha }}
path: |
android-app/app/build/reports/lint-results-debug.html
android-app/app/build/reports/tests/testDebugUnitTest/**
android-app/app/build/test-results/testDebugUnitTest/**
if-no-files-found: ignore
retention-days: 14
+278
View File
@@ -47,6 +47,8 @@ jobs:
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- run: npm run typecheck
- name: F3.1 WEB contract
run: bash ../scripts/check-f3-1-web-contract.sh
- run: npm run build
contract:
@@ -60,7 +62,283 @@ jobs:
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find scripts -type f -name '*.sh' -print0)
- name: Validate deploy preflight parity
run: |
grep -Fq -- '$STAGE/docker-compose.yml:/docker-compose.yml:ro' scripts/deploy-github.sh
grep -Fq -- '$STAGE/web-v2:/web-v2:ro' scripts/deploy-github.sh
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: Rehearse migrations and real API startup on clean PostGIS
run: |
set -Eeuo pipefail
cleanup() {
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup
docker compose --env-file .env.example up -d db
# Historical production reset is a one-shot migration that expects the
# production admin. Prove the clean chain reaches that exact guard,
# mark only that historical reset as applied, then continue the chain.
bootstrap_log="$(mktemp)"
set +e
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
bootstrap_status=${PIPESTATUS[0]}
set -e
if [ "$bootstrap_status" -eq 0 ]; then
echo "ERROR: clean migration rehearsal unexpectedly passed the historical production reset." >&2
exit 1
fi
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
echo "ERROR: migration rehearsal failed before the expected historical production-reset guard." >&2
exit 1
}
rm -f "$bootstrap_log"
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE reset_rows integer;
BEGIN
SELECT COUNT(*) INTO reset_rows
FROM typeorm_migrations
WHERE name = 'ResetProductionOperationalData1788652800000';
IF reset_rows <> 0 THEN
RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows;
END IF;
INSERT INTO typeorm_migrations ("timestamp", name)
VALUES (1788652800000, 'ResetProductionOperationalData1788652800000');
END $$;
SQL
docker compose --env-file .env.example --profile tools run --rm migrate
# The completed chain must end in the exact authoritative SQL model:
# 7 Departamentos, 64 Áreas, 230 Yacimientos, 13 Empresas, 2 Tipos de
# concesión, 14/109 technical families, 181 Hallazgos and their exact
# 48 + 880 contextual mappings.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE
f5_migrations integer;
domain_assets integer;
audits integer;
applicability integer;
legacy_territory_sources integer;
legacy_presentation_sources integer;
authoritative_sources integer;
source_installations integer;
source_subinstallations integer;
source_findings integer;
concession_types integer;
departments integer;
areas integer;
yacimientos integer;
companies integer;
invalid_yacimientos integer;
department_types integer;
area_department_rules integer;
yacimiento_area_rules integer;
installation_yacimiento_rules integer;
subinstallation_installation_rules integer;
BEGIN
SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
WHERE name IN (
'F5InventoryPhysicalInstance1790087100000',
'F5CanonicalInventoryHierarchy1790087150000',
'F5AuthoritativeTerritory1790087200000',
'F5OperationalContextCompatibility1790087250000',
'F5AuthoritativeInventoryCatalog1790087300000',
'F51CleanManualInventory1790087400000'
);
IF f5_migrations <> 6 THEN
RAISE EXCEPTION 'Expected 6 F5/F5.1 migrations, got %', f5_migrations;
END IF;
SELECT COUNT(*) INTO domain_assets FROM assets;
IF domain_assets <> 314 THEN
RAISE EXCEPTION 'Authoritative model must contain 314 Assets, got %', domain_assets;
END IF;
SELECT COUNT(*) INTO audits FROM audit_events;
IF audits <> 0 THEN
RAISE EXCEPTION 'Clean authoritative rehearsal must start with 0 audit events, got %', audits;
END IF;
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
IF applicability <> 928 THEN
RAISE EXCEPTION 'Authoritative model must contain 928 finding applicability links, got %', applicability;
END IF;
SELECT COUNT(*) INTO legacy_territory_sources
FROM source_documents
WHERE document_number='DH-F5-TERRITORY';
IF legacy_territory_sources <> 0 THEN
RAISE EXCEPTION 'Authoritative model must remove the old F5 territory source, got % rows', legacy_territory_sources;
END IF;
SELECT COUNT(*) INTO legacy_presentation_sources
FROM source_documents
WHERE document_number='DH-F6.1-PRESENTATION-TERRITORY-20260909';
IF legacy_presentation_sources <> 0 THEN
RAISE EXCEPTION 'Authoritative model must remove the F6.1 presentation source, got % rows', legacy_presentation_sources;
END IF;
SELECT COUNT(*) INTO authoritative_sources
FROM source_documents
WHERE document_number IN ('DH-AUTH-TERRITORY-20260911','DH-AUTH-INVENTORY-20260911');
IF authoritative_sources <> 2 THEN
RAISE EXCEPTION 'Authoritative model must contain its two SQL source documents, got %', authoritative_sources;
END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations
FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'modelo_relacional:%';
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
RAISE EXCEPTION 'Authoritative technical families must be 14 installations and 109 subinstallations, got % and %', source_installations,source_subinstallations;
END IF;
SELECT COUNT(*) INTO source_findings
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='authmodel' AND item.is_active=true;
IF source_findings <> 181 THEN
RAISE EXCEPTION 'Authoritative finding catalog must contain 181 items, got %', source_findings;
END IF;
SELECT COUNT(*) INTO concession_types FROM concession_types WHERE is_active=true;
IF concession_types <> 2 THEN
RAISE EXCEPTION 'Authoritative model must contain 2 concession types, got %', concession_types;
END IF;
SELECT COUNT(*) FILTER (WHERE lower(type.code)='departamento'),
COUNT(*) FILTER (WHERE lower(type.code)='area'),
COUNT(*) FILTER (WHERE lower(type.code)='yacimiento'),
COUNT(*) FILTER (WHERE type.operational_role='COMPANY')
INTO departments,areas,yacimientos,companies
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id;
IF departments <> 7 OR areas <> 64 OR yacimientos <> 230 OR companies <> 13 THEN
RAISE EXCEPTION 'Authoritative territory mismatch: departments %, areas %, yacimientos %, companies %', departments,areas,yacimientos,companies;
END IF;
SELECT COUNT(*) INTO invalid_yacimientos
FROM assets yacimiento
JOIN asset_types type ON type.id=yacimiento.asset_type_id
JOIN assets area ON area.id=yacimiento.parent_id
WHERE lower(type.code)='yacimiento'
AND (
yacimiento.operational_area_id IS DISTINCT FROM area.id
OR yacimiento.operator_company_id IS NULL
OR yacimiento.concession_type_id IS NULL
);
IF invalid_yacimientos <> 0 THEN
RAISE EXCEPTION 'Authoritative model contains % invalid Yacimiento relationships', invalid_yacimientos;
END IF;
SELECT COUNT(*) INTO department_types
FROM asset_types
WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true;
IF department_types <> 1 THEN
RAISE EXCEPTION 'Expected one active root Departamento type, got %', department_types;
END IF;
SELECT COUNT(*) INTO area_department_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='area' AND lower(parent.code)='departamento';
IF area_department_rules <> 1 THEN
RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules;
END IF;
SELECT COUNT(*) INTO yacimiento_area_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='yacimiento' AND lower(parent.code)='area';
IF yacimiento_area_rules <> 1 THEN
RAISE EXCEPTION 'Expected Yacimiento → Área canonical rule, got %', yacimiento_area_rules;
END IF;
SELECT COUNT(*) INTO installation_yacimiento_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='instalacion' AND lower(parent.code)='yacimiento';
IF installation_yacimiento_rules <> 1 THEN
RAISE EXCEPTION 'Expected Instalación → Yacimiento canonical rule, got %', installation_yacimiento_rules;
END IF;
SELECT COUNT(*) INTO subinstallation_installation_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion';
IF subinstallation_installation_rules <> 1 THEN
RAISE EXCEPTION 'Expected Subinstalación → Instalación canonical rule, got %', subinstallation_installation_rules;
END IF;
END $$;
SQL
# Destructive cuts are intentionally one-way: production rollback is
# the PRE database backup, not migration:revert. Prove the completed
# chain is idempotent and has no pending migration on a second run.
rerun_log="$(mktemp)"
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log"
grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
echo "ERROR: authoritative migration chain is not idempotent." >&2
cat "$rerun_log" >&2
exit 1
}
rm -f "$rerun_log"
# A build-only preflight cannot catch Nest dependency-injection or
# runtime configuration failures. Start the production API image against
# the migrated database and require the public health endpoint to answer.
export JWT_ACCESS_SECRET='CI_ACCESS_SECRET_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
export REFRESH_TOKEN_PEPPER='CI_REFRESH_PEPPER_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
export SMTP_SETTINGS_MASTER_KEY=''
docker compose --env-file .env.example build api
docker compose --env-file .env.example up -d api
api_ready=0
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then
api_ready=1
break
fi
sleep 2
done
if [ "$api_ready" -ne 1 ]; then
echo 'ERROR: production API image did not become healthy.' >&2
docker compose --env-file .env.example logs --no-color api >&2 || true
exit 1
fi
grep -Fq '"status":"ok"' /tmp/dhv2-health.json || {
echo 'ERROR: /api/v3/health did not report status ok.' >&2
cat /tmp/dhv2-health.json >&2
exit 1
}
- name: Isolated builder test preflight
run: |
set -Eeuo pipefail
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
docker build --target builder -t "$image" api-v3
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
run: docker compose --env-file .env.example build api migrate web
+136
View File
@@ -0,0 +1,136 @@
name: F2.2 Integration CI
on:
push:
branches:
- 'feature/f2-2*'
paths:
- 'api-v3/**'
- 'web-v2/**'
- 'scripts/**'
- 'docker-compose.yml'
- '.github/workflows/f2-2-ci.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
api:
name: API · typecheck, tests, build
runs-on: ubuntu-latest
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run build
migrations:
name: DB · migrations on PostgreSQL 16/PostGIS
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_USER: dhv2_owner
POSTGRES_PASSWORD: owner_test_password
POSTGRES_DB: dhv2_ci
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U dhv2_owner -d dhv2_ci"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_NAME: dhv2_ci
DB_MIGRATION_USER: dhv2_owner
DB_MIGRATION_PASSWORD: owner_test_password
DB_APP_USER: dhv2_app
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- name: PostgreSQL client
run: |
command -v psql >/dev/null || {
sudo apt-get update
sudo apt-get install -y postgresql-client
}
- name: Prepare roles and extensions
env:
PGPASSWORD: owner_test_password
run: |
psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U dhv2_owner -d dhv2_ci <<'SQL'
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dhv2_app') THEN
CREATE ROLE dhv2_app LOGIN PASSWORD 'app_test_password';
END IF;
END
$$;
GRANT CONNECT ON DATABASE dhv2_ci TO dhv2_app;
GRANT USAGE ON SCHEMA public TO dhv2_app;
-- This reset was a one-time late production operation. It is marked as
-- executed on the clean CI database so the historical schema can be
-- constructed chronologically without running that production-only wipe.
CREATE TABLE IF NOT EXISTS typeorm_migrations (
id SERIAL PRIMARY KEY,
timestamp bigint NOT NULL,
name varchar NOT NULL
);
INSERT INTO typeorm_migrations (timestamp,name)
VALUES (1788652800000,'ResetProductionOperationalData1788652800000');
SQL
- run: npm ci
- run: npm run build
- name: Run every pending migration
run: npm run migration:run
- name: Assert no pending migrations
run: |
npm run migration:show | tee /tmp/migrations.txt
grep -Fq 'Pending migrations: no' /tmp/migrations.txt
- name: Assert F2.2.1 reference import
env:
PGPASSWORD: owner_test_password
run: |
test "$(psql -At -h 127.0.0.1 -U dhv2_owner -d dhv2_ci -c "SELECT count(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.data_origin='IMPORT' AND a.source_name='Tablas de yacimiento(1).xlsx' AND lower(t.code)='yacimiento'")" = "230"
test "$(psql -At -h 127.0.0.1 -U dhv2_owner -d dhv2_ci -c "SELECT count(*) FROM assets WHERE (operational_area_id IS NULL) <> (operator_company_id IS NULL)")" = "0"
test "$(psql -At -h 127.0.0.1 -U dhv2_owner -d dhv2_ci -c "SELECT count(*) FROM source_documents WHERE document_number IN ('DH-F221-TERRITORIO','DH-F221-APP')")" = "2"
web:
name: WEB · typecheck, build
runs-on: ubuntu-latest
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm run build
+62
View File
@@ -0,0 +1,62 @@
name: F2.3 Field Finding CI
on:
push:
branches:
- 'feature/f2-3*'
paths:
- 'api-v3/**'
- '.github/workflows/f2-3-ci.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
api:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: api-v3
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Node 24
uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- name: Install
run: npm ci
- name: Typecheck
run: npm run typecheck 2>&1 | tee typecheck.log
- name: Upload TypeScript diagnostic
if: always()
uses: actions/upload-artifact@v4
with:
name: f2-3-typecheck-diagnostic
path: api-v3/typecheck.log
if-no-files-found: warn
retention-days: 3
- name: Tests
run: npm test 2>&1 | tee test.log
- name: Upload test diagnostic
if: always()
uses: actions/upload-artifact@v4
with:
name: f2-3-test-diagnostic
path: api-v3/test.log
if-no-files-found: warn
retention-days: 3
- name: Build
run: npm run build 2>&1 | tee build.log
- name: Upload build diagnostic
if: always()
uses: actions/upload-artifact@v4
with:
name: f2-3-build-diagnostic
path: api-v3/build.log
if-no-files-found: warn
retention-days: 3
+205
View File
@@ -0,0 +1,205 @@
name: F3.1 Inventory CI
on:
push:
branches:
- 'feature/f3-1*'
workflow_dispatch:
permissions:
contents: read
jobs:
api:
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- run: npm ci
- name: Typecheck
run: npm run typecheck 2>&1 | tee typecheck.log
- name: Tests
run: npm test 2>&1 | tee test.log
- name: Build
run: npm run build 2>&1 | tee build.log
- name: Diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: f3-1-api-diagnostics
path: |
api-v3/typecheck.log
api-v3/test.log
api-v3/build.log
if-no-files-found: warn
retention-days: 3
migrations:
name: DB · F3.1 on PostgreSQL 16/PostGIS
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_USER: dhv2_owner
POSTGRES_PASSWORD: owner_test_password
POSTGRES_DB: dhv2_ci
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U dhv2_owner -d dhv2_ci"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_NAME: dhv2_ci
DB_MIGRATION_USER: dhv2_owner
DB_MIGRATION_PASSWORD: owner_test_password
DB_APP_USER: dhv2_app
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- name: PostgreSQL client
run: |
command -v psql >/dev/null || {
sudo apt-get update
sudo apt-get install -y postgresql-client
}
- name: Prepare roles and extensions
env:
PGPASSWORD: owner_test_password
run: |
psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U dhv2_owner -d dhv2_ci <<'SQL'
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dhv2_app') THEN
CREATE ROLE dhv2_app LOGIN PASSWORD 'app_test_password';
END IF;
END
$$;
GRANT CONNECT ON DATABASE dhv2_ci TO dhv2_app;
GRANT USAGE ON SCHEMA public TO dhv2_app;
CREATE TABLE IF NOT EXISTS typeorm_migrations (
id SERIAL PRIMARY KEY,
timestamp bigint NOT NULL,
name varchar NOT NULL
);
INSERT INTO typeorm_migrations (timestamp,name)
VALUES (1788652800000,'ResetProductionOperationalData1788652800000');
SQL
- run: npm ci
- run: npm run build
- name: Run every pending migration
run: npm run migration:run
- name: Assert no pending migrations
run: |
npm run migration:show | tee /tmp/migrations.txt
grep -Fq 'Pending migrations: no' /tmp/migrations.txt
- name: Assert F3.1 structure, merges and Inspector profile
env:
PGPASSWORD: owner_test_password
run: |
set -Eeuo pipefail
query() {
psql -At -v ON_ERROR_STOP=1 \
-h 127.0.0.1 \
-U dhv2_owner \
-d dhv2_ci \
-c "$1"
}
assert_eq() {
local expected="$1"
local sql="$2"
local actual
actual="$(query "$sql")"
if [ "$actual" != "$expected" ]; then
echo "ASSERT FAILED"
echo "SQL: $sql"
echo "Expected: $expected"
echo "Actual: $actual"
exit 1
fi
}
assert_eq 15 "SELECT count(*) FROM inventory_families WHERE level='INSTALLATION' AND is_active=true"
assert_eq 124 "SELECT count(*) FROM inventory_families WHERE level='SUBINSTALLATION' AND is_active=true"
assert_eq 16 "SELECT count(*) FROM inventory_families WHERE source_reference LIKE 'SYSTEM:F3.1:OTHER%'"
assert_eq 1 "SELECT count(*) FROM finding_categories WHERE lower(code)='app26r2' AND is_active=true"
assert_eq 1 "SELECT count(*) FROM finding_categories WHERE lower(code)='app26' AND is_active=false"
assert_eq 1 "SELECT count(*) FROM information_schema.tables WHERE table_name='asset_merges'"
assert_eq 1 "SELECT count(*) FROM pg_trigger WHERE tgname='trg_asset_merges_append_only' AND NOT tgisinternal"
assert_eq 1 "SELECT count(*) FROM information_schema.tables WHERE table_name='finding_catalog_item_merges'"
assert_eq 1 "SELECT count(*) FROM pg_trigger WHERE tgname='trg_finding_catalog_item_merges_append_only' AND NOT tgisinternal"
assert_eq 4 "SELECT count(*) FROM information_schema.columns WHERE table_name='users' AND column_name IN ('dni','phone','job_title','employee_number')"
assert_eq 2 "SELECT count(*) FROM pg_trigger WHERE tgname IN ('trg_user_roles_inspector_email','trg_users_inspector_email') AND NOT tgisinternal"
assert_eq 1 "SELECT count(*) FROM information_schema.columns WHERE table_name='inspection_document_deliveries' AND column_name='recipient_user_id'"
assert_eq 1 "SELECT count(*) FROM pg_constraint WHERE conname='chk_inspection_document_deliveries_recipient_kind' AND pg_get_constraintdef(oid) LIKE '%INSPECTOR%'"
assert_eq 1 "SELECT count(*) FROM pg_constraint WHERE conname='fk_inspection_document_deliveries_recipient_user'"
assert_eq 230 "SELECT count(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.data_origin='IMPORT' AND a.source_name='Tablas de yacimiento(1).xlsx' AND lower(t.code)='yacimiento'"
assert_eq 0 "SELECT count(*) FROM assets WHERE (operational_area_id IS NULL) <> (operator_company_id IS NULL)"
web:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- name: Typecheck
run: npm run typecheck 2>&1 | tee typecheck.log
- name: Build
run: npm run build 2>&1 | tee build.log
- name: Diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: f3-1-web-diagnostics
path: |
web-v2/typecheck.log
web-v2/build.log
if-no-files-found: warn
retention-days: 3
docker:
needs: [api, web, migrations]
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: Build production images
run: docker compose --env-file .env.example build api migrate web
+80
View File
@@ -0,0 +1,80 @@
name: F3.2 Multi-Acta CI
on:
push:
branches:
- 'feature/f3-2*'
workflow_dispatch:
permissions:
contents: read
jobs:
api:
name: API · F3.2
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Tests
run: npm test
- name: Build
run: npm run build
web:
name: WEB · regression
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- run: npm run typecheck
- name: F3.1 structural WEB contract
run: bash ../scripts/check-f3-1-web-contract.sh
- run: npm run build
deploy-preflight:
name: VPS-equivalent preflight / Docker
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [api, web]
steps:
- uses: actions/checkout@v4
- name: Validate shell scripts
run: |
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find scripts -type f -name '*.sh' -print0)
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: VPS-equivalent isolated API tests
run: |
set -Eeuo pipefail
image="dhv2-api:f3-2-preflight-${GITHUB_SHA::12}"
docker build --target builder -t "$image" api-v3
docker run --rm \
-v "$PWD/api-v3/test:/app/test:ro" \
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
"$image" npm test
docker image rm "$image" >/dev/null 2>&1 || true
- name: Build production images
run: docker compose --env-file .env.example build api migrate web
@@ -0,0 +1,26 @@
name: Inspection planning smoke
on:
pull_request:
branches: [main]
paths:
- 'api-v3/src/inspection-visits/**'
- 'api-v3/src/database/migrations/**'
- 'api-v3/src/reference-data/**'
- 'web-v2/src/pages/InspectionVisitCreateF61Page.tsx'
- 'web-v2/src/pages/FieldBriefingsPage.tsx'
- 'web-v2/src/layout/AppLayout.tsx'
- 'scripts/ci-inspection-planning-smoke.sh'
- '.github/workflows/inspection-planning-smoke.yml'
permissions:
contents: read
jobs:
create-inspection:
name: F6.1 · real inspection create
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rehearse hierarchy and create a real Inspection
run: bash scripts/ci-inspection-planning-smoke.sh
+48
View File
@@ -0,0 +1,48 @@
name: Production dependency audit
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dhv2-production-audit-${{ github.ref }}
cancel-in-progress: true
jobs:
api:
name: API · production dependencies
runs-on: ubuntu-latest
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- name: Reject high/critical runtime advisories
run: npm audit --omit=dev --audit-level=high
web:
name: WEB · production dependencies
runs-on: ubuntu-latest
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- name: Reject high/critical runtime advisories
run: npm audit --omit=dev --audit-level=high
+22 -26
View File
@@ -1,35 +1,31 @@
# DH Inspección V2 · D5.6.2
Hotfix de mantenimiento de Fase D para accesos Android y contrato de regresión de cierre.
# DH Inspección V2
Versión: **0.19.5-1**
Fase: **D5.5.1 · Corrección de staging de planificación**
Repositorio del sistema DH Inspección V2.
D5.5.1 conserva íntegramente D5.5 y corrige únicamente el contrato de pruebas de staging. D5.5 consolida la preparación de visitas desde oficina antes del trabajo de campo. La visita queda anclada explícitamente a Área y Organización operadora, y el sistema genera un checklist auditable con antecedentes, respuestas vencidas, controles vencidos y próximos controles.
## Componentes
## Alcance D5.5
- `api-v3/`: API NestJS/TypeORM y contratos de dominio.
- `web-v2/`: aplicación WEB React/Vite.
- `android-app/`: aplicación Android para trabajo de campo.
- `scripts/`: utilidades operativas, preflight y despliegue.
- `docs/`: documentación funcional y técnica.
- selección web Área → Organización operadora vigente;
- fecha y equipo inspector dentro del plan;
- checklist automático versionado y append-only;
- respuestas de empresa vencidas y controles de verificación vencidos;
- próximos controles dentro de 30 días de la fecha planificada;
- antecedentes históricos del mismo contexto;
- incorporación automática de registros accionables;
- alta manual de registros preventivos del mismo Área/Operadora;
- exclusiones sólo mediante motivo auditable;
- reincorporación sin borrar la exclusión histórica;
- visitas de verificación integradas al mismo contrato de planificación;
- cambio de contexto o fecha invalida el checklist hasta regeneración explícita;
- inicio de visita exclusivamente desde APK mediante la política móvil ya existente.
## Documentación vigente
## Migración
- [Corte de presentación F6.1](docs/PRESENTACION_F6_1.md)
- [Manual del Programador](docs/MANUAL_PROGRAMADOR.md)
- [Manual de Usuario](docs/MANUAL_USUARIO.md)
- [Modelo canónico de Inventarios F6.1](docs/F6_INVENTORY_MODEL.md)
- [Auditoría de consistencia F6.1](docs/AUDITORIA_CONSISTENCIA_F6_1.md)
- [Contrato de release Android F6.1](android-app/RELEASE.md)
`1789063200000-phase-d5-5-web-planning-checklist.ts`
`PhaseD55WebPlanningChecklist1789063200000`
## Contratos que no deben romperse
## Despliegue
- Inventario físico: **Departamento → Área → Yacimiento → Instalación → Subinstalación**.
- Empresa es un maestro independiente; la Operadora se relaciona temporalmente con Área.
- Una Inspección puede tener múltiples Actas, pero sólo un Acta `DRAFT` simultánea.
- Hallazgos directos: Yacimiento, Instalación y Subinstalación, siempre con `OTROS` disponible.
- La APK puede abrir una Inspección reutilizando el lifecycle canónico del backend.
- GEDO/IF oficializa el Informe, pero no activa por sí solo el vencimiento No urgente.
El despliegue debe partir exactamente de API/WEB **0.19.4-1** y ejecutar `scripts/deploy-phase-d5-5.sh`. Antes de modificar producción, el script compila API y web en staging y exige **252 tests** aprobados.
Las reglas detalladas, el procedimiento de cambio seguro y el recorrido de demo están en los documentos enlazados arriba.
+48
View File
@@ -0,0 +1,48 @@
# DH Inspección Android · release F6.1
## Candidata vigente
- Fase funcional: **F6.1**.
- `versionName`: **0.15.1**.
- `versionCode`: **23**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
- Launcher de presentación: adaptación vectorial local inspirada en el escudo institucional publicado por Gobierno de Mendoza en `mendoza.gov.ar`, para reemplazar el ícono genérico del sistema.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
## Barrera obligatoria
Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `Android CI / RC`:
1. validación de identidad, HTTPS y políticas básicas del manifest;
2. Android lint;
3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada;
4. `assembleDebug`;
5. `assembleRelease` para comprobar que la variante productiva compile;
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
La barrera de lint exige además manejo explícito de la revocación de permisos de ubicación durante una captura GPS y declara la cámara como capacidad de hardware opcional, sin relajar permisos ni desactivar reglas globalmente.
El artefacto de CI contiene:
- APK debug;
- archivo `.sha256`;
- `release-metadata.txt` con fase, versión, versionCode, commit, applicationId, API base y canal.
## Firma release
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`.
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
## Criterio de distribución
Antes de distribuir una APK productiva:
- todas las barreras de CI del SHA exacto deben estar verdes;
- comprobar certificado/huella de firma contra la versión histórica;
- realizar actualización sobre al menos una tablet con la versión productiva anterior;
- ejecutar smoke funcional contra el entorno objetivo: login, lista de inspecciones, inicio, inventario, alta de campo, foto/GPS, Acta, Hallazgo y cierre;
- registrar el SHA Git y SHA-256 de la APK distribuida.
+80
View File
@@ -0,0 +1,80 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.korexlabs.dhinspeccion"
compileSdk = 36
defaultConfig {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 25
versionName = "0.16.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// La firma de release NO se redefine aquí. Se conservará la clave histórica.
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions.jvmTarget = "17"
packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.08.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.fragment:fragment-ktx:1.8.9")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.biometric:biometric:1.1.0")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.android.gms:play-services-location:21.3.0")
implementation("androidx.exifinterface:exifinterface:1.4.1")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}
+1
View File
@@ -0,0 +1 @@
# DH Inspección V2. Las reglas se ampliarán cuando se habilite minificación de release.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_mendoza_launcher_exact"
android:roundIcon="@drawable/ic_mendoza_launcher_exact"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.DHInspeccion"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,19 @@
package com.korexlabs.dhinspeccion
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import com.korexlabs.dhinspeccion.ui.DhRoot
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val model: MainViewModel = viewModel()
DhRoot(model, this@MainActivity)
}
}
}
@@ -0,0 +1,652 @@
package com.korexlabs.dhinspeccion
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.FieldAssetDetail
import com.korexlabs.dhinspeccion.data.FieldFindingEvidence
import com.korexlabs.dhinspeccion.data.FieldFindingItem
import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.MobileActClosure
import com.korexlabs.dhinspeccion.data.MobileActDetail
import com.korexlabs.dhinspeccion.data.MobileActSummary
import com.korexlabs.dhinspeccion.data.MobileActsRepository
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
import java.io.File
import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application)
var session: StoredSession? by mutableStateOf(repository.currentSession())
private set
var busy by mutableStateOf(false)
private set
var error: String? by mutableStateOf(null)
private set
var notice: String? by mutableStateOf(null)
private set
var visits: List<VisitSummary> by mutableStateOf(emptyList())
private set
var visit: VisitDetail? by mutableStateOf(null)
private set
var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList())
private set
var inventoryParentId: String? by mutableStateOf(null)
private set
var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
private set
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
private set
var acts: List<MobileActSummary> by mutableStateOf(emptyList())
private set
var selectedAct: MobileActDetail? by mutableStateOf(null)
private set
var actClosure: MobileActClosure? by mutableStateOf(null)
private set
var fieldFindingOptions: FieldFindingOptionsResponse? by mutableStateOf(null)
private set
var lastCreatedFinding: FieldFindingItem? by mutableStateOf(null)
private set
var fieldFindingEvidence: Map<String, List<FieldFindingEvidence>> by mutableStateOf(emptyMap())
private set
init {
if (session != null) loadVisits()
}
fun clearMessages() {
error = null
notice = null
}
fun login(identifier: String, password: String) {
if (identifier.isBlank() || password.isBlank()) {
error = "Ingresá usuario y contraseña."
return
}
launchBusy {
session = repository.login(identifier, password)
notice = "Sesión iniciada."
loadVisitsInternal()
}
}
fun logout() {
viewModelScope.launch {
runCatching { repository.logout() }
session = null
visits = emptyList()
visit = null
inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
clearFindingState()
}
}
fun loadVisits() = launchBusy { loadVisitsInternal() }
private suspend fun loadVisitsInternal() {
visits = repository.visits().data
}
fun openVisit(id: String) = launchBusy {
visit = repository.visit(id)
inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList()
selectedFieldAsset = null
clearFindingState()
loadActsInternal(id, selectDraft = true)
}
fun closeVisitView() {
visit = null
inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
clearFindingState()
loadVisits()
}
fun startVisit() {
val id = visit?.id ?: return
launchBusy {
visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
}
}
fun reloadActs() {
val visitId = visit?.id ?: return
launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) }
}
fun selectAct(actId: String) {
launchBusy {
selectedAct = actsRepository.get(actId)
actClosure = actsRepository.closure(actId)
clearFindingState()
}
}
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."
return
}
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Elegí si el Acta es urgente o no urgente."
return
}
if (acts.any { it.status == "DRAFT" }) {
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,
urgency,
)
selectedAct = created
actClosure = actsRepository.closure(created.id)
loadActsInternal(currentVisit.id, selectDraft = false)
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 (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
}
}
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return
launchBusy {
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
}
}
fun loadFieldTypes(parentId: String? = null) {
val currentVisit = visit ?: return
launchBusy {
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
}
}
private suspend fun reloadCurrentInventory(visitId: String) {
val effectiveParentId = inventoryParentId ?: visit?.scopeAsset?.id ?: visit?.operationalArea?.id
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
}
fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return
launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección."
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, item.id, draft.id)
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
}
}
}
fun createFieldAsset(
type: FieldType,
parentId: String?,
familyId: String?,
name: String,
commonName: String?,
attributes: Map<String, Any?>,
latitude: Double,
longitude: Double,
accuracyM: Double?,
) {
val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") {
error = "La Inspección debe estar en curso para dar de alta Inventario."
return
}
if (type.familyRequired && familyId == null) {
error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
return
}
launchBusy {
val request = CreateFieldInventoryRequest(
typeId = type.id,
parentId = parentId,
familyId = familyId,
name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
deviceLatitude = latitude,
deviceLongitude = longitude,
deviceAccuracyM = accuracyM,
deviceCapturedAt = Instant.now().toString(),
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
reloadCurrentInventory(visitId)
}
}
fun mergeCreatedFieldAsset(canonicalAssetId: String, reason: String) {
val visitId = visit?.id ?: return
val source = selectedFieldAsset?.asset ?: return
if (canonicalAssetId.isBlank()) {
error = "Elegí el registro existente que se conservará."
return
}
if (reason.trim().length < 8) {
error = "Explicá brevemente por qué se trata de un duplicado."
return
}
launchBusy {
val result = repository.mergeFieldAsset(
visitId = visitId,
assetId = source.id,
canonicalAssetId = canonicalAssetId,
reason = reason,
)
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
}
}
}
fun uploadFieldPhoto(
file: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
) {
val visitId = visit?.id ?: return
val asset = selectedFieldAsset?.asset ?: return
launchBusy {
val response = repository.uploadFieldPhoto(
visitId = visitId,
assetId = asset.id,
file = file,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
)
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
notice = if (response.capture.readyForFinding) {
"Captura completa: GPS y fotografía registrados."
} else {
"Fotografía registrada."
}
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (response.capture.readyForFinding && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, asset.id, draft.id)
} else if (response.capture.readyForFinding) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
}
}
}
fun openFindingForSelected() {
val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return
val draft = selectedDraftAct()
if (draft == null) {
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos."
return
}
launchBusy {
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, assetId, draft.id)
}
}
fun createFieldFinding(
catalogItemId: String?,
customTitle: String?,
customLegalBasis: String?,
description: String,
severity: Int?,
correctionDueOn: String?,
) {
val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return
val actId = selectedDraftAct()?.id
if (actId == null) {
error = "No hay un Acta en borrador seleccionada."
return
}
if (description.isBlank()) {
error = "Describí el Hallazgo antes de guardarlo."
return
}
if (catalogItemId == null && customTitle.isNullOrBlank()) {
error = "Para OTROS, indicá un título para el Hallazgo."
return
}
if (severity != null && severity !in 1..10) {
error = "La gravedad debe estar entre 1 y 10."
return
}
launchBusy {
selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create(
visitId,
assetId,
CreateFieldFindingRequest(
actId = actId,
catalogItemId = catalogItemId,
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
description = description.trim(),
severity = severity,
correctionDueOn = correctionDueOn?.trim()?.takeIf { it.isNotBlank() },
),
)
lastCreatedFinding = response.finding
notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica."
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
loadActsInternal(visitId, selectDraft = false)
}
}
fun uploadFindingPhoto(
findingId: String,
file: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
title: String? = null,
description: String? = null,
) {
launchBusy {
findingsRepository.uploadObservationPhoto(
findingId = findingId,
file = file,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
title = title,
description = description,
)
loadEvidenceInternal(findingId)
notice = "Evidencia fotográfica registrada con GPS."
}
}
fun reloadFindingEvidence(findingId: String) {
launchBusy { loadEvidenceInternal(findingId) }
}
fun setCompanyResponsiblePresent(
fullName: String,
documentType: String,
documentNumber: String,
position: String,
email: String?,
phone: String?,
) {
val actId = selectedAct?.id ?: return
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank()) {
error = "Completá nombre, documento y cargo del responsable de la empresa."
return
}
launchBusy {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
attendanceStatus = "PRESENT",
fullName = fullName.trim(),
documentType = documentType,
documentNumber = documentNumber.trim(),
position = position.trim(),
email = email?.trim()?.takeIf { it.isNotBlank() },
phone = phone?.trim()?.takeIf { it.isNotBlank() },
),
)
notice = "Responsable de empresa registrado para el Acta."
}
}
fun setCompanyResponsibleAbsent(reason: String) {
val actId = selectedAct?.id ?: return
if (reason.trim().length < 10) {
error = "Indicá un motivo de ausencia de al menos 10 caracteres."
return
}
launchBusy {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
attendanceStatus = "ABSENT",
absenceReason = reason.trim(),
),
)
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.lock(actId)
refreshSelectedActInternal(actId)
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
}
}
fun reopenSelectedAct() {
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
}
fun signSelectedActAsInspector(
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
) {
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
notice = "Firma del inspector incorporada al Acta."
}
}
fun signSelectedActAsCompany(
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
manifestation: String,
statement: String?,
) {
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.signCompany(
actId, png, latitude, longitude, accuracyM, manifestation, statement,
)
notice = if (manifestation == "DISSENT") {
"Firma de empresa registrada con disidencia."
} else {
"Firma de empresa registrada."
}
}
}
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 = "Negativa a firmar asentada."
}
}
fun closeSelectedAct() {
val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
}
}
fun closeInspection() {
val visitId = visit?.id ?: return
launchBusy {
visit = actsRepository.closeVisit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."
}
}
fun clearFindingFlow() {
fieldFindingOptions = null
lastCreatedFinding = null
fieldFindingEvidence = emptyMap()
error = null
}
fun clearSelectedFieldAsset() {
selectedFieldAsset = null
clearFindingState()
}
private fun selectedDraftAct(): MobileActDetail? =
selectedAct?.takeIf { it.status == "DRAFT" }
?: acts.firstOrNull { it.status == "DRAFT" }?.let { summary ->
selectedAct?.takeIf { it.id == summary.id && it.status == "DRAFT" }
}
private suspend fun loadActsInternal(visitId: String, selectDraft: Boolean) {
acts = actsRepository.list(visitId).data
val currentId = selectedAct?.id
val current = currentId?.let { id -> acts.firstOrNull { it.id == id } }
val target = when {
current != null -> current.id
selectDraft -> acts.firstOrNull { it.status == "DRAFT" }?.id
else -> null
}
if (target != null) {
selectedAct = actsRepository.get(target)
actClosure = actsRepository.closure(target)
} else if (current == null) {
selectedAct = null
actClosure = null
}
}
private suspend fun refreshSelectedActInternal(actId: String) {
selectedAct = actsRepository.get(actId)
actClosure = actsRepository.closure(actId)
visit?.id?.let { loadActsInternal(it, selectDraft = false) }
}
private suspend fun loadFindingOptionsInternal(
visitId: String,
assetId: String,
actId: String,
keepLastCreated: Boolean = false,
) {
val options = findingsRepository.options(visitId, assetId, actId)
fieldFindingOptions = options
if (!keepLastCreated) lastCreatedFinding = null
val loaded = linkedMapOf<String, List<FieldFindingEvidence>>()
for (finding in options.findings) {
loaded[finding.id] = findingsRepository.evidence(finding.id).data
}
fieldFindingEvidence = loaded
}
private suspend fun loadEvidenceInternal(findingId: String) {
fieldFindingEvidence = fieldFindingEvidence + (
findingId to findingsRepository.evidence(findingId).data
)
}
private fun clearActState() {
acts = emptyList()
selectedAct = null
actClosure = null
}
private fun clearFindingState() {
fieldFindingOptions = null
lastCreatedFinding = null
fieldFindingEvidence = emptyMap()
}
private fun launchBusy(block: suspend () -> Unit) {
viewModelScope.launch {
busy = true
error = null
try {
block()
} catch (throwable: Throwable) {
error = DhRepository.humanError(throwable)
if (repository.currentSession() == null) session = null
} finally {
busy = false
}
}
}
}
@@ -0,0 +1,579 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.security.KeyStore
import java.time.Instant
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
// ---------- Auth ----------
data class LoginRequest(
val identifier: String,
val password: String,
val deviceLabel: String = "DH Android",
)
data class RefreshRequest(val refreshToken: String)
data class MobileUser(
val id: String,
val username: String,
val firstName: String? = null,
val lastName: String? = null,
val email: String? = null,
val mustChangePassword: Boolean = false,
val roles: List<String> = emptyList(),
val permissions: List<String> = emptyList(),
)
data class MobileSessionResponse(
val user: MobileUser,
val accessToken: String,
val refreshToken: String,
val accessExpiresInSeconds: Long,
)
data class StoredSession(
val userId: String,
val username: String,
val displayName: String,
val accessToken: String,
val refreshToken: String,
)
// ---------- Inspections ----------
data class AssetSummary(
val id: String,
val code: String,
val name: String,
val typeName: String? = null,
)
data class PersonSummary(
val id: String,
val username: String? = null,
val firstName: String? = null,
val lastName: String? = null,
)
data class VisitSummary(
val id: String,
val code: String,
val objective: String? = null,
val status: String,
val scopeAsset: AssetSummary? = null,
val operationalArea: AssetSummary? = null,
val operatorCompany: AssetSummary? = null,
val leadInspector: PersonSummary? = null,
val plannedStartAt: String? = null,
val actualStartedAt: String? = null,
val actualClosedAt: String? = null,
val instructions: String? = null,
val checklistGeneration: Int = 0,
val assetCount: Int = 0,
val memberCount: Int = 0,
)
data class VisitMeta(
val page: Int,
val pageSize: Int,
val total: Int,
val totalPages: Int,
)
data class VisitListResponse(val data: List<VisitSummary>, val meta: VisitMeta)
data class PlannedAsset(
val id: String,
val code: String,
val name: String,
val typeName: String? = null,
val included: Boolean = true,
val planningSource: String? = null,
val exclusionReason: String? = null,
)
data class ChecklistItem(
val id: String,
val findingId: String? = null,
val findingCode: String? = null,
val findingTitle: String? = null,
val findingStatus: String? = null,
val severity: Int? = null,
val itemKind: String? = null,
val referenceOn: String? = null,
val asset: AssetSummary? = null,
val assetIncluded: Boolean = true,
)
data class ChecklistSummary(
val generation: Int = 0,
val stale: Boolean = false,
val antecedents: Int = 0,
val companyOverdue: Int = 0,
val verificationOverdue: Int = 0,
val upcomingControls: Int = 0,
val actionableAssets: Int = 0,
val excludedAssets: Int = 0,
val items: List<ChecklistItem> = emptyList(),
)
data class VisitDetail(
val id: String,
val code: String,
val objective: String? = null,
val status: String,
val scopeAsset: AssetSummary? = null,
val operationalArea: AssetSummary? = null,
val operatorCompany: AssetSummary? = null,
val leadInspector: PersonSummary? = null,
val plannedStartAt: String? = null,
val actualStartedAt: String? = null,
val actualClosedAt: String? = null,
val instructions: String? = null,
val assets: List<AssetSummary> = emptyList(),
val planningAssets: List<PlannedAsset> = emptyList(),
val team: List<PersonSummary> = emptyList(),
val checklist: ChecklistSummary = ChecklistSummary(),
)
// ---------- Field inventory ----------
data class FieldContext(
val visitId: String? = null,
val visitCode: String? = null,
val areaId: String? = null,
val areaCode: String? = null,
val areaName: String? = null,
val companyId: String? = null,
val companyCode: String? = null,
val companyName: String? = null,
)
data class FieldInventoryItem(
val id: String,
val code: String,
val name: String,
val commonName: String? = null,
val informationStatus: String? = null,
val dataOrigin: String? = null,
val type: AssetSummary? = null,
val parent: AssetSummary? = null,
val selectedInInspection: Boolean = false,
val captureRequired: Boolean = false,
val hasGeometry: Boolean = false,
val fieldPhotoCount: Int = 0,
val readyForFinding: Boolean = true,
)
data class FieldInventoryListResponse(
val context: FieldContext,
val data: List<FieldInventoryItem>,
)
data class FieldAttributeDefinition(
val id: String,
val code: String,
val name: String,
val dataType: String,
val isRequired: Boolean = false,
val unit: String? = null,
val options: Any? = null,
val sortOrder: Int = 0,
)
data class FieldInventoryFamily(
val id: String,
val code: String,
val name: String,
val level: String,
val informationLabels: List<String> = emptyList(),
val sourceReference: String? = null,
val isOther: Boolean = false,
)
data class FieldType(
val id: String,
val code: String,
val name: String,
val description: String? = null,
val structuralKind: String? = null,
val families: List<FieldInventoryFamily> = emptyList(),
val familyRequired: Boolean = false,
val attributes: List<FieldAttributeDefinition> = emptyList(),
)
data class FieldTypeResponse(
val context: FieldContext,
val parent: AssetSummary,
val data: List<FieldType>,
)
data class CaptureStatus(
val captureRequired: Boolean = false,
val hasGeometry: Boolean = false,
val creationGpsCaptured: Boolean = false,
val fieldPhotoCount: Int = 0,
val readyForFinding: Boolean = true,
)
data class FieldAssetDetail(
val context: FieldContext? = null,
val asset: FieldInventoryItem,
val selectedInInspection: Boolean = true,
val capture: CaptureStatus = CaptureStatus(),
)
data class CreateFieldInventoryRequest(
val typeId: String,
val parentId: String? = null,
val familyId: String? = null,
val code: String? = null,
val name: String,
val commonName: String? = null,
val description: String? = null,
val discoveryNotes: String? = null,
val attributes: Map<String, Any?>,
val deviceLatitude: Double,
val deviceLongitude: Double,
val deviceAccuracyM: Double? = null,
val deviceCapturedAt: String,
val deviceLabel: String = "DH Android",
)
data class FieldPhotoResponse(
val capture: CaptureStatus,
)
data class MergeFieldInventoryRequest(
val canonicalAssetId: String,
val reason: String,
)
data class FieldInventoryMergeResult(
val source: AssetSummary,
val canonical: AssetSummary,
val sourceVersionNumber: Int,
val reparentedChildIds: List<String> = emptyList(),
val historyPolicy: String,
)
interface DhApi {
@POST("auth/mobile/login")
suspend fun login(@Body request: LoginRequest): MobileSessionResponse
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
@POST("auth/mobile/logout")
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Any?>
@GET("inspection-visits")
suspend fun visits(
@Header("Authorization") authorization: String,
@Query("inspectorId") inspectorId: String,
@Query("pageSize") pageSize: Int = 100,
): VisitListResponse
@GET("inspection-visits/{id}")
suspend fun visit(
@Header("Authorization") authorization: String,
@Path("id") id: String,
): VisitDetail
@POST("inspection-visits/{id}/start")
suspend fun startVisit(
@Header("Authorization") authorization: String,
@Path("id") id: String,
): VisitDetail
@GET("inspection-visits/{visitId}/field-inventory")
suspend fun fieldInventory(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Query("search") search: String? = null,
@Query("parentId") parentId: String? = null,
@Query("limit") limit: Int = 80,
): FieldInventoryListResponse
@GET("inspection-visits/{visitId}/field-inventory/types")
suspend fun fieldTypes(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Query("parentId") parentId: String? = null,
): FieldTypeResponse
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/select")
suspend fun selectFieldAsset(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
): FieldAssetDetail
@POST("inspection-visits/{visitId}/field-inventory")
suspend fun createFieldAsset(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Body request: CreateFieldInventoryRequest,
): FieldAssetDetail
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/merge")
suspend fun mergeFieldAsset(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
@Body request: MergeFieldInventoryRequest,
): FieldInventoryMergeResult
@Multipart
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/photos")
suspend fun uploadFieldPhoto(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
@Part file: MultipartBody.Part,
@Part("deviceLatitude") latitude: okhttp3.RequestBody,
@Part("deviceLongitude") longitude: okhttp3.RequestBody,
@Part("deviceAccuracyM") accuracy: okhttp3.RequestBody?,
@Part("deviceCapturedAt") capturedAt: okhttp3.RequestBody,
@Part("deviceLabel") deviceLabel: okhttp3.RequestBody,
@Part("exifLatitude") exifLatitude: okhttp3.RequestBody?,
@Part("exifLongitude") exifLongitude: okhttp3.RequestBody?,
@Part("exifCapturedAt") exifCapturedAt: okhttp3.RequestBody?,
): FieldPhotoResponse
}
class SecureSessionStore(context: Context) {
private val prefs = context.getSharedPreferences("dh_v2_mobile_session", Context.MODE_PRIVATE)
private val alias = "dh_v2_mobile_session_key"
fun load(): StoredSession? {
val encoded = prefs.getString("payload", null) ?: return null
return runCatching {
val parts = encoded.split('.', limit = 2)
require(parts.size == 2)
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val encrypted = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, iv))
val json = JSONObject(String(cipher.doFinal(encrypted), Charsets.UTF_8))
StoredSession(
userId = json.getString("userId"),
username = json.getString("username"),
displayName = json.optString("displayName", json.getString("username")),
accessToken = json.getString("accessToken"),
refreshToken = json.getString("refreshToken"),
)
}.getOrElse {
clear()
null
}
}
fun save(response: MobileSessionResponse): StoredSession {
val displayName = listOfNotNull(response.user.firstName, response.user.lastName)
.joinToString(" ").trim().ifBlank { response.user.username }
val stored = StoredSession(
userId = response.user.id,
username = response.user.username,
displayName = displayName,
accessToken = response.accessToken,
refreshToken = response.refreshToken,
)
val json = JSONObject()
.put("userId", stored.userId)
.put("username", stored.username)
.put("displayName", stored.displayName)
.put("accessToken", stored.accessToken)
.put("refreshToken", stored.refreshToken)
.toString()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key())
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
val payload = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + "." +
Base64.encodeToString(encrypted, Base64.NO_WRAP)
prefs.edit().putString("payload", payload).apply()
return stored
}
fun clear() {
prefs.edit().clear().apply()
}
private fun key(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keyStore.getKey(alias, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(
KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build(),
)
return generator.generateKey()
}
}
class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(DhApi::class.java)
fun currentSession(): StoredSession? = store.load()
suspend fun login(identifier: String, password: String): StoredSession =
store.save(api.login(LoginRequest(identifier.trim(), password)))
suspend fun logout() {
val session = store.load()
if (session != null) runCatching { api.logout("Bearer ${session.accessToken}") }
store.clear()
}
suspend fun visits(): VisitListResponse = authorized { session ->
api.visits("Bearer ${session.accessToken}", session.userId)
}
suspend fun visit(id: String): VisitDetail = authorized { session ->
api.visit("Bearer ${session.accessToken}", id)
}
suspend fun startVisit(id: String): VisitDetail = authorized { session ->
api.startVisit("Bearer ${session.accessToken}", id)
}
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session ->
api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId)
}
suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session ->
api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId)
}
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
}
suspend fun createFieldAsset(visitId: String, request: CreateFieldInventoryRequest) = authorized { session ->
api.createFieldAsset("Bearer ${session.accessToken}", visitId, request)
}
suspend fun mergeFieldAsset(
visitId: String,
assetId: String,
canonicalAssetId: String,
reason: String,
): FieldInventoryMergeResult = authorized { session ->
api.mergeFieldAsset(
"Bearer ${session.accessToken}",
visitId,
assetId,
MergeFieldInventoryRequest(canonicalAssetId, reason.trim()),
)
}
suspend fun uploadFieldPhoto(
visitId: String,
assetId: String,
file: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
capturedAt: String = Instant.now().toString(),
): FieldPhotoResponse = authorized { session ->
val text = "text/plain".toMediaType()
val body = file.asRequestBody("image/jpeg".toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, body)
api.uploadFieldPhoto(
authorization = "Bearer ${session.accessToken}",
visitId = visitId,
assetId = assetId,
file = part,
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
companion object {
fun humanError(error: Throwable): String {
if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
if (!message.isNullOrBlank()) return message
return "Error HTTP ${error.code()}"
}
return error.message ?: "Ocurrió un error inesperado"
}
fun newOperationId(): String = UUID.randomUUID().toString()
}
}
@@ -0,0 +1,258 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.time.Instant
data class FieldFindingAct(
val id: String,
val code: String,
val status: String,
)
data class FieldFindingCatalogItem(
val id: String,
val categoryId: String,
val code: String,
val sourceNumber: Int,
val title: String,
val legalBasis: String? = null,
val glossary: String? = null,
val suggestedSeverity: Int? = null,
val revision: Int = 1,
val categoryName: String? = null,
)
data class FieldFindingOther(
val enabled: Boolean = true,
val code: String = "OTHER",
val label: String = "OTROS",
val help: String? = null,
)
data class FieldFindingCatalog(
val typeConfigured: Boolean = false,
val configurationReason: String? = null,
val items: List<FieldFindingCatalogItem> = emptyList(),
val other: FieldFindingOther = FieldFindingOther(),
)
data class FieldFindingItem(
val id: String,
val actId: String,
val assetId: String,
val catalogItemId: String? = null,
val findingNumber: Int,
val code: String,
val status: String,
val title: String,
val description: String,
val severity: Int? = null,
val suggestedSeverity: Int? = null,
val correctionDueOn: String? = null,
)
data class FieldFindingEvidence(
val id: String,
val findingId: String,
val kind: String,
val purpose: String,
val originalName: String,
val mimeType: String,
val sizeBytes: Long,
val sha256: String,
val title: String? = null,
val description: String? = null,
val capturedAt: String? = null,
val latitude: Double? = null,
val longitude: Double? = null,
val accuracyM: Double? = null,
val deviceLabel: String? = null,
val source: String,
val createdAt: String,
)
data class FieldFindingEvidenceListResponse(
val data: List<FieldFindingEvidence> = emptyList(),
)
data class FieldFindingOptionsResponse(
val act: FieldFindingAct,
val capture: CaptureStatus = CaptureStatus(),
val assetIncludedInAct: Boolean = true,
val catalog: FieldFindingCatalog,
val findings: List<FieldFindingItem> = emptyList(),
val canAddAnother: Boolean = true,
val actSelectionMode: String = "EXPLICIT",
)
data class CreateFieldFindingRequest(
val actId: String,
val catalogItemId: String? = null,
val customTitle: String? = null,
val customLegalBasis: String? = null,
val description: String,
val severity: Int? = null,
val correctionDueOn: String? = null,
)
data class FieldFindingCreateResponse(
val act: FieldFindingAct,
val capture: CaptureStatus = CaptureStatus(),
val finding: FieldFindingItem,
val canAddAnother: Boolean = true,
val actSelectionMode: String = "EXPLICIT",
)
private interface FieldFindingsApi {
@GET("inspection-visits/{visitId}/field-findings/{assetId}/options")
suspend fun options(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
@Query("actId") actId: String,
): FieldFindingOptionsResponse
@POST("inspection-visits/{visitId}/field-findings/{assetId}")
suspend fun create(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
@Body request: CreateFieldFindingRequest,
): FieldFindingCreateResponse
@GET("inspection-findings/{findingId}/evidence")
suspend fun evidence(
@Header("Authorization") authorization: String,
@Path("findingId") findingId: String,
): FieldFindingEvidenceListResponse
@Multipart
@POST("inspection-findings/{findingId}/evidence")
suspend fun uploadEvidence(
@Header("Authorization") authorization: String,
@Path("findingId") findingId: String,
@Part file: MultipartBody.Part,
@Part("kind") kind: RequestBody,
@Part("purpose") purpose: RequestBody,
@Part("title") title: RequestBody?,
@Part("description") description: RequestBody?,
@Part("capturedAt") capturedAt: RequestBody,
@Part("latitude") latitude: RequestBody,
@Part("longitude") longitude: RequestBody,
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
): FieldFindingEvidence
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
/**
* Cliente de campo para Hallazgos y sus evidencias append-only.
* F3.2 exige que la APK identifique explícitamente el Acta activa.
*/
class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(FieldFindingsApi::class.java)
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse =
authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
}
suspend fun create(
visitId: String,
assetId: String,
request: CreateFieldFindingRequest,
): FieldFindingCreateResponse = authorized { session ->
api.create("Bearer ${session.accessToken}", visitId, assetId, request)
}
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session ->
api.evidence("Bearer ${session.accessToken}", findingId)
}
suspend fun uploadObservationPhoto(
findingId: String,
file: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
title: String? = null,
description: String? = null,
capturedAt: String = Instant.now().toString(),
): FieldFindingEvidence = authorized { session ->
val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData(
"file",
file.name,
file.asRequestBody("image/jpeg".toMediaType()),
)
api.uploadEvidence(
authorization = "Bearer ${session.accessToken}",
findingId = findingId,
file = part,
kind = "PHOTO".toRequestBody(text),
purpose = "OBSERVATION".toRequestBody(text),
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -0,0 +1,471 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.time.Instant
data class MobileActSummary(
val id: String,
val visitId: String,
val code: String,
val status: String,
val occurredAt: String,
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,
val assetCount: Int = 0,
val findingCount: Int = 0,
)
data class MobileActDetail(
val id: String,
val visitId: String,
val code: String,
val status: String,
val occurredAt: String,
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,
val assetCount: Int = 0,
val findingCount: Int = 0,
val assets: List<AssetSummary> = emptyList(),
)
data class MobileActListMeta(
val page: Int = 1,
val pageSize: Int = 100,
val total: Int = 0,
val totalPages: Int = 0,
)
data class MobileActListResponse(
val data: List<MobileActSummary> = emptyList(),
val meta: MobileActListMeta = MobileActListMeta(),
)
data class CreateMobileActRequest(
val occurredAt: String,
val urgency: String,
val title: String,
val summary: String,
val observations: String? = null,
val assetIds: List<String>,
)
data class UpdateMobileActRequest(
val assetIds: List<String>,
)
data class MobileResponsibleRequest(
val attendanceStatus: String,
val fullName: String? = null,
val documentType: String? = null,
val documentNumber: String? = null,
val position: String? = null,
val email: String? = null,
val phone: String? = null,
val absenceReason: String? = null,
)
data class MobileResponsible(
val actId: String,
val attendanceStatus: String,
val fullName: String? = null,
val documentType: String? = null,
val documentNumber: String? = null,
val position: String? = null,
val email: String? = null,
val phone: String? = null,
val absenceReason: String? = null,
)
data class MobileActClosureHeader(
val id: String,
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,
)
data class MobileVisitClosureHeader(
val id: String,
val code: String,
val status: String,
val actualClosedAt: String? = null,
)
data class MobileSignature(
val id: String,
val signerType: String,
val signerUserId: String? = null,
val signerName: String,
val status: String,
val reason: String? = null,
val companyManifestation: String? = null,
val companyStatement: String? = null,
val signedAt: String? = null,
val imageSha256: String? = null,
)
data class MobileClosureRecord(
val schemaVersion: String,
val preparedSha256: String,
val preparedAt: String,
val finalSha256: String? = null,
val deviceClosedAt: String? = null,
val serverClosedAt: String? = null,
val uploadMode: String? = null,
val isCurrent: Boolean = true,
)
data class MobileClosureConsents(
val version: String = "",
val inspector: String = "",
val company: String = "",
)
data class MobileActClosure(
val act: MobileActClosureHeader,
val visit: MobileVisitClosureHeader,
val responsible: MobileResponsible? = null,
val closure: MobileClosureRecord? = null,
val signatures: List<MobileSignature> = emptyList(),
val consents: MobileClosureConsents = MobileClosureConsents(),
)
data class MobileCompanyOutcomeRequest(
val status: String,
val reason: String,
)
data class MobileSealActRequest(
val clientClosedAt: String = Instant.now().toString(),
val uploadMode: String = "ONLINE",
)
data class MobileCloseVisitRequest(
val clientClosedAt: String = Instant.now().toString(),
)
private interface MobileActsApi {
@GET("inspection-visits/{visitId}/acts")
suspend fun listActs(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Query("pageSize") pageSize: Int = 100,
): MobileActListResponse
@GET("inspection-acts/{actId}")
suspend fun act(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActDetail
@POST("inspection-visits/{visitId}/acts")
suspend fun createAct(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Body request: CreateMobileActRequest,
): MobileActDetail
@PATCH("inspection-acts/{actId}")
suspend fun updateAct(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: UpdateMobileActRequest,
): MobileActDetail
@GET("inspection-acts/{actId}/closure")
suspend fun closure(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActClosure
@PUT("inspection-acts/{actId}/responsible")
suspend fun responsible(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: MobileResponsibleRequest,
): MobileActClosure
@POST("inspection-acts/{actId}/lock")
suspend fun lock(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActClosure
@Multipart
@POST("inspection-acts/{actId}/signatures/inspector")
suspend fun signInspector(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Part file: MultipartBody.Part,
@Part("consentAccepted") consentAccepted: RequestBody,
@Part("clientSignedAt") clientSignedAt: RequestBody,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
): MobileActClosure
@Multipart
@POST("inspection-acts/{actId}/signatures/company")
suspend fun signCompany(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Part file: MultipartBody.Part,
@Part("consentAccepted") consentAccepted: RequestBody,
@Part("clientSignedAt") clientSignedAt: RequestBody,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
@Part("manifestation") manifestation: RequestBody?,
@Part("statement") statement: RequestBody?,
): MobileActClosure
@POST("inspection-acts/{actId}/company-outcome")
suspend fun companyOutcome(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: MobileCompanyOutcomeRequest,
): MobileActClosure
@POST("inspection-acts/{actId}/seal")
suspend fun sealAct(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: MobileSealActRequest,
): MobileActClosure
@POST("inspection-visits/{visitId}/close")
suspend fun closeVisit(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Body request: MobileCloseVisitRequest,
): VisitDetail
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileActsApi::class.java)
suspend fun list(visitId: String): MobileActListResponse = authorized { session ->
api.listActs("Bearer ${session.accessToken}", visitId)
}
suspend fun get(actId: String): MobileActDetail = authorized { session ->
api.act("Bearer ${session.accessToken}", actId)
}
suspend fun create(
visitId: String,
assetId: String? = null,
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 inspección.",
assetIds = listOfNotNull(assetId),
),
)
}
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
val detail = get(actId)
if (detail.assets.any { it.id == assetId }) return detail
val ids = (detail.assets.map { it.id } + assetId).distinct()
return authorized { session ->
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(ids))
}
}
suspend fun closure(actId: String): MobileActClosure = authorized { session ->
api.closure("Bearer ${session.accessToken}", actId)
}
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session ->
api.responsible("Bearer ${session.accessToken}", actId, request)
}
suspend fun lock(actId: String): MobileActClosure = authorized { session ->
api.lock("Bearer ${session.accessToken}", actId)
}
suspend fun signInspector(
actId: String,
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
): MobileActClosure = signature(actId, png, latitude, longitude, accuracyM, company = false)
suspend fun signCompany(
actId: String,
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
manifestation: String = "CONFORMITY",
statement: String? = null,
): MobileActClosure = signature(
actId = actId,
png = png,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
company = true,
manifestation = manifestation,
statement = statement,
)
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session ->
api.companyOutcome(
"Bearer ${session.accessToken}",
actId,
MobileCompanyOutcomeRequest(status, reason.trim()),
)
}
suspend fun seal(actId: String): MobileActClosure = authorized { session ->
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
}
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
}
private suspend fun signature(
actId: String,
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
company: Boolean,
manifestation: String? = null,
statement: String? = null,
): MobileActClosure = authorized { session ->
val text = "text/plain".toMediaType()
val file = MultipartBody.Part.createFormData(
"file",
png.name,
png.asRequestBody("image/png".toMediaType()),
)
val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text)
if (company) {
api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
lat, lon, accuracy, device,
manifestation?.toRequestBody(text),
statement?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
)
} else {
api.signInspector(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
lat, lon, accuracy, device,
)
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -0,0 +1,103 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
data class MobilePlanningAsset(
val id: String,
val code: String,
val name: String,
)
data class MobilePlanningAssetResponse(
val data: List<MobilePlanningAsset> = emptyList(),
)
data class OpenMobileInspectionRequest(
val operationalAreaId: String,
val operatorCompanyId: String,
)
private interface MobileInspectionOpenApi {
@GET("inspection-visits/mobile/planning-context/areas")
suspend fun areas(
@Header("Authorization") authorization: String,
): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators")
suspend fun operators(
@Header("Authorization") authorization: String,
@Path("areaId") areaId: String,
): MobilePlanningAssetResponse
@POST("inspection-visits/mobile/open")
suspend fun open(
@Header("Authorization") authorization: String,
@Body request: OpenMobileInspectionRequest,
): VisitDetail
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
class MobileInspectionOpenRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileInspectionOpenApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileInspectionOpenApi::class.java)
suspend fun areas(): MobilePlanningAssetResponse = authorized { session ->
api.areas("Bearer ${session.accessToken}")
}
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session ->
api.operators("Bearer ${session.accessToken}", areaId)
}
suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session ->
api.open(
"Bearer ${session.accessToken}",
OpenMobileInspectionRequest(areaId, companyId),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -0,0 +1,583 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class GeoSnapshot(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun DhApp(model: MainViewModel) {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
when {
model.session == null -> LoginScreen(model)
model.visit == null -> VisitsScreen(model)
else -> VisitRouter(model)
}
}
}
}
@Composable
private fun MessageStrip(model: MainViewModel) {
val error = model.error
val notice = model.notice
if (error != null || notice != null) {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
colors = CardDefaults.cardColors(
containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.secondaryContainer,
),
onClick = { model.clearMessages() },
) {
Text(
text = error ?: notice.orEmpty(),
modifier = Modifier.padding(12.dp),
)
}
}
}
@Composable
private fun LoginScreen(model: MainViewModel) {
var identifier by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos")
MessageStrip(model)
OutlinedTextField(
value = identifier,
onValueChange = { identifier = it },
label = { Text("Usuario o email") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Contraseña") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
Button(
onClick = { model.login(identifier, password) },
modifier = Modifier.fillMaxWidth(),
enabled = !model.busy,
) {
if (model.busy) CircularProgressIndicator(modifier = Modifier.width(20.dp).height(20.dp))
else Text("Ingresar")
}
Text(
"El acceso móvil está reservado a usuarios con rol Inspector.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@Composable
private fun VisitsScreen(model: MainViewModel) {
val session = model.session ?: return
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy) { Text("Actualizar") }
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }) { Text("Salir") }
}
}
Column(Modifier.padding(horizontal = 16.dp)) { MessageStrip(model) }
if (model.busy && model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tenés inspecciones asignadas.")
}
} else {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(model.visits, key = { it.id }) { visit ->
VisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
}
@Composable
private fun VisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visit.status)
}
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let { Text("Prevista: ${shortDate(it)}", style = MaterialTheme.typography.bodySmall) }
Text("Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}", style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun VisitRouter(model: MainViewModel) {
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
if (inventoryMode) {
FieldInventoryScreen(model) { inventoryMode = false }
} else {
VisitScreen(model) { inventoryMode = true }
}
}
@Composable
private fun VisitScreen(model: MainViewModel, onInventory: () -> Unit) {
val visit = model.visit ?: return
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 30.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = { model.closeVisitView() }) { Text("Volver") }
Text(visit.status, fontWeight = FontWeight.Bold)
}
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.instructions?.takeIf { it.isNotBlank() }?.let {
Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { Text("Instrucciones", fontWeight = FontWeight.Bold); Text(it) } }
}
MessageStrip(model)
if (visit.status == "PLANNED") {
Button(onClick = { model.startVisit() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) {
Text("Iniciar inspección")
}
}
if (visit.status == "PLANNED" || visit.status == "IN_PROGRESS") {
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { Text("Inventario de campo") }
}
ChecklistCard(visit)
Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (visit.planningAssets.isEmpty()) Text("Sin elementos planificados.")
visit.planningAssets.filter { it.included }.forEach { asset ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp)) {
Text(asset.name, fontWeight = FontWeight.SemiBold)
Text("${asset.code} · ${asset.typeName.orEmpty()}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun ChecklistCard(visit: VisitDetail) {
val checklist = visit.checklist
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Checklist de antecedentes", fontWeight = FontWeight.Bold)
Text("Vencidos empresa: ${checklist.companyOverdue} · Verificaciones vencidas: ${checklist.verificationOverdue}")
Text("Antecedentes: ${checklist.antecedents} · Próximos controles: ${checklist.upcomingControls}")
if (checklist.stale) Text("El checklist requiere revisión/actualización.", color = MaterialTheme.colorScheme.error)
checklist.items.take(10).forEach { item ->
HorizontalDivider()
Text(item.findingTitle ?: item.findingCode ?: "Hallazgo", fontWeight = FontWeight.SemiBold)
Text("${item.asset?.name.orEmpty()} · Gravedad ${item.severity ?: "s/d"}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
@Composable
private fun FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val visit = model.visit ?: return
var search by rememberSaveable { mutableStateOf("") }
var showCreate by rememberSaveable { mutableStateOf(false) }
var parentId by rememberSaveable { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable { mutableStateOf("Área de la inspección") }
var name by rememberSaveable { mutableStateOf("") }
var commonName by rememberSaveable { mutableStateOf("") }
var selectedTypeId by rememberSaveable { mutableStateOf<String?>(null) }
val attributeValues = remember { mutableStateMapOf<String, String>() }
LaunchedEffect(visit.id) {
model.searchInventory("")
model.loadFieldTypes(null)
}
LaunchedEffect(model.fieldTypes) {
if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = model.fieldTypes.firstOrNull()?.id
attributeValues.clear()
}
}
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val createWithLocation: () -> Unit = {
val type = selectedType
if (type != null) {
scope.launch {
runCatching { currentGeo(context) }
.onSuccess { geo ->
val values = buildAttributes(type, attributeValues)
model.createFieldAsset(type, parentId, name, commonName, values, geo.latitude, geo.longitude, geo.accuracyM)
}
}
}
}
val locationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val allowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (allowed) createWithLocation()
}
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<GeoSnapshot?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
val geo = pendingPhotoGeo
if (success && file != null && geo != null) {
runCatching { writeExif(file, geo) }
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
}
pendingPhotoFile = null
pendingPhotoGeo = null
}
val beginPhoto: () -> Unit = {
scope.launch {
runCatching { currentGeo(context) }.onSuccess { geo ->
val (file, uri) = newPhoto(context)
pendingPhotoFile = file
pendingPhotoGeo = geo
takePicture.launch(uri)
}
}
}
val photoPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val camera = result[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA)
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || hasLocation(context)
if (camera && location) beginPhoto()
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedButton(onClick = onBack) { Text("Volver") }
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Column(Modifier.padding(horizontal = 16.dp)) { MessageStrip(model) }
val selectedCapture = model.selectedFieldAsset
if (selectedCapture != null) {
CaptureCard(
detailName = selectedCapture.asset.name,
captureRequired = selectedCapture.capture.captureRequired,
gps = selectedCapture.capture.creationGpsCaptured,
photos = selectedCapture.capture.fieldPhotoCount,
ready = selectedCapture.capture.readyForFinding,
onPhoto = {
val permissions = arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
if (hasPermission(context, Manifest.permission.CAMERA) && hasLocation(context)) beginPhoto()
else photoPermissionLauncher.launch(permissions)
},
onClose = { model.clearSelectedFieldAsset() },
)
}
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar por nombre, código o atributo") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
}
if (visit.status == "IN_PROGRESS") {
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Alta en campo", fontWeight = FontWeight.Bold)
OutlinedButton(onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
}) { Text(if (showCreate) "Ocultar" else "Crear nuevo") }
}
}
if (showCreate && visit.status == "IN_PROGRESS") {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp).verticalScroll(rememberScrollState()).weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
if (parentId != null) {
OutlinedButton(onClick = {
parentId = null
parentLabel = "Área de la inspección"
model.loadFieldTypes(null)
}) { Text("Volver al Área") }
}
if (model.fieldTypes.isEmpty()) Text("No hay tipos habilitados debajo de este padre.")
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type ->
AssistChip(
onClick = { selectedTypeId = type.id; attributeValues.clear() },
label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) },
)
}
}
OutlinedTextField(name, { name = it }, label = { Text("Nombre o código identificable *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(commonName, { commonName = it }, label = { Text("Nombre común") }, modifier = Modifier.fillMaxWidth())
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = Modifier.fillMaxWidth(),
)
}
val requiredReady = selectedType?.attributes?.filter { it.isRequired }?.all { attributeValues[it.code].orEmpty().isNotBlank() } ?: false
Button(
onClick = {
if (hasLocation(context)) createWithLocation()
else locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
},
enabled = selectedType != null && name.isNotBlank() && requiredReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Capturar GPS y crear") }
HorizontalDivider()
}
}
Text("Resultados", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(model.inventory, key = { it.id }) { item ->
InventoryCard(
item = item,
canModify = visit.status == "IN_PROGRESS",
onSelect = { model.selectExisting(item) },
onUseParent = {
parentId = item.id
parentLabel = "${item.name} (${item.code})"
showCreate = true
selectedTypeId = null
model.loadFieldTypes(item.id)
},
)
}
item { Spacer(Modifier.height(30.dp)) }
}
}
}
@Composable
private fun CaptureCard(
detailName: String,
captureRequired: Boolean,
gps: Boolean,
photos: Int,
ready: Boolean,
onPhoto: () -> Unit,
onClose: () -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(detailName, fontWeight = FontWeight.Bold)
OutlinedButton(onClick = onClose) { Text("Cerrar") }
}
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (captureRequired && !ready) {
Text("El Hallazgo permanece bloqueado hasta completar GPS + foto.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
} else if (ready) {
Text("Captura completa · habilitado para Hallazgos", color = MaterialTheme.colorScheme.primary)
}
}
}
}
@Composable
private fun InventoryCard(item: FieldInventoryItem, canModify: Boolean, onSelect: () -> Unit, onUseParent: () -> Unit) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(item.name, fontWeight = FontWeight.SemiBold)
Text("${item.code} · ${item.type?.name.orEmpty()}", style = MaterialTheme.typography.bodySmall)
item.commonName?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text(
if (item.readyForFinding) "Listo" else "Captura incompleta: GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
if (canModify) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onSelect) { Text(if (item.selectedInInspection) "Abrir" else "Seleccionar") }
OutlinedButton(onClick = onUseParent) { Text("Crear hijo") }
}
}
}
}
}
private fun buildAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceAttribute(definition, raw)
}.toMap()
private fun coerceAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun hasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun hasLocation(context: Context): Boolean =
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!hasLocation(context)) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
private fun newPhoto(context: Context): Pair<File, Uri> {
val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
?: throw IllegalStateException("No se pudo acceder al almacenamiento de fotografías.")
directory.mkdirs()
val file = File.createTempFile("DH_${System.currentTimeMillis()}_", ".jpg", directory)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
return file to uri
}
private fun writeExif(file: File, geo: GeoSnapshot) {
val now = Instant.now()
val exif = ExifInterface(file)
exif.setLatLong(geo.latitude, geo.longitude)
val formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneId.systemDefault())
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formatter.format(now))
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, formatter.format(now))
exif.saveAttributes()
}
private fun shortDate(value: String): String = value.replace('T', ' ').take(16)
@@ -0,0 +1,786 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class F3GeoSnapshot(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun F3VisitRoot(model: MainViewModel) {
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
var actsMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
when {
actsMode -> MobileActsScreen(
model = model,
onBack = { actsMode = false },
onGoInventory = {
actsMode = false
inventoryMode = true
},
)
inventoryMode -> F3FieldInventoryScreen(model, onBack = {
inventoryMode = false
actsMode = true
})
else -> F3VisitOverview(
model = model,
onInventory = { inventoryMode = true },
onActs = { actsMode = true },
)
}
}
@Composable
private fun F3VisitOverview(
model: MainViewModel,
onInventory: () -> Unit,
onActs: () -> Unit,
) {
val visit = model.visit ?: return
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 30.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = { model.closeVisitView() }) { Text("Volver") }
Text(statusLabel(visit.status), fontWeight = FontWeight.Bold)
}
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.scopeAsset?.let {
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
visit.plannedStartAt?.let {
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
}
visit.instructions?.takeIf { it.isNotBlank() }?.let {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text("Instrucciones", fontWeight = FontWeight.Bold)
Text(it)
}
}
}
F3MessageStrip(model)
if (visit.status == "PLANNED") {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
Text("Al iniciar, pasás directamente a las Actas de esta inspección.")
Button(
onClick = { model.startVisit(); onActs() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
}
}
}
if (visit.status == "IN_PROGRESS") {
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Inventario / Hallazgos")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Text("Ver Actas · ${model.acts.size}")
}
} else if (visit.status == "PLANNED") {
Text(
"Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.",
style = MaterialTheme.typography.bodySmall,
)
}
F3ChecklistCard(visit)
Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (visit.planningAssets.none { it.included }) {
Text("Sin elementos planificados.")
}
visit.planningAssets.filter { it.included }.forEach { asset ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp)) {
Text(asset.name, fontWeight = FontWeight.SemiBold)
Text("${asset.code} · ${asset.typeName.orEmpty()}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun F3ChecklistCard(visit: VisitDetail) {
val checklist = visit.checklist
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Antecedentes antes de salir", fontWeight = FontWeight.Bold)
Text("Vencidos empresa: ${checklist.companyOverdue} · Verificaciones vencidas: ${checklist.verificationOverdue}")
Text("Antecedentes: ${checklist.antecedents} · Próximos controles: ${checklist.upcomingControls}")
if (checklist.stale) {
Text("El checklist requiere revisión/actualización.", color = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val visit = model.visit ?: return
var search by rememberSaveable(visit.id) { mutableStateOf("") }
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) {
mutableStateOf(visit.scopeAsset?.name ?: "Yacimiento de la inspección")
}
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
val attributeValues = remember { mutableStateMapOf<String, String>() }
var mergeSearch by rememberSaveable(visit.id) { mutableStateOf("") }
var mergeCandidateId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var mergeReason by rememberSaveable(visit.id) { mutableStateOf("") }
LaunchedEffect(visit.id) {
model.searchInventory("")
model.loadFieldTypes(null)
}
LaunchedEffect(model.fieldTypes) {
if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = model.fieldTypes.firstOrNull()?.id
selectedFamilyId = null
attributeValues.clear()
}
}
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
fun resetCreateForm() {
name = ""
commonName = ""
selectedFamilyId = null
attributeValues.clear()
}
val createWithLocation: () -> Unit = {
val type = selectedType
if (type != null) {
scope.launch {
runCatching { currentF3Geo(context) }
.onSuccess { geo ->
model.createFieldAsset(
type = type,
parentId = parentId,
familyId = selectedFamilyId,
name = name,
commonName = commonName,
attributes = buildF3Attributes(type, attributeValues),
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
)
}
}
}
}
val locationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
val allowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (allowed) createWithLocation()
}
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<F3GeoSnapshot?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
val geo = pendingPhotoGeo
if (success && file != null && geo != null) {
runCatching { writeF3Exif(file, geo) }
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
}
pendingPhotoFile = null
pendingPhotoGeo = null
}
val beginPhoto: () -> Unit = {
scope.launch {
runCatching { currentF3Geo(context) }.onSuccess { geo ->
val (file, uri) = newF3Photo(context)
pendingPhotoFile = file
pendingPhotoGeo = geo
takePicture.launch(uri)
}
}
}
val photoPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
val camera = result[Manifest.permission.CAMERA] == true || f3HasPermission(context, Manifest.permission.CAMERA)
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || f3HasLocation(context)
if (camera && location) beginPhoto()
}
val selectedCapture = model.selectedFieldAsset
val mergeSource = selectedCapture?.takeIf {
it.capture.captureRequired &&
it.asset.type?.let { type -> f3TypeCode(type) in setOf("instalacion", "subinstalacion") } == true
}
val mergeCandidates = remember(model.inventory, mergeSource) {
if (mergeSource == null) emptyList() else model.inventory.filter { candidate ->
candidate.id != mergeSource.asset.id &&
candidate.informationStatus != "INACTIVE" &&
candidate.type?.id == mergeSource.asset.type?.id &&
candidate.parent?.id == mergeSource.asset.parent?.id &&
!candidate.captureRequired
}
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBack) { Text("Acta") }
Column(horizontalAlignment = Alignment.End) {
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall)
}
}
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
if (selectedCapture != null) {
F3CaptureCard(
detailName = selectedCapture.asset.name,
captureRequired = selectedCapture.capture.captureRequired,
gps = selectedCapture.capture.creationGpsCaptured,
photos = selectedCapture.capture.fieldPhotoCount,
ready = selectedCapture.capture.readyForFinding,
onPhoto = {
val permissions = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
if (f3HasPermission(context, Manifest.permission.CAMERA) && f3HasLocation(context)) beginPhoto()
else photoPermissionLauncher.launch(permissions)
},
onClose = { model.clearSelectedFieldAsset() },
)
}
if (mergeSource != null) {
Card(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("¿Lo acabás de crear y ya existía?", fontWeight = FontWeight.Bold)
Text(
"Podés fusionar esta alta de campo con el registro existente. La ficha nueva no se borra: queda como alias histórico con fecha, inspector e inspección.",
style = MaterialTheme.typography.bodySmall,
)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = mergeSearch,
onValueChange = { mergeSearch = it },
label = { Text("Buscar posible duplicado") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = {
mergeCandidateId = null
model.searchInventory(mergeSearch, mergeSource.asset.parent?.id)
}) { Text("Buscar") }
}
if (mergeCandidates.isEmpty()) {
Text("No hay coincidencias compatibles en esta búsqueda.", style = MaterialTheme.typography.bodySmall)
} else {
mergeCandidates.take(8).forEach { candidate ->
AssistChip(
onClick = { mergeCandidateId = candidate.id },
label = {
Text(
if (mergeCandidateId == candidate.id) "${candidate.code} · ${candidate.name}"
else "${candidate.code} · ${candidate.name}",
)
},
)
}
}
OutlinedTextField(
value = mergeReason,
onValueChange = { mergeReason = it },
label = { Text("Motivo de la fusión") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
val target = mergeCandidateId ?: return@Button
model.mergeCreatedFieldAsset(target, mergeReason)
},
enabled = mergeCandidateId != null && mergeReason.trim().length >= 8 && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Fusionar y conservar el registro existente") }
}
}
}
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold)
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar instalación o subinstalación") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { model.searchInventory(search) },
enabled = !model.busy,
modifier = Modifier.weight(1f),
) { Text("Buscar") }
OutlinedButton(
onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
},
modifier = Modifier.weight(1f),
) { Text(if (showCreate) "Cancelar" else "+ Agregar") }
}
}
}
if (showCreate) {
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
Column(
Modifier
.fillMaxWidth()
.padding(12.dp)
.verticalScroll(rememberScrollState())
.weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall)
if (parentId != null) {
OutlinedButton(onClick = {
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetCreateForm()
selectedTypeId = null
model.loadFieldTypes(null)
}) { Text("Volver al Yacimiento") }
}
if (model.fieldTypes.isEmpty()) {
Text("No hay un tipo disponible para esta ubicación.")
} else {
Text("Tipo de registro", style = MaterialTheme.typography.bodySmall)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) },
)
}
}
}
if (selectedType?.familyRequired == true) {
Text("Clasificación", fontWeight = FontWeight.Bold)
Text(
"Elegí el tipo técnico. Si no está catalogado, usá Otro / no catalogado.",
style = MaterialTheme.typography.bodySmall,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(selectedType.families, key = { it.id }) { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
}
}
selectedFamily?.let { family ->
if (family.isOther) {
Text(
"Se registrará como no catalogado para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall,
)
}
if (family.informationLabels.isNotEmpty()) {
Text(
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre o código identificable *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
)
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = Modifier.fillMaxWidth(),
)
}
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = {
if (f3HasLocation(context)) createWithLocation()
else locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
},
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar alta y capturar GPS") }
}
}
}
Text(
"Instalaciones y subinstalaciones",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontWeight = FontWeight.Bold,
)
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
model.inventory.filter { candidate ->
candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion")
},
key = { it.id },
) { item ->
F3InventoryCard(
item = item,
onInspect = { model.selectExisting(item) },
onUseParent = {
parentId = item.id
parentLabel = "${item.name} (${item.code})"
showCreate = true
selectedTypeId = null
resetCreateForm()
model.loadFieldTypes(item.id)
},
)
}
item { Spacer(Modifier.height(30.dp)) }
}
}
}
@Composable
private fun F3CaptureCard(
detailName: String,
captureRequired: Boolean,
gps: Boolean,
photos: Int,
ready: Boolean,
onPhoto: () -> Unit,
onClose: () -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(detailName, fontWeight = FontWeight.Bold)
OutlinedButton(onClick = onClose) { Text("Cerrar") }
}
if (captureRequired) {
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) {
Text("Antes de registrar el Hallazgo, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
} else {
Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary)
}
} else {
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
}
}
}
}
@Composable
private fun F3InventoryCard(
item: FieldInventoryItem,
onInspect: () -> Unit,
onUseParent: () -> Unit,
) {
val typeCode = item.type?.let(::f3TypeCode).orEmpty()
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
val canHaveChild = typeCode == "instalacion"
val childLabel = "+ Agregar subinstalación"
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(item.name, fontWeight = FontWeight.SemiBold)
Text(structureLabel(typeCode), style = MaterialTheme.typography.bodySmall)
}
Text(item.code, style = MaterialTheme.typography.bodySmall)
item.commonName?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodySmall)
}
if (canHaveFinding) {
Text(
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo")
}
}
if (canHaveChild) {
OutlinedButton(onClick = onUseParent, modifier = Modifier.fillMaxWidth()) { Text(childLabel) }
}
}
}
}
@Composable
private fun F3MessageStrip(model: MainViewModel) {
val error = model.error
val notice = model.notice
if (error != null || notice != null) {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
colors = CardDefaults.cardColors(
containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.secondaryContainer,
),
onClick = { model.clearMessages() },
) {
Text(error ?: notice.orEmpty(), Modifier.padding(12.dp))
}
}
}
private fun f3TypeCode(summary: com.korexlabs.dhinspeccion.data.AssetSummary): String =
(summary.typeName ?: summary.name).trim().lowercase()
.replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u')
private fun structureLabel(typeCode: String): String = when (typeCode) {
"area" -> "Área"
"yacimiento" -> "Yacimiento"
"instalacion" -> "Instalación"
"subinstalacion" -> "Subinstalación"
else -> typeCode.ifBlank { "Inventario" }
}
private fun statusLabel(status: String): String = when (status) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceF3Attribute(definition, raw)
}.toMap()
private fun coerceF3Attribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun f3HasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun f3HasLocation(context: Context): Boolean =
f3HasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
f3HasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!f3HasLocation(context)) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) {
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
} else {
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
private fun newF3Photo(context: Context): Pair<File, Uri> {
val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
?: throw IllegalStateException("No se pudo acceder al almacenamiento de fotografías.")
directory.mkdirs()
val file = File.createTempFile("DH_F3_${System.currentTimeMillis()}_", ".jpg", directory)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
return file to uri
}
private fun writeF3Exif(file: File, geo: F3GeoSnapshot) {
val now = Instant.now()
val exif = ExifInterface(file)
exif.setLatLong(geo.latitude, geo.longitude)
val formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneId.systemDefault())
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formatter.format(now))
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, formatter.format(now))
exif.saveAttributes()
}
private fun f3ShortDate(value: String): String = value.replace('T', ' ').take(16)
@@ -0,0 +1,425 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class FindingGeoSnapshot(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun FieldFindingScreen(model: MainViewModel) {
val options = model.fieldFindingOptions ?: return
val asset = model.selectedFieldAsset?.asset ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
var search by rememberSaveable(asset.id) { mutableStateOf("") }
var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) }
var other by rememberSaveable(asset.id) { mutableStateOf(false) }
var customTitle by rememberSaveable(asset.id) { mutableStateOf("") }
var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") }
var description by rememberSaveable(asset.id) { mutableStateOf("") }
var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
var requestedFindingId by remember { mutableStateOf<String?>(null) }
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
var pendingPhotoFindingId by remember { mutableStateOf<String?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
val geo = pendingPhotoGeo
val findingId = pendingPhotoFindingId
if (success && file != null && geo != null && findingId != null) {
runCatching { writeFindingExif(file, geo) }
model.uploadFindingPhoto(
findingId = findingId,
file = file,
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
title = "Evidencia fotográfica de campo",
)
}
pendingPhotoFile = null
pendingPhotoGeo = null
pendingPhotoFindingId = null
}
fun beginPhoto(findingId: String) {
scope.launch {
runCatching { currentFindingGeo(context) }
.onSuccess { geo ->
val (file, uri) = newFindingPhoto(context)
pendingPhotoFile = file
pendingPhotoGeo = geo
pendingPhotoFindingId = findingId
takePicture.launch(uri)
}
}
}
val photoPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
val camera = result[Manifest.permission.CAMERA] == true || findingHasPermission(context, Manifest.permission.CAMERA)
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || findingHasLocation(context)
val findingId = requestedFindingId
requestedFindingId = null
if (camera && location && findingId != null) beginPhoto(findingId)
}
fun requestPhoto(findingId: String) {
requestedFindingId = findingId
if (findingHasPermission(context, Manifest.permission.CAMERA) && findingHasLocation(context)) {
requestedFindingId = null
beginPhoto(findingId)
} else {
photoPermissionLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
val selected = options.catalog.items.firstOrNull { it.id == selectedCatalogId }
val filtered = options.catalog.items.filter {
search.isBlank() ||
it.title.contains(search, ignoreCase = true) ||
it.code.contains(search, ignoreCase = true) ||
it.categoryName.orEmpty().contains(search, ignoreCase = true)
}
LaunchedEffect(selectedCatalogId, other) {
if (!other && selected != null && severityText.isBlank() && selected.suggestedSeverity != null) {
severityText = selected.suggestedSeverity.toString()
}
}
LaunchedEffect(model.lastCreatedFinding?.id) {
if (model.lastCreatedFinding != null) {
selectedCatalogId = null
other = false
customTitle = ""
customLegalBasis = ""
description = ""
severityText = ""
correctionDueOn = ""
search = ""
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 30.dp, start = 16.dp, end = 16.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) {
Text("Volver")
}
Text("Hallazgo de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(asset.name, fontWeight = FontWeight.Bold)
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
model.error?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(it, Modifier.padding(12.dp))
}
}
model.notice?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)) {
Text(it, Modifier.padding(12.dp))
}
}
if (options.findings.isNotEmpty()) {
Text("Hallazgos registrados en este Inventario", fontWeight = FontWeight.Bold)
options.findings.forEach { finding ->
val evidence = model.fieldFindingEvidence[finding.id].orEmpty()
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall)
Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall,
)
evidence.take(3).forEach { item ->
Text(
"${item.title ?: item.originalName}${item.capturedAt?.let { " · ${shortFindingDate(it)}" }.orEmpty()}",
style = MaterialTheme.typography.bodySmall,
)
}
Button(
onClick = { requestPhoto(finding.id) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con GPS")
}
}
}
}
HorizontalDivider()
}
Text("1. Elegí el tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (!options.catalog.typeConfigured) {
Text(
options.catalog.configurationReason
?: "Este tipo de Inventario todavía no tiene un catálogo contextual configurado. Podés usar OTROS.",
color = MaterialTheme.colorScheme.secondary,
)
}
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar en catálogo") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
filtered.forEach { item ->
val chosen = !other && selectedCatalogId == item.id
Card(
modifier = Modifier.fillMaxWidth().clickable {
selectedCatalogId = item.id
other = false
severityText = item.suggestedSeverity?.toString().orEmpty()
},
colors = if (chosen) {
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
CardDefaults.cardColors()
},
) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(if (chosen) "${item.title}" else item.title, fontWeight = FontWeight.SemiBold)
Text(
listOfNotNull(item.categoryName, item.code, item.suggestedSeverity?.let { "Gravedad sugerida $it" })
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
OutlinedButton(
onClick = {
other = true
selectedCatalogId = null
severityText = ""
},
modifier = Modifier.fillMaxWidth(),
) {
Text(if (other) "✓ OTROS · Hallazgo no catalogado" else "OTROS · No está en el catálogo")
}
if (other) {
options.catalog.other.help?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
OutlinedTextField(
value = customTitle,
onValueChange = { customTitle = it },
label = { Text("Título del nuevo Hallazgo *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = customLegalBasis,
onValueChange = { customLegalBasis = it },
label = { Text("Base legal / normativa (opcional)") },
modifier = Modifier.fillMaxWidth(),
)
}
Text("2. Describí lo observado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
selected?.let {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(it.title, fontWeight = FontWeight.SemiBold)
it.legalBasis?.takeIf(String::isNotBlank)?.let { basis -> Text(basis, style = MaterialTheme.typography.bodySmall) }
it.glossary?.takeIf(String::isNotBlank)?.let { glossary -> Text(glossary, style = MaterialTheme.typography.bodySmall) }
}
}
}
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Descripción del Hallazgo *") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
)
OutlinedTextField(
value = severityText,
onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) },
label = { Text("Gravedad 1 a 10") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
OutlinedTextField(
value = correctionDueOn,
onValueChange = { correctionDueOn = it },
label = { Text("Fecha de corrección AAAA-MM-DD (opcional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
val severity = severityText.toIntOrNull()
val choiceReady = selectedCatalogId != null || (other && customTitle.isNotBlank())
Button(
onClick = {
model.createFieldFinding(
catalogItemId = if (other) null else selectedCatalogId,
customTitle = if (other) customTitle else null,
customLegalBasis = if (other) customLegalBasis else null,
description = description,
severity = severity,
correctionDueOn = correctionDueOn,
)
},
enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (model.busy) "Guardando…" else "Guardar Hallazgo")
}
model.lastCreatedFinding?.let { finding ->
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Hallazgo registrado", fontWeight = FontWeight.Bold)
Text("${finding.code} · ${finding.title}")
Button(
onClick = { requestPhoto(finding.id) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con GPS")
}
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
private fun findingHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun findingHasLocation(context: Context): Boolean =
findingHasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
findingHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!findingHasLocation(context)) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
private fun newFindingPhoto(context: Context): Pair<File, Uri> {
val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
?: throw IllegalStateException("No se pudo acceder al almacenamiento de fotografías.")
directory.mkdirs()
val file = File.createTempFile("DH_HALLAZGO_${System.currentTimeMillis()}_", ".jpg", directory)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
return file to uri
}
private fun writeFindingExif(file: File, geo: FindingGeoSnapshot) {
val now = Instant.now()
val exif = ExifInterface(file)
exif.setLatLong(geo.latitude, geo.longitude)
val formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneId.systemDefault())
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formatter.format(now))
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, formatter.format(now))
exif.saveAttributes()
}
private fun shortFindingDate(value: String): String = value.replace('T', ' ').take(16)
@@ -0,0 +1,30 @@
package com.korexlabs.dhinspeccion.ui
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldType
/**
* Compatibilidad transitoria con la pantalla F2.x que queda compilada pero ya no
* será la ruta activa en F3.1. El flujo nuevo siempre envía familyId cuando el
* nivel estructural lo requiere.
*/
fun MainViewModel.createFieldAsset(
type: FieldType,
parentId: String?,
name: String,
commonName: String?,
attributes: Map<String, Any?>,
latitude: Double,
longitude: Double,
accuracyM: Double?,
) = createFieldAsset(
type = type,
parentId = parentId,
familyId = null,
name = name,
commonName = commonName,
attributes = attributes,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
)
@@ -0,0 +1,208 @@
package com.korexlabs.dhinspeccion.ui
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.korexlabs.dhinspeccion.MainViewModel
private const val BIOMETRIC_PREFS = "dh_v2_biometric"
private const val BIOMETRIC_ENABLED = "enabled"
private fun biometricAvailable(context: Context): Boolean =
BiometricManager.from(context).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
private fun biometricEnabled(context: Context): Boolean =
context.getSharedPreferences(BIOMETRIC_PREFS, Context.MODE_PRIVATE)
.getBoolean(BIOMETRIC_ENABLED, false)
private fun setBiometricEnabled(context: Context, enabled: Boolean) {
context.getSharedPreferences(BIOMETRIC_PREFS, Context.MODE_PRIVATE)
.edit().putBoolean(BIOMETRIC_ENABLED, enabled).apply()
}
@Composable
fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
val context = LocalContext.current
val capable = remember { biometricAvailable(context) }
var unlocked by rememberSaveable { mutableStateOf(false) }
var passwordLoginInFlight by rememberSaveable { mutableStateOf(false) }
var enabled by remember { mutableStateOf(biometricEnabled(context)) }
LaunchedEffect(model.session) {
if (model.session != null && passwordLoginInFlight) {
if (capable) {
setBiometricEnabled(context, true)
enabled = true
}
unlocked = true
passwordLoginInFlight = false
}
if (model.session == null) unlocked = false
}
MaterialTheme {
Surface(Modifier.fillMaxSize()) {
when {
model.session == null -> EnhancedLoginScreen(model) {
passwordLoginInFlight = true
}
enabled && capable && !unlocked -> BiometricUnlockScreen(
activity = activity,
displayName = model.session?.displayName.orEmpty(),
onAuthenticated = { unlocked = true },
onUsePassword = {
setBiometricEnabled(context, false)
enabled = false
model.logout()
},
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> F3VisitRoot(model)
else -> MobileHomeScreen(model)
}
}
}
}
@Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos")
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
OutlinedTextField(
value = identifier,
onValueChange = { identifier = it },
label = { Text("Usuario o email") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Contraseña") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = if (passwordVisible) "Ocultar contraseña" else "Mostrar contraseña",
)
}
},
)
Button(
onClick = {
onPasswordLogin()
model.login(identifier, password)
},
modifier = Modifier.fillMaxWidth(),
enabled = !model.busy,
) { Text(if (model.busy) "Ingresando…" else "Ingresar") }
Text(
"Después del primer ingreso correcto, si la tablet tiene una huella fuerte configurada, se habilita el acceso biométrico. La contraseña no se almacena.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@Composable
private fun BiometricUnlockScreen(
activity: FragmentActivity,
displayName: String,
onAuthenticated: () -> Unit,
onUsePassword: () -> Unit,
) {
var error by rememberSaveable { mutableStateOf<String?>(null) }
fun authenticate() {
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
error = null
onAuthenticated()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
error = errString.toString()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
error = "Huella no reconocida."
}
},
)
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("Ingresar a DH Inspección")
.setSubtitle(if (displayName.isBlank()) "Validá tu identidad" else "Hola, $displayName")
.setNegativeButtonText("Usar contraseña")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
prompt.authenticate(info)
}
LaunchedEffect(Unit) { authenticate() }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(onClick = { authenticate() }, modifier = Modifier.fillMaxWidth()) { Text("Usar huella") }
OutlinedButton(onClick = onUsePassword, modifier = Modifier.fillMaxWidth()) { Text("Ingresar con contraseña") }
}
}
}
@@ -0,0 +1,452 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class ActSignatureGeo(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun MobileActsScreen(
model: MainViewModel,
onBack: () -> Unit,
onGoInventory: () -> Unit,
) {
val visit = model.visit ?: return
val selected = model.selectedAct
val closure = model.actClosure
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")
}
var fullName by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.fullName.orEmpty())
}
var documentType by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentType ?: "DNI")
}
var documentNumber by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentNumber.orEmpty())
}
var position by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.position.orEmpty())
}
var email by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.email.orEmpty())
}
var phone by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.phone.orEmpty())
}
var absenceReason by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.absenceReason.orEmpty())
}
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
LaunchedEffect(visit.id) { model.reloadActs() }
fun signWithGeo(file: File, company: Boolean) {
scope.launch {
val geo = runCatching { currentActSignatureGeo(context) }.getOrNull()
if (company) {
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
} else {
model.signSelectedActAsInspector(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
)
}
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedButton(onClick = onBack, enabled = !model.busy) { Text("Volver") }
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
visit.scopeAsset?.let { Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodySmall) }
F32ActMessage(model)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Actas de esta inspección", fontWeight = FontWeight.Bold)
if (model.acts.isEmpty()) {
Text("Todavía no hay Actas. Creá la primera y después agregá los Hallazgos.")
}
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)} · $urgency · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}",
)
}
}
}
}
val hasDraftAct = model.acts.any { it.status == "DRAFT" }
if (visit.status == "IN_PROGRESS" && !hasDraftAct) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "LOCKED" }) {
Text(
"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", 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,
)
Text(
"Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { model.createAct(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
}
}
}
if (selected != null) {
HorizontalDivider()
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status))
Text(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")
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
}
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
}
}
when (selected.status) {
"DRAFT" -> {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Buscá una Instalación o Subinstalación existente. Si no está, podés agregarla en campo en el mismo flujo.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Hallazgo")
}
}
}
HorizontalDivider()
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
}
if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "$kind" else kind) })
}
}
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth())
Button(
onClick = {
model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone)
},
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar responsable") }
} else {
OutlinedTextField(
absenceReason,
{ absenceReason = it },
label = { Text("Motivo de ausencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
enabled = !model.busy && absenceReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar ausencia") }
}
HorizontalDivider()
Text("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("Finalizar y BLOQUEAR Acta") }
}
"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 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)
if (inspectorSigned) {
Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary)
} else {
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
SignaturePad(
label = "Firmá como inspector/a",
enabled = !model.busy,
onCaptured = { file -> signWithGeo(file, company = false) },
)
}
HorizontalDivider()
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar"
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. 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)) {
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "En disidencia") })
}
if (manifestation == "DISSENT") {
OutlinedTextField(
dissentStatement,
{ dissentStatement = it },
label = { Text("Manifestación de disidencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
SignaturePad(
label = "Firma del responsable de empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) },
)
Text("Si la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
refusalReason,
{ refusalReason = it },
label = { Text("Motivo de negativa") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") }
}
HorizontalDivider()
if (inspectorSigned && companyResolved) {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("SELLAR Acta definitivamente") }
} else {
Text(
"Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.",
style = MaterialTheme.typography.bodySmall,
)
}
}
"SEALED" -> {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
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) }
}
}
}
"CANCELLED" -> Text("Esta Acta fue cancelada y permanece sólo como antecedente.")
}
}
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
HorizontalDivider()
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 && activeActs.isNotEmpty() && pendingActs.isEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Inspección y salir de la empresa") }
}
}
}
@Composable
private fun F32ActMessage(model: MainViewModel) {
val text = model.error ?: model.notice ?: return
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (model.error != null) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.secondaryContainer,
),
onClick = { model.clearMessages() },
) {
Text(text, Modifier.padding(12.dp))
}
}
private fun actStatusLabel(status: String): String = when (status) {
"DRAFT" -> "Borrador"
"LOCKED" -> "BLOQUEADA"
"SEALED" -> "SELLADA"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun hasActLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo = suspendCancellableCoroutine { continuation ->
if (!hasActLocation(context)) {
continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
try {
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
@@ -0,0 +1,231 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository
import com.korexlabs.dhinspeccion.data.MobilePlanningAsset
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
@Composable
fun MobileHomeScreen(model: MainViewModel) {
val session = model.session ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
var showOpen by rememberSaveable { mutableStateOf(false) }
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
var loadingContext by remember { mutableStateOf(false) }
var opening by remember { mutableStateOf(false) }
var localError by remember { mutableStateOf<String?>(null) }
fun loadAreas() {
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.areas().data }
.onSuccess { areas = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseArea(areaId: String) {
selectedAreaId = areaId
selectedCompanyId = null
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.operators(areaId).data }
.onSuccess { operators = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
LaunchedEffect(showOpen) {
if (showOpen && areas.isEmpty()) loadAreas()
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
Text("Actualizar")
}
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
}
}
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
showOpen = !showOpen
localError = null
},
modifier = Modifier.fillMaxWidth(),
enabled = !opening,
) {
Text(if (showOpen) "Cancelar nueva inspección" else "Abrir inspección")
}
if (showOpen) {
Card(Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
style = MaterialTheme.typography.bodySmall,
)
Text("1. Área", fontWeight = FontWeight.SemiBold)
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(areas, key = { it.id }) { area ->
AssistChip(
onClick = { chooseArea(area.id) },
label = { Text(if (area.id == selectedAreaId) "${area.name}" else area.name) },
)
}
}
if (selectedAreaId != null) {
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
if (loadingContext && operators.isEmpty()) {
CircularProgressIndicator()
} else if (operators.isEmpty()) {
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(operators, key = { it.id }) { company ->
AssistChip(
onClick = { selectedCompanyId = company.id },
label = { Text(if (company.id == selectedCompanyId) "${company.name}" else company.name) },
)
}
}
}
Button(
onClick = {
val areaId = selectedAreaId ?: return@Button
val companyId = selectedCompanyId ?: return@Button
scope.launch {
opening = true
localError = null
runCatching { openRepository.open(areaId, companyId) }
.onSuccess { opened ->
showOpen = false
model.openVisit(opened.id)
}
.onFailure { localError = DhRepository.humanError(it) }
opening = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
) {
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
}
}
}
}
}
if (model.busy && model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
}
} else {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(model.visits, key = { it.id }) { visit ->
MobileVisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
}
@Composable
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visit.status)
}
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let {
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
}
Text(
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@@ -0,0 +1,137 @@
package com.korexlabs.dhinspeccion.ui
import android.graphics.Bitmap
import android.graphics.Canvas as AndroidCanvas
import android.graphics.Color as AndroidColor
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import java.io.File
import java.io.FileOutputStream
@Composable
fun SignaturePad(
label: String,
enabled: Boolean = true,
onCaptured: (File) -> Unit,
) {
val context = LocalContext.current
val strokes = remember { mutableStateListOf<List<Offset>>() }
var currentStroke by remember { mutableStateOf<List<Offset>>(emptyList()) }
var canvasSize by remember { mutableStateOf(IntSize.Zero) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
Text(
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.",
style = MaterialTheme.typography.bodySmall,
)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(190.dp)
.background(Color.White)
.onSizeChanged { canvasSize = it }
.pointerInput(enabled) {
if (!enabled) return@pointerInput
detectDragGestures(
onDragStart = { position -> currentStroke = listOf(position) },
onDrag = { change, _ ->
change.consume()
currentStroke = currentStroke + change.position
},
onDragEnd = {
if (currentStroke.size > 1) strokes.add(currentStroke)
currentStroke = emptyList()
},
onDragCancel = { currentStroke = emptyList() },
)
},
) {
val all = strokes + listOf(currentStroke)
all.forEach { stroke ->
stroke.zipWithNext().forEach { (start, end) ->
drawLine(
color = Color.Black,
start = start,
end = end,
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round,
)
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { strokes.clear(); currentStroke = emptyList() },
enabled = enabled && (strokes.isNotEmpty() || currentStroke.isNotEmpty()),
modifier = Modifier.weight(1f),
) { Text("Limpiar") }
Button(
onClick = {
val width = canvasSize.width.coerceAtLeast(1)
val height = canvasSize.height.coerceAtLeast(1)
val targetWidth = 1000
val targetHeight = 400
val scaleX = targetWidth.toFloat() / width.toFloat()
val scaleY = targetHeight.toFloat() / height.toFloat()
val bitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888)
val native = AndroidCanvas(bitmap)
native.drawColor(AndroidColor.WHITE)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = AndroidColor.BLACK
strokeWidth = 7f
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
style = Paint.Style.STROKE
}
strokes.forEach { stroke ->
stroke.zipWithNext().forEach { (start, end) ->
native.drawLine(
start.x * scaleX,
start.y * scaleY,
end.x * scaleX,
end.y * scaleY,
paint,
)
}
}
val file = File.createTempFile("DH_FIRMA_", ".png", context.cacheDir)
FileOutputStream(file).use { stream ->
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream))
}
bitmap.recycle()
onCaptured(file)
},
enabled = enabled && strokes.isNotEmpty(),
modifier = Modifier.weight(1f),
) { Text("Usar firma") }
}
}
}
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Launcher de presentación F6.1 inspirado en el escudo institucional publicado
por Gobierno de Mendoza en mendoza.gov.ar. Se mantiene como vector local para
evitar el ícono genérico del sistema y no depender de assets remotos.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- fondo institucional -->
<path
android:fillColor="#0B5FA5"
android:pathData="M54,4 A50,50 0,1 0,54 104 A50,50 0,1 0,54 4" />
<!-- escudo exterior -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,13 C72,13 84,22 87,38 L83,65 C80,81 68,93 54,99 C40,93 28,81 25,65 L21,38 C24,22 36,13 54,13 Z" />
<!-- campo superior celeste -->
<path
android:fillColor="#22A9E0"
android:pathData="M27,38 C30,27 39,21 54,21 C69,21 78,27 81,38 L78,59 L30,59 Z" />
<!-- campo inferior dorado -->
<path
android:fillColor="#C6A66A"
android:pathData="M30,63 L78,63 L76,68 C73,80 65,89 54,94 C43,89 35,80 32,68 Z" />
<!-- gorro frigio simplificado -->
<path
android:fillColor="#FFFFFF"
android:pathData="M47,31 C52,26 61,25 67,28 C64,31 63,35 64,39 C58,37 52,37 46,40 C44,36 44,33 47,31 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M53,39 L57,39 L56,48 L52,48 Z" />
<!-- racimo de uva simplificado -->
<path
android:fillColor="#FFFFFF"
android:pathData="M53,69 C56,69 58,71 58,74 C58,77 56,79 53,79 C50,79 48,77 48,74 C48,71 50,69 53,69 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M45,73 C48,73 50,75 50,78 C50,81 48,83 45,83 C42,83 40,81 40,78 C40,75 42,73 45,73 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M61,73 C64,73 66,75 66,78 C66,81 64,83 61,83 C58,83 56,81 56,78 C56,75 58,73 61,73 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M53,80 C56,80 58,82 58,85 C58,88 56,90 53,90 C50,90 48,88 48,85 C48,82 50,80 53,80 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M52,67 C54,63 58,61 62,61 C60,65 57,68 53,70 Z" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">DH Inspección</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.DHInspeccion" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:colorAccent">#1B5E20</item>
<item name="android:navigationBarColor">#FFFFFF</item>
<item name="android:statusBarColor">#FFFFFF</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path name="inspection_photos" path="Pictures/" />
</paths>
@@ -0,0 +1,20 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(25, BuildConfig.VERSION_CODE)
assertEquals("0.16.0-debug", BuildConfig.VERSION_NAME)
}
@Test
fun fieldBuildTargetsOnlyTheHttpsProductionApi() {
assertEquals("https://dhv2.korexlabs.com/api/v3/", BuildConfig.API_BASE_URL)
assertTrue(BuildConfig.API_BASE_URL.startsWith("https://"))
}
}
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "DHInspeccion"
include(":app")
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-api",
"version": "0.20.0-2",
"version": "0.29.0-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-api",
"version": "0.20.0-2",
"version": "0.29.0-1",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/common": "^11.0.0",
@@ -4166,9 +4166,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -4666,9 +4666,9 @@
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.20.0-3",
"version": "0.29.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -42,5 +42,9 @@
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.9.0"
},
"overrides": {
"multer": "2.3.0",
"qs": "6.16.0"
}
}
@@ -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;
}
@@ -36,6 +36,20 @@ export interface FieldBriefingAct {
findings: FieldBriefingFinding[];
}
function toIsoDate(value: unknown): string {
const parsed = value instanceof Date
? value
: value == null
? new Date()
: new Date(String(value));
if (Number.isNaN(parsed.getTime())) {
throw new Error('La fecha planificada de la inspección no es válida');
}
return parsed.toISOString().slice(0, 10);
}
@Injectable()
export class FieldBriefingService {
constructor(private readonly dataSource: DataSource) {}
@@ -67,7 +81,9 @@ export class FieldBriefingService {
});
}
const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
// PostgreSQL/pg entrega los timestamptz como Date. String(date).slice(0, 10)
// produce textos como "Thu Sep 17", que PostgreSQL rechaza al castear a date.
const plannedOn = toIsoDate(visit.plannedStartAt);
const rows = (await this.dataSource.query(`
WITH prior_acts AS (
@@ -129,7 +145,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
@@ -12,6 +12,9 @@ import {
MinLength,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class CreateUserDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
@@ -23,13 +26,38 @@ export class CreateUserDto {
username!: string;
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
)
@IsOptional()
@IsEmail()
@MaxLength(320)
email?: string | null;
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
)
@IsOptional()
@Matches(/^\d{7,11}$/)
dni?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(160)
jobTitle?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(80)
employeeNumber?: string | null;
@IsString()
@MinLength(12)
@MaxLength(128)
@@ -8,6 +8,9 @@ import {
MinLength,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class UpdateUserDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
@@ -20,13 +23,38 @@ export class UpdateUserDto {
username?: string;
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
)
@IsOptional()
@IsEmail()
@MaxLength(320)
email?: string | null;
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
)
@IsOptional()
@Matches(/^\d{7,11}$/)
dni?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(160)
jobTitle?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(80)
employeeNumber?: string | null;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
@@ -41,6 +41,10 @@ export interface AdministrativeUserView {
id: string;
username: string;
email: string | null;
dni: string | null;
phone: string | null;
jobTitle: string | null;
employeeNumber: string | null;
firstName: string;
lastName: string;
status: UserStatus;
@@ -72,6 +76,13 @@ function roleSelectionInvalid(): BadRequestException {
});
}
function inspectorEmailRequired(): BadRequestException {
return new BadRequestException({
code: 'INSPECTOR_EMAIL_REQUIRED',
message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones',
});
}
@Injectable()
export class UsersService {
constructor(
@@ -96,6 +107,10 @@ export class UsersService {
OR user_account.email ILIKE $${parameters.length}
OR user_account.first_name ILIKE $${parameters.length}
OR user_account.last_name ILIKE $${parameters.length}
OR user_account.dni ILIKE $${parameters.length}
OR user_account.phone ILIKE $${parameters.length}
OR user_account.job_title ILIKE $${parameters.length}
OR user_account.employee_number ILIKE $${parameters.length}
)
`);
}
@@ -115,6 +130,10 @@ export class UsersService {
user_account.id,
user_account.username,
user_account.email,
user_account.dni,
user_account.phone,
user_account.job_title AS "jobTitle",
user_account.employee_number AS "employeeNumber",
user_account.first_name AS "firstName",
user_account.last_name AS "lastName",
user_account.status,
@@ -176,9 +195,14 @@ export class UsersService {
try {
return await this.dataSource.transaction(async (manager) => {
const roles = await this.resolveRoles(manager, dto.roleIds);
this.assertInspectorHasEmail(roles, dto.email ?? null);
const user = manager.getRepository(User).create({
username: dto.username.trim().toLowerCase(),
email: dto.email?.trim().toLowerCase() || null,
dni: dto.dni ?? null,
phone: dto.phone ?? null,
jobTitle: dto.jobTitle ?? null,
employeeNumber: dto.employeeNumber ?? null,
passwordHash,
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
@@ -227,6 +251,10 @@ export class UsersService {
if (
dto.username === undefined &&
dto.email === undefined &&
dto.dni === undefined &&
dto.phone === undefined &&
dto.jobTitle === undefined &&
dto.employeeNumber === undefined &&
dto.firstName === undefined &&
dto.lastName === undefined
) {
@@ -240,6 +268,9 @@ export class UsersService {
return await this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id);
if (dto.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) {
throw inspectorEmailRequired();
}
if (dto.username !== undefined) {
user.username = dto.username.trim().toLowerCase();
@@ -247,6 +278,10 @@ export class UsersService {
if (dto.email !== undefined) {
user.email = dto.email?.trim().toLowerCase() || null;
}
if (dto.dni !== undefined) user.dni = dto.dni ?? null;
if (dto.phone !== undefined) user.phone = dto.phone ?? null;
if (dto.jobTitle !== undefined) user.jobTitle = dto.jobTitle ?? null;
if (dto.employeeNumber !== undefined) user.employeeNumber = dto.employeeNumber ?? null;
if (dto.firstName !== undefined) user.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
user.updatedBy = principal.userId;
@@ -379,6 +414,7 @@ export class UsersService {
await this.lockUser(manager, id);
const roles = await this.resolveRoles(manager, dto.roleIds);
const before = await this.loadUserView(manager, id);
this.assertInspectorHasEmail(roles, before.email);
const beforeIds = before.roles.map((role) => role.id).sort();
const afterIds = roles.map((role) => role.id).sort();
if (beforeIds.join(',') === afterIds.join(',')) return before;
@@ -417,6 +453,12 @@ export class UsersService {
return roles;
}
private assertInspectorHasEmail(roles: Role[], email: string | null | undefined): void {
if (roles.some((role) => role.code === 'inspector') && !email?.trim()) {
throw inspectorEmailRequired();
}
}
private async insertUserRoles(
manager: EntityManager,
userId: string,
@@ -455,6 +497,10 @@ export class UsersService {
user_account.id,
user_account.username,
user_account.email,
user_account.dni,
user_account.phone,
user_account.job_title AS "jobTitle",
user_account.employee_number AS "employeeNumber",
user_account.first_name AS "firstName",
user_account.last_name AS "lastName",
user_account.status,
@@ -491,7 +537,7 @@ export class UsersService {
private userConflict(): ConflictException {
return new ConflictException({
code: 'USER_ALREADY_EXISTS',
message: 'El usuario o email ya está registrado',
message: 'El usuario, email o DNI ya está registrado',
});
}
}
-4
View File
@@ -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,
+55 -158
View File
@@ -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<AssetVersionDetail> {
@@ -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<void> {
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<Record<string, unknown>> {
private async loadCurrentSnapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
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
+34 -1
View File
@@ -22,12 +22,30 @@ import { AssetOperationalRelationsService } from './asset-operational-relations.
import { AssetRegistryController } from './asset-registry.controller';
import { AssetRegistryService } from './asset-registry.service';
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
import { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service';
@Module({
imports: [AuditModule],
controllers: [
AssetTypesController,
AssetsController,
InventoryStructureController,
InventoryFamilyCatalogController,
InventoryTechnicalValuesController,
InventoryBrowserController,
InventoryMergeController,
FieldInventoryMergeController,
AssetGeometriesController,
MapAssetsController,
AssetHistoryController,
@@ -40,6 +58,13 @@ import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/fi
providers: [
AssetTypesService,
AssetsService,
InventoryStructureService,
InventoryFamilyCatalogService,
InventoryTechnicalValuesService,
InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService,
MergedInventoryDossierService,
AssetGeometriesService,
AssetHistoryService,
AssetMediaService,
@@ -49,6 +74,14 @@ import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/fi
AssetRegistryService,
FieldDiscoveryInspectionLinkService,
],
exports: [AssetHistoryService],
exports: [
AssetHistoryService,
AssetsService,
InventoryMergeService,
MergedInventoryDossierService,
AssetGeometriesService,
AssetMediaService,
FieldDiscoveryInspectionLinkService,
],
})
export class AssetMasterModule {}
@@ -79,23 +79,15 @@ export class AssetOperationalRelationsService {
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
const data = (await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName"
FROM (
SELECT relation.company_id
FROM area_company_relations relation
WHERE relation.area_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
UNION
SELECT asset.operator_company_id AS company_id
FROM assets asset
WHERE asset.operational_area_id = $1
AND asset.operator_company_id IS NOT NULL
AND asset.information_status <> 'INACTIVE'
) linked
INNER JOIN assets company ON company.id = linked.company_id
SELECT DISTINCT company.id, company.code, company.name,
company.common_name AS "commonName", company_type.name AS "typeName"
FROM area_company_relations relation
INNER JOIN assets company ON company.id = relation.company_id
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
WHERE company.information_status <> 'INACTIVE'
WHERE relation.area_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
AND company.information_status <> 'INACTIVE'
ORDER BY company.name, company.code
`, [areaId])) as OperationalAssetSummary[];
return { data };
@@ -104,23 +96,15 @@ export class AssetOperationalRelationsService {
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
const data = (await this.dataSource.query(`
SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName"
FROM (
SELECT relation.area_id
FROM area_company_relations relation
WHERE relation.company_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
UNION
SELECT asset.operational_area_id AS area_id
FROM assets asset
WHERE asset.operator_company_id = $1
AND asset.operational_area_id IS NOT NULL
AND asset.information_status <> 'INACTIVE'
) linked
INNER JOIN assets area ON area.id = linked.area_id
SELECT DISTINCT area.id, area.code, area.name,
area.common_name AS "commonName", area_type.name AS "typeName"
FROM area_company_relations relation
INNER JOIN assets area ON area.id = relation.area_id
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
WHERE area.information_status <> 'INACTIVE'
WHERE relation.company_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
AND area.information_status <> 'INACTIVE'
ORDER BY area.name, area.code
`, [companyId])) as OperationalAssetSummary[];
return { data };
@@ -157,12 +141,38 @@ export class AssetOperationalRelationsService {
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
}
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
const [currentOperator] = (await manager.query(`
SELECT relation.id,company.name AS "companyName"
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND relation.company_id<>$2
ORDER BY relation.valid_from DESC
LIMIT 1
FOR UPDATE OF relation
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
if (currentOperator) {
throw new ConflictException({
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
});
}
}
const [row] = (await manager.query(`
INSERT INTO area_company_relations (
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// F5/F6: changing the Area operator is a temporal relation event only.
// Existing Inventory keeps its creation/historical operator snapshot and
// physical hierarchy unchanged. Runtime ownership must resolve this row.
const created = await this.loadRelation(manager, row.id);
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -170,6 +180,9 @@ export class AssetOperationalRelationsService {
entityType: 'area_company_relation',
entityId: row.id,
afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
: undefined,
}, manager);
return created;
});
@@ -198,12 +211,9 @@ export class AssetOperationalRelationsService {
message: 'La relación ya se encuentra finalizada',
});
}
if (before.assignedAssetCount > 0) {
throw new ConflictException({
code: 'AREA_COMPANY_RELATION_IN_USE',
message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`,
});
}
// F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
await manager.query(`
UPDATE area_company_relations
SET valid_until = CURRENT_TIMESTAMP,
@@ -220,6 +230,10 @@ export class AssetOperationalRelationsService {
entityId: id,
beforeData: this.auditView(before),
afterData: this.auditView(updated),
metadata: {
inventoryHierarchyChanged:false,
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
},
}, manager);
return updated;
});
@@ -330,7 +344,8 @@ export class AssetOperationalRelationsService {
(relation.valid_until IS NULL) AS active,
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
WHERE asset.operational_area_id = relation.area_id
AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount"
AND asset.operator_company_id = relation.company_id
AND asset.is_inventory_instance=true) ELSE 0 END AS "assignedAssetCount"
FROM area_company_relations relation
INNER JOIN assets area ON area.id = relation.area_id
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
@@ -137,21 +137,41 @@ export class AssetTemporalService {
if (!row) throw temporalAssetNotFound();
const asOf = new Date(query.at);
row.asOf = asOf;
// Physical context is reconstructed from the Inventory history, while the
// operator is resolved independently from the temporal Area↔Empresa ledger.
// This keeps historical operator changes from rewriting the Inventory.
const [context] = (await this.dataSource.query(`
SELECT
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "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 AS "operatorCompany"
FROM asset_context_history history
LEFT JOIN assets parent ON parent.id=history.parent_id
LEFT JOIN assets area ON area.id=history.operational_area_id
LEFT JOIN assets company ON company.id=history.operator_company_id
LEFT JOIN LATERAL (
SELECT relation.company_id
FROM area_company_relations relation
WHERE relation.area_id=history.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2
AND (relation.valid_until IS NULL OR relation.valid_until > $2)
ORDER BY relation.valid_from DESC, relation.created_at DESC, relation.id DESC
LIMIT 1
) operator_relation ON true
LEFT JOIN assets operator_company ON operator_company.id=operator_relation.company_id
WHERE history.asset_id=$1
AND history.valid_from <= $2
AND (history.valid_until IS NULL OR history.valid_until > $2)
ORDER BY history.valid_from DESC
LIMIT 1
`, [assetId, asOf])) as Array<{ parent: Record<string, unknown> | null; operationalArea: Record<string, unknown> | null; operatorCompany: Record<string, unknown> | null }>;
`, [assetId, asOf])) as Array<{
parent: Record<string, unknown> | null;
operationalArea: Record<string, unknown> | null;
operatorCompany: Record<string, unknown> | null;
}>;
if (context && row.snapshot) {
row.snapshot = {
...row.snapshot,
+7 -3
View File
@@ -16,6 +16,7 @@ import type {
RequestWithContext,
} from '../common/http/request-context';
import { AssetsService } from './assets.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { ChangeAssetStatusDto } from './dto/change-asset-status.dto';
import { ChangeAssetOperationalStatusDto } from './dto/change-asset-operational-status.dto';
import { CreateAssetDto } from './dto/create-asset.dto';
@@ -31,7 +32,10 @@ import { ChangeAssetContextDto } from './dto/change-asset-context.dto';
@Controller('assets')
export class AssetsController {
constructor(private readonly assets: AssetsService) {}
constructor(
private readonly assets: AssetsService,
private readonly mergedDossier: MergedInventoryDossierService,
) {}
@Get()
@RequirePermissions('assets.read')
@@ -131,8 +135,8 @@ export class AssetsController {
'inspection_evidence.read',
'inspection_communications.read',
)
dossier(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.assets.dossier(id);
dossier(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise<unknown> {
return this.mergedDossier.dossier(id);
}
@Get(':id/context-history')
+6 -6
View File
@@ -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"
@@ -1,5 +1,5 @@
import { Transform, Type } from 'class-transformer';
import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
import { IsDate, IsEmpty, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class ChangeAssetContextDto {
@IsOptional()
@@ -10,8 +10,13 @@ export class ChangeAssetContextDto {
@IsUUID('4')
operationalAreaId?: string | null;
/**
* Compatibilidad de contrato únicamente. F5/F6 separa la ubicación física
* del Inventario de la Operadora temporal del Área, por lo que este valor no
* puede modificarse desde un cambio de contexto del Inventario.
*/
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
operatorCompanyId?: string | null;
@IsOptional()
@@ -42,6 +42,10 @@ export class CreateAssetDto {
@IsUUID('4')
parentId?: string | null;
@IsOptional()
@IsUUID('4')
inventoryFamilyId?: string | null;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string | null;
@@ -50,6 +54,10 @@ export class CreateAssetDto {
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
@@ -0,0 +1,69 @@
import { Transform } from 'class-transformer';
import {
IsIn,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
// Invariante de dominio: Empresa es un maestro independiente. La jerarquía física
// se expresa sólo como Departamento → Área → Yacimiento → Instalación → Subinstalación.
// La Empresa y el Tipo de concesión se relacionan directamente con el Yacimiento.
export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA',
'DEPARTAMENTO',
'AREA',
'YACIMIENTO',
'INSTALACION',
'SUBINSTALACION',
] as const;
export type InventoryStructureKind = typeof INVENTORY_STRUCTURE_KINDS[number];
export class CreateInventoryStructureDto {
@IsIn(INVENTORY_STRUCTURE_KINDS)
kind!: InventoryStructureKind;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toUpperCase() : null)
@IsString()
@MaxLength(120)
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code?: string | null;
@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(200)
commonName?: string | null;
@IsOptional()
@IsUUID('4')
parentId?: string | null;
@IsOptional()
@IsUUID('4')
familyId?: string | null;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
description?: string | null;
}
@@ -0,0 +1,63 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class InventoryBrowserQueryDto {
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
needsValidation?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
}
@@ -0,0 +1,75 @@
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateInventoryFamilyDto {
@IsIn(['INSTALLATION', 'SUBINSTALLATION'])
level!: 'INSTALLATION' | 'SUBINSTALLATION';
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(240)
name!: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsString({ each: true })
@MaxLength(160, { each: true })
informationLabels?: string[];
}
export class UpdateInventoryFamilyDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(240)
name?: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsString({ each: true })
@MaxLength(160, { each: true })
informationLabels?: string[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class ReplaceInventoryFamilyFindingsDto {
@IsArray()
@ArrayMaxSize(2000)
@IsUUID('4', { each: true })
itemIds!: string[];
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(5)
@MaxLength(2000)
reason!: string;
}
@@ -0,0 +1,103 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const DATA_TYPES = ['TEXT','NUMBER','BOOLEAN','DATE','DATETIME','SELECT'] as const;
export type InventoryFamilyAttributeDataType = typeof DATA_TYPES[number];
export class CreateInventoryFamilyAttributeDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsString()
@Matches(/^[a-z][a-z0-9_]*$/)
@MaxLength(80)
code!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(160)
name!: string;
@IsIn(DATA_TYPES)
dataType!: InventoryFamilyAttributeDataType;
@IsOptional()
@IsBoolean()
isRequired?: boolean;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@ValidateIf((_object, value) => value !== null && value !== undefined)
@IsString()
@MaxLength(40)
unit?: string | null;
@ValidateIf((object) => object.dataType === 'SELECT')
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
@MaxLength(160, { each: true })
options?: string[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
sortOrder?: number;
}
export class UpdateInventoryFamilyAttributeDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(160)
name?: string;
@IsOptional()
@IsIn(DATA_TYPES)
dataType?: InventoryFamilyAttributeDataType;
@IsOptional()
@IsBoolean()
isRequired?: boolean;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@ValidateIf((_object, value) => value !== null && value !== undefined)
@IsString()
@MaxLength(40)
unit?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
@MaxLength(160, { each: true })
options?: string[] | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
sortOrder?: number;
}
@@ -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;
}
@@ -1,5 +1,5 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { IsBoolean, IsEmpty, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class ListAssetTreeQueryDto {
@@ -24,8 +24,12 @@ export class ListAssetTreeQueryDto {
@IsUUID('4')
operationalAreaId?: string;
/**
* Retenido sólo para compatibilidad tipada. El árbol operativo no puede
* filtrar por el snapshot histórico de Empresa del Inventario.
*/
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
operatorCompanyId?: string;
@IsOptional()
@@ -37,4 +41,9 @@ export class ListAssetTreeQueryDto {
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
inventoryOnly?: boolean;
}
@@ -1,6 +1,7 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEmpty,
IsEnum,
IsInt,
IsOptional,
@@ -39,7 +40,6 @@ export class ListAssetsQueryDto {
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus;
@@ -54,6 +54,11 @@ export class ListAssetsQueryDto {
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
inventoryOnly?: boolean;
@IsOptional()
@IsUUID('4')
parentId?: string;
@@ -62,7 +67,12 @@ export class ListAssetsQueryDto {
@IsUUID('4')
operationalAreaId?: string;
/**
* Retenido sólo para compatibilidad tipada con consumidores históricos.
* F5/F6 no permite buscar ownership actual por el snapshot de Empresa del
* Inventario; la navegación Área↔Operadora usa area_company_relations.
*/
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
operatorCompanyId?: string;
}
@@ -0,0 +1,13 @@
import { Transform } from 'class-transformer';
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class MergeInventoryAssetDto {
@IsUUID('4')
canonicalAssetId!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(8)
@MaxLength(2000)
reason!: string;
}
@@ -1,5 +1,6 @@
import { Transform } from 'class-transformer';
import {
IsEmpty,
IsObject,
IsOptional,
IsString,
@@ -48,9 +49,13 @@ export class UpdateAssetDto {
operationalAreaId?: string | null;
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'La Empresa relacionada se administra en el Yacimiento, no desde la edición genérica del Inventario' })
operatorCompanyId?: string | null;
@IsOptional()
@IsEmpty({ message: 'El Tipo de concesión se administra en el Yacimiento, no desde la edición genérica del Inventario' })
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
@@ -0,0 +1,6 @@
import { IsObject } from 'class-validator';
export class UpdateInventoryTechnicalValuesDto {
@IsObject()
values!: Record<string, unknown>;
}
@@ -0,0 +1,38 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
import { InventoryBrowserService } from './inventory-browser.service';
@Controller('inventory-browser')
@RequirePermissions('assets.read')
export class InventoryBrowserController {
constructor(private readonly inventoryBrowser: InventoryBrowserService) {}
@Get('items')
items(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.items(query);
}
@Get('departments')
departments(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.departments(query);
}
@Get('companies')
companies(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.companies(query);
}
@Get('areas')
areas(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.areas(query);
}
@Get(':parentId/children')
children(
@Param('parentId', new ParseUUIDPipe({ version: '4' })) parentId: string,
@Query() query: InventoryBrowserQueryDto,
) {
return this.inventoryBrowser.children(parentId, query);
}
}
@@ -0,0 +1,335 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
type ParentContext = {
id: string;
code: string;
name: string;
typeCode: string;
};
@Injectable()
export class InventoryBrowserService {
constructor(private readonly dataSource: DataSource) {}
async items(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
"(type.operational_role='COMPANY' OR lower(type.code) IN ('departamento','area','yacimiento','instalacion','subinstalacion'))",
];
const add = (value: unknown): string => {
params.push(value);
return `$${params.length}`;
};
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
}
if (query.typeId) conditions.push(`asset.asset_type_id=${add(query.typeId)}::uuid`);
if (query.status) conditions.push(`asset.information_status=${add(query.status)}::asset_information_status`);
if (query.operationalStatus) conditions.push(`asset.operational_status=${add(query.operationalStatus)}::asset_operational_status`);
if (query.needsValidation === true) conditions.push("asset.information_status NOT IN ('VALIDATED','INACTIVE')");
if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'");
if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.operationalAreaId) {
const area = add(query.operationalAreaId);
conditions.push(`(asset.id=${area}::uuid OR asset.operational_area_id=${area}::uuid)`);
}
if (query.operatorCompanyId) {
const company = add(query.operatorCompanyId);
conditions.push(`(
asset.id=${company}::uuid
OR EXISTS (
SELECT 1 FROM area_company_relations relation
WHERE (relation.area_id=asset.operational_area_id OR relation.area_id=asset.id)
AND relation.company_id=${company}::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
)
)`);
}
const where = conditions.join(' AND ');
const [countRow] = (await this.dataSource.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE ${where}
`,params)) as Array<{total:number}>;
const total=Number(countRow?.total ?? 0);
const offset=(query.page-1)*query.pageSize;
params.push(query.pageSize);
const limit=`$${params.length}`;
params.push(offset);
const offsetParam=`$${params.length}`;
const data=await this.dataSource.query(`
SELECT
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',parent.id,'code',parent.code,'name',parent.name
) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',area.id,'code',area.code,'name',area.name
) END AS "operationalArea",
(
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=COALESCE(asset.operational_area_id,
CASE WHEN type.operational_role='AREA' THEN asset.id ELSE NULL END)
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC
LIMIT 1
) AS "operatorCompany",
asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus",
(SELECT COUNT(*)::integer FROM assets child
WHERE child.parent_id=asset.id AND child.information_status<>'INACTIVE') AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType",
(SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount",
asset.data_origin AS "dataOrigin",
(asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified",
asset.current_version AS "currentVersion",
asset.updated_at AS "updatedAt"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN LATERAL (
SELECT ST_GeometryType(geometry.geometry)::text AS type
FROM asset_geometries geometry
WHERE geometry.asset_id=asset.id
ORDER BY geometry.updated_at DESC
LIMIT 1
) geometry_type ON true
WHERE ${where}
ORDER BY CASE
WHEN lower(type.code)='departamento' THEN 0
WHEN type.operational_role='COMPANY' THEN 1
WHEN lower(type.code)='area' THEN 2
WHEN lower(type.code)='yacimiento' THEN 3
WHEN lower(type.code)='instalacion' THEN 4
WHEN lower(type.code)='subinstalacion' THEN 5 ELSE 9 END,
asset.name,asset.code
LIMIT ${limit} OFFSET ${offsetParam}
`,params);
return {
data,
meta:{
page:query.page,
pageSize:query.pageSize,
total,
totalPages:total===0 ? 0 : Math.ceil(total/query.pageSize),
},
};
}
async departments(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"lower(type.code)='departamento'",
"department.information_status<>'INACTIVE'",
'type.is_active=true',
];
const add = (value: unknown): string => {
params.push(value);
return `$${params.length}`;
};
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(department.code ILIKE ${p} OR department.name ILIKE ${p} OR COALESCE(department.common_name,'') ILIKE ${p})`);
}
if (query.operatorCompanyId) {
const p = add(query.operatorCompanyId);
conditions.push(`EXISTS (
SELECT 1
FROM assets area
JOIN asset_types atype ON atype.id=area.asset_type_id
JOIN area_company_relations relation ON relation.area_id=area.id
WHERE area.parent_id=department.id
AND lower(atype.code)='area'
AND relation.company_id=${p}::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
)`);
}
const data = await this.dataSource.query(`
SELECT
department.id,department.code,department.name,department.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
department.information_status AS "informationStatus",
department.operational_status AS "operationalStatus",
(SELECT COUNT(*)::integer FROM assets area
JOIN asset_types atype ON atype.id=area.asset_type_id
WHERE area.parent_id=department.id AND lower(atype.code)='area'
AND area.information_status<>'INACTIVE') AS "areaCount",
(SELECT COUNT(*)::integer FROM assets child
WHERE child.parent_id=department.id AND child.information_status<>'INACTIVE') AS "childrenCount"
FROM assets department
JOIN asset_types type ON type.id=department.asset_type_id
WHERE ${conditions.join(' AND ')}
ORDER BY department.name,department.code
`, params);
return { data, meta: { count: data.length } };
}
async companies(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"type.operational_role='COMPANY'",
"company.information_status<>'INACTIVE'",
'type.is_active=true',
];
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
conditions.push(`(company.code ILIKE $1 OR company.name ILIKE $1 OR COALESCE(company.common_name,'') ILIKE $1)`);
}
const data = await this.dataSource.query(`
SELECT company.id,company.code,company.name,company.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
company.information_status AS "informationStatus",
(SELECT COUNT(*)::integer FROM area_company_relations relation
WHERE relation.company_id=company.id AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL) AS "areaCount"
FROM assets company
JOIN asset_types type ON type.id=company.asset_type_id
WHERE ${conditions.join(' AND ')}
ORDER BY company.name,company.code
`,params);
return { data,meta:{count:data.length} };
}
async areas(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"type.operational_role='AREA'",
"area.information_status<>'INACTIVE'",
'type.is_active=true',
];
const add = (value: unknown): string => { params.push(value); return `$${params.length}`; };
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`);
}
if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`);
if (query.operatorCompanyId) {
const p = add(query.operatorCompanyId);
conditions.push(`EXISTS (SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=area.id AND relation.company_id=${p}::uuid
AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL)`);
}
const data = await this.dataSource.query(`
SELECT area.id,area.code,area.name,area.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
area.information_status AS "informationStatus",area.operational_status AS "operationalStatus",
(SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=area.id AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC LIMIT 1) AS "currentOperator",
(SELECT COUNT(*)::integer FROM assets yacimiento JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=area.id AND lower(ytype.code)='yacimiento'
AND yacimiento.information_status<>'INACTIVE') AS "yacimientoCount",
(SELECT COUNT(*)::integer FROM assets inventory
WHERE inventory.information_status<>'INACTIVE' AND inventory.operational_area_id=area.id) AS "inventoryCount"
FROM assets area JOIN asset_types type ON type.id=area.asset_type_id
WHERE ${conditions.join(' AND ')}
ORDER BY area.name,area.code
`, params);
return { data, meta: { count: data.length } };
}
async children(parentId: string, query: InventoryBrowserQueryDto) {
const parent = await this.parent(parentId);
const allowedChildType = this.allowedChildType(parent.typeCode);
if (!allowedChildType) return { parent, data: [], meta: { count: 0, hasMore: false } };
const params: unknown[] = [parentId, allowedChildType];
const conditions = [
'asset.parent_id=$1::uuid',
'lower(type.code)=lower($2)',
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
];
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
const p = `$${params.length}`;
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
}
params.push(201);
const limit = `$${params.length}`;
const rows = await this.dataSource.query(`
SELECT
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',parent_asset.id,'code',parent_asset.code,'name',parent_asset.name
) END AS parent,
asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus",
asset.is_inventory_instance AS "isInventoryInstance",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level
) END AS "inventoryFamily",
(SELECT COUNT(*)::integer FROM assets child
WHERE child.parent_id=asset.id AND child.information_status<>'INACTIVE') AS "childrenCount",
CASE WHEN family.id IS NULL THEN 0 ELSE (
SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=family.id
) END AS "findingCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
asset.updated_at AS "updatedAt"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent_asset ON parent_asset.id=asset.parent_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE ${conditions.join(' AND ')}
ORDER BY asset.name,asset.code
LIMIT ${limit}
`, params);
const hasMore = rows.length > 200;
const data = hasMore ? rows.slice(0,200) : rows;
return { parent, data, meta: { count: data.length, hasMore } };
}
private async parent(parentId: string): Promise<ParentContext> {
const [parent] = await this.dataSource.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
`,[parentId]) as ParentContext[];
if (!parent) {
throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' });
}
if (!['departamento','area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) {
throw new BadRequestException({
code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID',
message:'La navegación de Inventarios admite Departamento → Área → Yacimiento → Instalación → Subinstalación',
});
}
return parent;
}
private allowedChildType(typeCode: string): string | null {
switch (typeCode.toLowerCase()) {
case 'departamento': return 'area';
case 'area': return 'yacimiento';
case 'yacimiento': return 'instalacion';
case 'instalacion': return 'subinstalacion';
default: return null;
}
}
}
@@ -0,0 +1,102 @@
import {
Body,
Controller,
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 {
CreateInventoryFamilyDto,
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
import {
CreateInventoryFamilyAttributeDto,
UpdateInventoryFamilyAttributeDto,
} from './dto/inventory-family-attribute.dto';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
@Controller('inventory-families')
export class InventoryFamilyCatalogController {
constructor(private readonly families: InventoryFamilyCatalogService) {}
@Get('admin')
@RequirePermissions('asset_types.read')
admin() {
return this.families.listAdmin();
}
@Post()
@RequirePermissions('asset_types.manage')
create(
@Body() dto: CreateInventoryFamilyDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.families.create(dto,principal,request);
}
@Patch(':familyId')
@RequirePermissions('asset_types.manage')
update(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:UpdateInventoryFamilyDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.update(familyId,dto,principal,request);
}
@Get(':familyId/attributes')
@RequirePermissions('asset_types.read')
attributes(@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string) {
return this.families.attributes(familyId);
}
@Post(':familyId/attributes')
@RequirePermissions('asset_types.manage')
createAttribute(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:CreateInventoryFamilyAttributeDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.createAttribute(familyId,dto,principal,request);
}
@Patch(':familyId/attributes/:attributeId')
@RequirePermissions('asset_types.manage')
updateAttribute(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Param('attributeId',new ParseUUIDPipe({version:'4'})) attributeId:string,
@Body() dto:UpdateInventoryFamilyAttributeDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.updateAttribute(familyId,attributeId,dto,principal,request);
}
@Put(':familyId/findings')
@RequirePermissions('finding_catalog.manage')
replaceFindings(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:ReplaceInventoryFamilyFindingsDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.replaceFindings(familyId,dto,principal,request);
}
@Get(':familyId/findings')
@RequirePermissions('assets.read')
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
return this.families.findings(familyId);
}
}
@@ -0,0 +1,445 @@
import { randomUUID } from 'node:crypto';
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 { AuditAction } from '../database/entities';
import type {
CreateInventoryFamilyDto,
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
import type {
CreateInventoryFamilyAttributeDto,
InventoryFamilyAttributeDataType,
UpdateInventoryFamilyAttributeDto,
} from './dto/inventory-family-attribute.dto';
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
sourceReference: string | null;
isActive: boolean;
parentFamilyIds: string[];
parentFamilies: FamilyParent[];
assetCount: number;
findingCount: number;
findingItemIds: string[];
technicalAttributeCount: number;
};
type AttributeRow = {
id: string;
inventoryFamilyId: string;
code: string;
name: string;
dataType: InventoryFamilyAttributeDataType;
isRequired: boolean;
isActive: boolean;
unit: string | null;
options: string[] | null;
sortOrder: number;
};
@Injectable()
export class InventoryFamilyCatalogService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listAdmin() {
const data = await this.dataSource.query(this.familySelect(`ORDER BY family.level,family.is_active DESC,family.name,family.code`)) as FamilyRow[];
return { data };
}
async findings(familyId: string) {
const family = await this.family(familyId, false);
const items = await this.dataSource.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
category.id AS "categoryId",category.code AS "categoryCode",category.name AS "categoryName"
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
JOIN finding_categories category ON category.id=item.category_id
WHERE mapping.inventory_family_id=$1::uuid
AND item.is_active=true AND category.is_active=true
ORDER BY category.sort_order,item.source_number,item.title
`, [familyId]);
return { family, items, count: items.length };
}
async attributes(familyId: string) {
const family = await this.family(familyId, false);
const items = await this.attributeRows(this.dataSource.manager, familyId);
return { family, items, count: items.length };
}
async create(
dto: CreateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const parentIds = await this.validateParents(manager, dto.level, dto.parentFamilyIds ?? [], null);
const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`;
const [inserted] = (await manager.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true)
RETURNING id
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
await this.replaceParents(manager, inserted.id, parentIds);
const created = await this.family(inserted.id,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: inserted.id,
afterData: created as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F6' },
},manager);
return created;
});
}
async update(
familyId: string,
dto: UpdateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const before = await this.family(familyId,false,manager,true);
const nextParentIds = dto.parentFamilyIds === undefined
? before.parentFamilyIds
: dto.parentFamilyIds;
const parentIds = await this.validateParents(manager,before.level,nextParentIds,familyId);
if (before.level === 'SUBINSTALLATION' && dto.parentFamilyIds !== undefined) {
await this.assertRemovedCompatibilitiesUnused(manager, familyId, parentIds);
}
await manager.query(`
UPDATE inventory_families SET
name=COALESCE($2::varchar,name),
information_labels=COALESCE($3::jsonb,information_labels),
is_active=COALESCE($4::boolean,is_active),
updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`,[
familyId,
dto.name ?? null,
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
dto.isActive ?? null,
]);
if (dto.parentFamilyIds !== undefined) await this.replaceParents(manager, familyId, parentIds);
const after = await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: familyId,
beforeData: before as unknown as Record<string,unknown>,
afterData: after as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_UPDATED' },
},manager);
return after;
});
}
async createAttribute(
familyId: string,
dto: CreateInventoryFamilyAttributeDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
await this.family(familyId,false,manager,true);
const options = this.attributeOptions(dto.dataType,dto.options);
try {
const [created] = (await manager.query(`
INSERT INTO inventory_family_attribute_definitions(
inventory_family_id,code,name,data_type,is_required,is_active,unit,options,sort_order,created_by,updated_by
) VALUES ($1::uuid,$2,$3,$4::asset_attribute_data_type,$5,true,$6,$7::jsonb,$8,$9::uuid,$9::uuid)
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
`,[familyId,dto.code,dto.name,dto.dataType,dto.isRequired ?? false,dto.unit ?? null,
options === null ? null : JSON.stringify(options),dto.sortOrder ?? 0,principal.userId])) as AttributeRow[];
if (!created) throw new Error('No se pudo crear el campo técnico');
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_attribute',entityId:created.id,
afterData:created as unknown as Record<string,unknown>,
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_CREATED',inventoryFamilyId:familyId},
},manager);
return created;
} catch (error) {
if (this.isUniqueViolation(error)) throw new ConflictException({
code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS',
message:'Ya existe un campo técnico con ese código en la clasificación',
});
throw error;
}
});
}
async updateAttribute(
familyId: string,
attributeId: string,
dto: UpdateInventoryFamilyAttributeDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
await this.family(familyId,false,manager,true);
const before = await this.attribute(manager,familyId,attributeId,true);
const nextType = dto.dataType ?? before.dataType;
const nextOptions = dto.options === undefined
? this.attributeOptions(nextType,before.options ?? undefined)
: this.attributeOptions(nextType,dto.options ?? undefined);
const [after] = (await manager.query(`
UPDATE inventory_family_attribute_definitions SET
name=COALESCE($3::varchar,name),
data_type=COALESCE($4::asset_attribute_data_type,data_type),
is_required=COALESCE($5::boolean,is_required),
is_active=COALESCE($6::boolean,is_active),
unit=CASE WHEN $7::boolean THEN $8::varchar ELSE unit END,
options=$9::jsonb,
sort_order=COALESCE($10::integer,sort_order),
updated_by=$11::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid AND inventory_family_id=$2::uuid
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
`,[attributeId,familyId,dto.name ?? null,dto.dataType ?? null,dto.isRequired ?? null,dto.isActive ?? null,
dto.unit !== undefined,dto.unit ?? null,nextOptions === null ? null : JSON.stringify(nextOptions),
dto.sortOrder ?? null,principal.userId])) as AttributeRow[];
if (!after) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'});
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_attribute',entityId:attributeId,
beforeData:before as unknown as Record<string,unknown>,afterData:after as unknown as Record<string,unknown>,
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_UPDATED',inventoryFamilyId:familyId},
},manager);
return after;
});
}
async replaceFindings(
familyId: string,
dto: ReplaceInventoryFamilyFindingsDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const family = await this.family(familyId,false,manager,true);
const uniqueIds=[...new Set(dto.itemIds)];
if (uniqueIds.length) {
const [count] = (await manager.query(`
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true
`,[uniqueIds])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==uniqueIds.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_FINDING_INVALID',
message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
});
}
const beforeIds=family.findingItemIds;
await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]);
if (uniqueIds.length) {
await manager.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
SELECT item_id,$2::uuid FROM UNNEST($1::uuid[]) AS selected(item_id)
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`,[uniqueIds,familyId]);
}
const after=await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_findings',entityId:familyId,
beforeData:{itemIds:beforeIds},afterData:{itemIds:after.findingItemIds},
metadata:{operation:'INVENTORY_FAMILY_FINDINGS_REPLACED',reason:dto.reason},
},manager);
return this.findingsWithManager(manager,familyId);
});
}
private async findingsWithManager(manager:EntityManager,familyId:string) {
const family=await this.family(familyId,false,manager);
const items=await manager.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
category.id AS "categoryId",category.code AS "categoryCode",category.name AS "categoryName"
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
JOIN finding_categories category ON category.id=item.category_id
WHERE mapping.inventory_family_id=$1::uuid AND item.is_active=true AND category.is_active=true
ORDER BY category.sort_order,item.source_number,item.title
`,[familyId]);
return {family,items,count:items.length};
}
private familySelect(suffix:string) {
return `
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds",
(SELECT COUNT(*)::integer FROM inventory_family_attribute_definitions definition
WHERE definition.inventory_family_id=family.id AND definition.is_active=true) AS "technicalAttributeCount"
FROM inventory_families family
${suffix}
`;
}
private async family(
familyId:string,
activeOnly:boolean,
manager:EntityManager=this.dataSource.manager,
lock=false,
):Promise<FamilyRow> {
const lockClause=lock ? 'FOR UPDATE OF family' : '';
const rows=(await manager.query(this.familySelect(`
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
${lockClause}
`),[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
});
return rows[0];
}
private async validateParents(
manager:EntityManager,
level:'INSTALLATION'|'SUBINSTALLATION',
requestedIds:string[],
ownId:string|null,
):Promise<string[]> {
const ids=[...new Set(requestedIds)];
if (level==='INSTALLATION') {
if (ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Una clasificación de Instalación no lleva compatibilidades padre',
});
return [];
}
if (!ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_REQUIRED',
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
});
if (ownId && ids.includes(ownId)) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma',
});
const [count]=(await manager.query(`
SELECT COUNT(*)::integer AS total FROM inventory_families
WHERE id=ANY($1::uuid[]) AND level='INSTALLATION' AND is_active=true
`,[ids])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_INVALID',
message:'Todas las compatibilidades deben ser clasificaciones de Instalación activas',
});
return ids;
}
private async replaceParents(manager:EntityManager,familyId:string,parentIds:string[]) {
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentIds.length) await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
SELECT $1::uuid,parent_id FROM UNNEST($2::uuid[]) AS selected(parent_id)
ON CONFLICT (child_family_id,parent_family_id) DO NOTHING
`,[familyId,parentIds]);
}
private async assertRemovedCompatibilitiesUnused(manager:EntityManager,childFamilyId:string,nextParentIds:string[]) {
const rows=await manager.query(`
SELECT child.name AS "childName",parent.name AS "parentName",COUNT(*)::integer AS total
FROM assets child
JOIN assets parent ON parent.id=child.parent_id
WHERE child.inventory_family_id=$1::uuid
AND parent.inventory_family_id IS NOT NULL
AND NOT (parent.inventory_family_id=ANY($2::uuid[]))
GROUP BY child.name,parent.name
ORDER BY total DESC
LIMIT 1
`,[childFamilyId,nextParentIds]);
if (rows[0]) throw new ConflictException({
code:'INVENTORY_FAMILY_COMPATIBILITY_IN_USE',
message:`No podés quitar esa compatibilidad: ya existen Subinstalaciones de este tipo dentro de ${rows[0].parentName}`,
});
}
private async attributeRows(manager:EntityManager,familyId:string):Promise<AttributeRow[]> {
return manager.query(`
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
FROM inventory_family_attribute_definitions
WHERE inventory_family_id=$1::uuid
ORDER BY is_active DESC,sort_order,name,code
`,[familyId]) as Promise<AttributeRow[]>;
}
private async attribute(manager:EntityManager,familyId:string,attributeId:string,lock=false):Promise<AttributeRow> {
const rows=(await manager.query(`
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
FROM inventory_family_attribute_definitions
WHERE id=$1::uuid AND inventory_family_id=$2::uuid
${lock ? 'FOR UPDATE' : ''}
`,[attributeId,familyId])) as AttributeRow[];
if (!rows[0]) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'});
return rows[0];
}
private attributeOptions(type:InventoryFamilyAttributeDataType,raw:string[]|undefined):string[]|null {
if (type!=='SELECT') return null;
const values=[...new Map((raw ?? []).map((value) => {
const clean=value.trim(); return [clean.toLocaleLowerCase('es-AR'),clean] as const;
}).filter(([,value]) => Boolean(value))).values()];
if (!values.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_ATTRIBUTE_OPTIONS_REQUIRED',
message:'Un campo de lista necesita al menos una opción',
});
return values;
}
private cleanLabels(labels:string[]):string[] {
const unique=new Map<string,string>();
for (const raw of labels) {
const clean=raw.trim(); if (!clean) continue;
const identity=clean.toLocaleLowerCase('es-AR');
if (!unique.has(identity)) unique.set(identity,clean);
}
if (unique.size>100) throw new ConflictException({
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información',
});
return [...unique.values()];
}
private isUniqueViolation(error:unknown) {
return Boolean(error && typeof error==='object' && 'code' in error && (error as {code?:string}).code==='23505');
}
}
@@ -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);
}
}
@@ -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<InventoryFunctionRow> {
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<string, unknown> }, manager);
return created;
});
}
async update(id: string, dto: UpdateInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext): Promise<InventoryFunctionRow> {
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<string, unknown>,
afterData: updated as unknown as Record<string, unknown> }, 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<FunctionAssignmentRow | null> {
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<InventoryFunctionRow> {
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<FunctionEligibleAsset> {
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;
}
}
@@ -0,0 +1,53 @@
import {
Body,
Controller,
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 { MergeInventoryAssetDto } from './dto/merge-inventory-asset.dto';
import { InventoryMergeService } from './inventory-merge.service';
@Controller('assets')
export class InventoryMergeController {
constructor(private readonly merges: InventoryMergeService) {}
@Get(':id/merge-status')
@RequirePermissions('assets.read')
status(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.merges.status(id);
}
@Post(':id/merge')
@RequirePermissions('assets.update')
merge(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: MergeInventoryAssetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.merges.merge(id, dto, principal, request);
}
}
@Controller('inspection-visits/:visitId/field-inventory')
export class FieldInventoryMergeController {
constructor(private readonly merges: InventoryMergeService) {}
@Post(':assetId/merge')
@RequirePermissions('assets.create', 'inspections.execute')
mergeFieldDiscovery(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Body() dto: MergeInventoryAssetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.merges.mergeFromVisit(visitId, assetId, dto, principal, request);
}
}
@@ -0,0 +1,522 @@
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 { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { MergeInventoryAssetDto } from './dto/merge-inventory-asset.dto';
import { AssetHistoryService } from './asset-history.service';
type MergeableAssetRow = {
id: string;
code: string;
name: string;
typeCode: string;
parentId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
inventoryFamilyId: string | null;
informationStatus: string;
};
type MergeRow = {
id: string;
sourceAssetId: string;
canonicalAssetId: string;
reason: string;
mergedAt: Date;
mergedBy: string | null;
source: string;
requestId: string | null;
};
type DocumentInvariantRow = {
actId: string;
actStatus: string;
lockedSha256: string | null;
closureSha256: string | null;
sealedAt: Date | null;
actVersion: number;
reportId: string | null;
reportStatus: string | null;
reportActClosureSha256: string | null;
reportFrozenSha256: string | null;
gedoPdfSha256: string | null;
wordSha256: string | null;
reportRevision: number | null;
};
const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
@Injectable()
export class InventoryMergeService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async status(assetId: string) {
const requested = await this.loadAsset(this.dataSource.manager, assetId, false);
const canonicalId = await this.resolveCanonicalId(this.dataSource.manager, assetId);
const canonical = canonicalId === assetId
? requested
: await this.loadAsset(this.dataSource.manager, canonicalId, false);
const chain = await this.mergeChain(assetId);
const aliases = await this.aliasesFor(canonicalId);
return {
requested,
isMerged: canonicalId !== assetId,
canonical,
chain,
aliases,
};
}
async merge(
sourceAssetId: string,
dto: MergeInventoryAssetDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction((manager) =>
this.mergeInTransaction(manager, sourceAssetId, dto, principal, request),
);
}
async mergeFromVisit(
visitId: string,
sourceAssetId: string,
dto: MergeInventoryAssetDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
const [gate] = (await this.dataSource.query(`
SELECT
visit.status,
EXISTS (
SELECT 1 FROM inspection_visit_members member
WHERE member.visit_id=visit.id
AND member.user_id=$3::uuid
AND member.included=true
) OR visit.lead_inspector_user_id=$3::uuid AS assigned,
EXISTS (
SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id=visit.id
AND discovery.asset_id=$2::uuid
) AS "createdInVisit"
FROM inspection_visits visit
WHERE visit.id=$1::uuid
`, [visitId, sourceAssetId, principal.userId])) as Array<{
status: string;
assigned: boolean;
createdInVisit: boolean;
}>;
if (!gate) throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
if (gate.status !== 'IN_PROGRESS') throw new ConflictException({
code: 'FIELD_MERGE_VISIT_NOT_IN_PROGRESS',
message: 'Sólo se puede conciliar Inventario mientras la inspección está en curso',
});
if (!gate.assigned) throw new ConflictException({
code: 'FIELD_MERGE_INSPECTOR_NOT_ASSIGNED',
message: 'El inspector no está asignado a esta inspección',
});
if (!gate.createdInVisit) throw new ConflictException({
code: 'FIELD_MERGE_SOURCE_MUST_BE_DISCOVERY',
message: 'Desde la APK sólo se puede fusionar un alta nacida en esta inspección',
});
return this.merge(sourceAssetId, dto, principal, request);
}
private async mergeInTransaction(
manager: EntityManager,
sourceAssetId: string,
dto: MergeInventoryAssetDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const existing = await this.currentMerge(manager, sourceAssetId);
if (existing) throw new ConflictException({
code: 'INVENTORY_ALREADY_MERGED',
message: 'Este registro ya fue fusionado',
canonicalAssetId: existing.canonicalAssetId,
});
const canonicalAssetId = await this.resolveCanonicalId(manager, dto.canonicalAssetId);
if (canonicalAssetId === sourceAssetId) throw new BadRequestException({
code: 'INVENTORY_MERGE_CYCLE',
message: 'La fusión generaría un ciclo entre registros',
});
const ids = [sourceAssetId, canonicalAssetId].sort();
const locked = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.parent_id AS "parentId",
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId",
asset.inventory_family_id AS "inventoryFamilyId",
asset.information_status AS "informationStatus"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=ANY($1::uuid[])
ORDER BY asset.id
FOR UPDATE OF asset
`, [ids])) as MergeableAssetRow[];
const source = locked.find((row) => row.id === sourceAssetId);
const canonical = locked.find((row) => row.id === canonicalAssetId);
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
this.validatePair(source, canonical);
// Empresa is temporal inspection context, not physical ownership. A merge is
// valid when both records resolve to the same Area in the physical hierarchy,
// even if their historical operator snapshots differ.
const [sourceAreaId, canonicalAreaId] = await Promise.all([
this.resolvePhysicalAreaId(manager, source.id),
this.resolvePhysicalAreaId(manager, canonical.id),
]);
if (!sourceAreaId || sourceAreaId !== canonicalAreaId) throw new BadRequestException({
code: 'INVENTORY_MERGE_AREA_MISMATCH',
message: 'Los duplicados deben pertenecer a la misma Área física',
});
const sourceParentCanonical = source.parentId
? await this.resolveCanonicalId(manager, source.parentId)
: null;
const targetParentCanonical = canonical.parentId
? await this.resolveCanonicalId(manager, canonical.parentId)
: null;
if (sourceParentCanonical !== targetParentCanonical) throw new BadRequestException({
code: 'INVENTORY_MERGE_PARENT_MISMATCH',
message: 'Los duplicados deben pertenecer al mismo padre estructural (considerando fusiones previas)',
});
if (source.typeCode.toLowerCase() === 'instalacion') {
const invalidChildren = await manager.query(`
SELECT child.id,child.code,child.name
FROM assets child
WHERE child.parent_id=$1::uuid
AND child.information_status<>'INACTIVE'
AND (
child.inventory_family_id IS NULL
OR $2::uuid IS NULL
OR NOT EXISTS (
SELECT 1 FROM inventory_family_parent_rules rule
WHERE rule.child_family_id=child.inventory_family_id
AND rule.parent_family_id=$2::uuid
)
)
ORDER BY child.name,child.code
LIMIT 20
`, [source.id, canonical.inventoryFamilyId]) as Array<{ id: string; code: string; name: string }>;
if (invalidChildren.length > 0) throw new ConflictException({
code: 'INVENTORY_MERGE_CHILD_FAMILY_CONFLICT',
message: 'Hay Subinstalaciones que no son compatibles con la familia de la Instalación canónica',
data: invalidChildren,
});
}
const affectedAssetIds = [source.id, canonical.id];
const documentInvariantsBefore = await this.documentInvariants(manager, affectedAssetIds);
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
this.snapshot(manager, source.id),
this.snapshot(manager, canonical.id),
]);
const sourceLabel = principal.transport === 'bearer' ? 'ANDROID' : 'WEB';
const [mergeRecord] = (await manager.query(`
INSERT INTO asset_merges (
source_asset_id,canonical_asset_id,reason,merged_by,source,request_id,
source_snapshot,canonical_snapshot
) VALUES ($1::uuid,$2::uuid,$3::text,$4::uuid,$5::varchar,$6::varchar,$7::jsonb,$8::jsonb)
RETURNING id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
`, [
source.id,
canonical.id,
dto.reason,
principal.userId,
sourceLabel,
request.requestId,
sourceSnapshot,
canonicalSnapshot,
])) as MergeRow[];
const children = (await manager.query(`
SELECT id FROM assets
WHERE parent_id=$1::uuid AND information_status<>'INACTIVE'
ORDER BY id FOR UPDATE
`, [source.id])) as Array<{ id: string }>;
const reparentedChildIds: string[] = [];
for (const child of children) {
await manager.query(`
UPDATE asset_context_history
SET valid_until=CURRENT_TIMESTAMP,
end_reason=$2::text,
ended_by=$3::uuid,
ended_at=CURRENT_TIMESTAMP
WHERE asset_id=$1::uuid AND valid_until IS NULL
`, [child.id, `Reparentado por fusión cronológica ${source.code}${canonical.code}`, principal.userId]);
await manager.query(`
UPDATE assets
SET parent_id=$2::uuid,updated_by=$3::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [child.id, canonical.id, principal.userId]);
const versionNumber = await this.history.capture(
manager,
child.id,
AssetVersionChangeType.UPDATED,
principal,
request,
);
await manager.query(`
INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by
)
SELECT id,parent_id,operational_area_id,operator_company_id,CURRENT_TIMESTAMP,
$2::text,$3::integer,$4::varchar,$5::varchar,$6::uuid
FROM assets WHERE id=$1::uuid
`, [
child.id,
`Reparentado por fusión cronológica ${source.code}${canonical.code}`,
versionNumber,
sourceLabel,
request.requestId,
principal.userId,
]);
reparentedChildIds.push(child.id);
}
await manager.query(`
UPDATE assets
SET information_status='INACTIVE',updated_by=$2::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [source.id, principal.userId]);
const sourceVersionNumber = await this.history.capture(
manager,
source.id,
AssetVersionChangeType.STATUS_CHANGED,
principal,
request,
);
// No historical Acta/Finding/Informe foreign key is rewritten by a merge.
// Verify that legal/documentary fingerprints are byte-for-byte unchanged
// before committing the transaction; otherwise rollback the entire merge.
const documentInvariantsAfter = await this.documentInvariants(manager, affectedAssetIds);
if (JSON.stringify(documentInvariantsBefore) !== JSON.stringify(documentInvariantsAfter)) {
throw new ConflictException({
code: 'INVENTORY_MERGE_DOCUMENT_INVARIANT_BROKEN',
message: 'La fusión intentó alterar la huella documental histórica y fue revertida',
});
}
const result = {
merge: mergeRecord,
source: { id: source.id, code: source.code, name: source.name },
canonical: { id: canonical.id, code: canonical.code, name: canonical.name },
sourceVersionNumber,
reparentedChildIds,
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
documentaryInvariantsVerified: true,
};
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_UPDATED,
entityType: 'asset_merge',
entityId: mergeRecord.id,
beforeData: sourceSnapshot,
afterData: canonicalSnapshot,
metadata: {
operation: 'CHRONOLOGICAL_MERGE',
sourceAssetId: source.id,
canonicalAssetId: canonical.id,
physicalAreaId: sourceAreaId,
reason: dto.reason,
sourceVersionNumber,
reparentedChildIds,
historicalReferencesRewritten: false,
documentaryInvariantsVerified: true,
},
}, manager);
return result;
}
private validatePair(source: MergeableAssetRow, canonical: MergeableAssetRow): void {
const sourceType = source.typeCode.toLowerCase();
const targetType = canonical.typeCode.toLowerCase();
if (!MERGEABLE_TYPES.has(sourceType) || sourceType !== targetType) throw new BadRequestException({
code: 'INVENTORY_MERGE_TYPE_INVALID',
message: 'Sólo se pueden fusionar Instalaciones entre sí o Subinstalaciones entre sí',
});
if (source.informationStatus === 'INACTIVE') throw new ConflictException({
code: 'INVENTORY_MERGE_SOURCE_INACTIVE',
message: 'El registro de origen ya está inactivo',
});
if (canonical.informationStatus === 'INACTIVE') throw new ConflictException({
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
message: 'El registro canónico no puede estar inactivo',
});
}
private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise<string | null> {
const rows = (await manager.query(`
WITH RECURSIVE lineage AS (
SELECT asset.id,asset.parent_id,asset.asset_type_id,0 AS depth
FROM assets asset WHERE asset.id=$1::uuid
UNION ALL
SELECT parent.id,parent.parent_id,parent.asset_type_id,lineage.depth+1
FROM assets parent
JOIN lineage ON lineage.parent_id=parent.id
WHERE lineage.depth<32
)
SELECT lineage.id
FROM lineage
JOIN asset_types type ON type.id=lineage.asset_type_id
WHERE type.operational_role='AREA'
ORDER BY lineage.depth
LIMIT 1
`, [assetId])) as Array<{ id: string }>;
return rows[0]?.id ?? null;
}
private async documentInvariants(manager: EntityManager, assetIds: string[]): Promise<DocumentInvariantRow[]> {
return (await manager.query(`
WITH affected_acts AS (
SELECT DISTINCT act.id
FROM inspection_acts act
LEFT JOIN inspection_act_assets act_asset
ON act_asset.act_id=act.id AND act_asset.included=true
LEFT JOIN inspection_findings finding ON finding.act_id=act.id
WHERE act_asset.asset_id=ANY($1::uuid[])
OR finding.asset_id=ANY($1::uuid[])
)
SELECT
act.id AS "actId",
act.status AS "actStatus",
act.locked_sha256 AS "lockedSha256",
act.closure_sha256 AS "closureSha256",
act.sealed_at AS "sealedAt",
act.current_version AS "actVersion",
report.id AS "reportId",
report.status AS "reportStatus",
report.act_closure_sha256 AS "reportActClosureSha256",
report.frozen_sha256 AS "reportFrozenSha256",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
report.word_sha256 AS "wordSha256",
report.current_revision_number AS "reportRevision"
FROM affected_acts affected
JOIN inspection_acts act ON act.id=affected.id
LEFT JOIN inspection_reports report ON report.act_id=act.id
ORDER BY act.id,report.id NULLS FIRST
`, [assetIds])) as DocumentInvariantRow[];
}
private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
const lockSql = lock ? 'FOR UPDATE OF asset' : '';
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.parent_id AS "parentId",
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId",
asset.inventory_family_id AS "inventoryFamilyId",
asset.information_status AS "informationStatus"
FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid ${lockSql}
`, [id])) as MergeableAssetRow[];
if (!rows[0]) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
return rows[0];
}
private async currentMerge(manager: EntityManager, sourceAssetId: string): Promise<MergeRow | null> {
const rows = (await manager.query(`
SELECT id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
FROM asset_merges WHERE source_asset_id=$1::uuid
`, [sourceAssetId])) as MergeRow[];
return rows[0] ?? null;
}
private async resolveCanonicalId(manager: EntityManager, assetId: string): Promise<string> {
const rows = (await manager.query(`
WITH RECURSIVE chain AS (
SELECT source_asset_id,canonical_asset_id,1 AS depth
FROM asset_merges WHERE source_asset_id=$1::uuid
UNION ALL
SELECT merge_record.source_asset_id,merge_record.canonical_asset_id,chain.depth+1
FROM asset_merges merge_record
JOIN chain ON merge_record.source_asset_id=chain.canonical_asset_id
WHERE chain.depth<32
)
SELECT canonical_asset_id AS id FROM chain ORDER BY depth DESC LIMIT 1
`, [assetId])) as Array<{ id: string }>;
return rows[0]?.id ?? assetId;
}
private async mergeChain(assetId: string): Promise<MergeRow[]> {
return (await this.dataSource.query(`
WITH RECURSIVE chain AS (
SELECT id,source_asset_id,canonical_asset_id,reason,merged_at,merged_by,source,request_id,1 AS depth
FROM asset_merges WHERE source_asset_id=$1::uuid
UNION ALL
SELECT merge_record.id,merge_record.source_asset_id,merge_record.canonical_asset_id,
merge_record.reason,merge_record.merged_at,merge_record.merged_by,merge_record.source,merge_record.request_id,
chain.depth+1
FROM asset_merges merge_record
JOIN chain ON merge_record.source_asset_id=chain.canonical_asset_id
WHERE chain.depth<32
)
SELECT id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
FROM chain ORDER BY depth
`, [assetId])) as MergeRow[];
}
private async aliasesFor(canonicalAssetId: string) {
return this.dataSource.query(`
WITH RECURSIVE aliases AS (
SELECT source_asset_id,canonical_asset_id,reason,merged_at,1 AS depth
FROM asset_merges WHERE canonical_asset_id=$1::uuid
UNION ALL
SELECT merge_record.source_asset_id,merge_record.canonical_asset_id,
merge_record.reason,merge_record.merged_at,aliases.depth+1
FROM asset_merges merge_record
JOIN aliases ON merge_record.canonical_asset_id=aliases.source_asset_id
WHERE aliases.depth<32
)
SELECT alias.source_asset_id AS id,asset.code,asset.name,alias.reason,
alias.merged_at AS "mergedAt",alias.depth
FROM aliases alias
JOIN assets asset ON asset.id=alias.source_asset_id
ORDER BY alias.merged_at,alias.depth
`, [canonicalAssetId]);
}
private async snapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
const rows = (await manager.query(`
SELECT TO_JSONB(asset) || JSONB_BUILD_OBJECT(
'typeCode',type.code,
'typeName',type.name,
'inventoryFamily',CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level
) END
) AS snapshot
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [assetId])) as Array<{ snapshot: Record<string, unknown> }>;
if (!rows[0]) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
return rows[0].snapshot;
}
}
@@ -0,0 +1,36 @@
import { Body, Controller, Get, Param, 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 { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
import { InventoryStructureService } from './inventory-structure.service';
@Controller('inventory-structure')
export class InventoryStructureController {
constructor(private readonly inventoryStructure: InventoryStructureService) {}
@Get()
@RequirePermissions('assets.read')
options() {
return this.inventoryStructure.options();
}
@Get('parents/:kind')
@RequirePermissions('assets.read')
parents(
@Param('kind') kind: string,
@Query('search') search?: string,
) {
return this.inventoryStructure.parents(kind, search);
}
@Post()
@RequirePermissions('assets.create')
create(
@Body() dto: CreateInventoryStructureDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.inventoryStructure.create(dto, principal, request);
}
}
@@ -0,0 +1,577 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
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 {
AssetDataOrigin,
AssetInformationStatus,
AssetOperationalStatus,
AssetVersionChangeType,
AuditAction,
} from '../database/entities';
import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/create-inventory-structure.dto';
import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string };
type SimpleOptionRow = { id: string; code: string; name: string };
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null;
informationLabels: string[];
parentFamilyIds: string[];
parentFamilies: FamilyParent[];
};
type ParentRow = {
id: string;
code: string;
name: string;
typeCode: string;
inventoryFamilyId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
};
type IdRow = { id: string };
type YacimientoContext = {
companyId: string;
concessionTypeId: string;
concessionName: string;
};
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area',
YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion',
SUBINSTALACION: 'subinstalacion',
};
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
EMPRESA: null,
DEPARTAMENTO: null,
AREA: 'departamento',
YACIMIENTO: 'area',
INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion',
};
const FAMILY_LEVEL_BY_KIND: Partial<Record<InventoryStructureKind, FamilyRow['level']>> = {
INSTALACION: 'INSTALLATION',
SUBINSTALACION: 'SUBINSTALLATION',
};
@Injectable()
export class InventoryStructureService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async options() {
const types = (await this.dataSource.query(`
SELECT id,code,name
FROM asset_types
WHERE (
lower(code) IN ('departamento','area','yacimiento','instalacion','subinstalacion')
OR operational_role='COMPANY'
) AND is_active=true
ORDER BY CASE
WHEN operational_role='COMPANY' THEN 0
WHEN lower(code)='departamento' THEN 1
WHEN lower(code)='area' THEN 2
WHEN lower(code)='yacimiento' THEN 3
WHEN lower(code)='instalacion' THEN 4
WHEN lower(code)='subinstalacion' THEN 5 ELSE 9 END
`)) as StructureTypeRow[];
const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase()));
const departamento = types.find((item) => item.code.toLowerCase()==='departamento');
const area = types.find((item) => item.code.toLowerCase()==='area');
const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento');
const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion');
const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion');
if (!company || !departamento || !area || !yacimiento || !instalacion || !subinstalacion) {
throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
});
}
const families = (await this.dataSource.query(`
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels",
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
FROM inventory_families family
WHERE family.is_active=true
ORDER BY family.level,family.name,family.code
`)) as FamilyRow[];
const companies = (await this.dataSource.query(`
SELECT asset.id,asset.code,COALESCE(profile.legal_name,asset.name) AS name
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id AND type.operational_role='COMPANY' AND type.is_active=true
LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id
WHERE asset.information_status<>'INACTIVE'
ORDER BY COALESCE(profile.legal_name,asset.name),asset.code
`)) as SimpleOptionRow[];
const concessionTypes = (await this.dataSource.query(`
SELECT id,code,name
FROM concession_types
WHERE is_active=true
ORDER BY CASE lower(name) WHEN 'explotación' THEN 1 WHEN 'exploración' THEN 2 ELSE 9 END,name,code
`)) as SimpleOptionRow[];
return {
independentMasters: [
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
],
levels: [
{ kind: 'DEPARTAMENTO', label: 'Departamento', type: departamento, parentKind: null, requiresFamily: false },
{ kind: 'AREA', label: 'Área', type: area, parentKind: 'DEPARTAMENTO', requiresFamily: false },
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false },
{ kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
],
companies,
concessionTypes,
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
};
}
async parents(kindValue: string, search?: string) {
const kind = kindValue.toUpperCase() as InventoryStructureKind;
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'DEPARTAMENTO' || kind === 'EMPRESA') {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
message: 'El nivel indicado no requiere un registro padre',
});
}
const expectedType = PARENT_TYPE_BY_KIND[kind];
const parameters: unknown[] = [expectedType];
let searchSql = '';
if (search?.trim()) {
parameters.push(`%${search.trim()}%`);
searchSql = `AND (asset.code ILIKE $2 OR asset.name ILIKE $2 OR COALESCE(asset.common_name,'') ILIKE $2)`;
}
const rows = await this.dataSource.query(`
SELECT
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels
) END AS "inventoryFamily",
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',parent.id,'code',parent.code,'name',parent.name
) END AS parent
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
WHERE lower(type.code)=lower($1::text)
AND asset.information_status<>'INACTIVE'
${searchSql}
ORDER BY asset.name,asset.code
LIMIT 100
`, parameters);
return { data: rows };
}
async create(
dto: CreateInventoryStructureDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
try {
return await this.dataSource.transaction(async (manager) => {
const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const yacimientoContext = await this.requireYacimientoContext(manager, dto);
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent && ['YACIMIENTO','INSTALACION','SUBINSTALACION'].includes(dto.kind)
? await this.resolveAreaId(manager, parent)
: null;
const operatorCompanyId = dto.kind === 'YACIMIENTO'
? yacimientoContext?.companyId ?? null
: dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION'
? parent?.operatorCompanyId ?? null
: null;
const concessionTypeId = dto.kind === 'YACIMIENTO'
? yacimientoContext?.concessionTypeId ?? null
: null;
if ((dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') && !operatorCompanyId) {
throw new ConflictException({
code: 'INVENTORY_YACIMIENTO_COMPANY_MISSING',
message: 'El Yacimiento de origen no tiene una Empresa relacionada válida',
});
}
const inserted = (await manager.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,
$7::varchar,$8::varchar,$9::varchar,$10::text,$11::asset_information_status,$12::asset_operational_status,
$13::varchar,$14::varchar,$15::varchar,$16::text,$17::uuid,$17::uuid,$17::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
concessionTypeId,
family?.id ?? null,
generatedCode,
dto.name,
dto.commonName ?? null,
dto.description ?? null,
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family
? `Clasificación técnica: ${family.code} · ${family.name}`
: yacimientoContext
? `Tipo de concesión: ${yacimientoContext.concessionName}`
: null,
principal.userId,
])) as Array<{ id: string }>;
const id = inserted[0]?.id;
if (!id) throw new Error('No se pudo crear el registro');
if (dto.kind === 'EMPRESA') {
await manager.query(`
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
ON CONFLICT (asset_id) DO UPDATE SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
`,[id,dto.name,principal.userId]);
}
if (dto.kind === 'YACIMIENTO' && operationalAreaId && operatorCompanyId && concessionTypeId) {
await this.ensureCompatibilityProjection(
manager,
operationalAreaId,
operatorCompanyId,
concessionTypeId,
yacimientoContext?.concessionName ?? 'Concesión',
);
}
const versionNumber = await this.history.capture(
manager,id,AssetVersionChangeType.CREATED,principal,request,
);
await manager.query(`
INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
`, [
id,parent?.id ?? null,operationalAreaId,operatorCompanyId,
dto.kind === 'YACIMIENTO'
? 'Alta manual de Yacimiento con Área, Empresa y Tipo de concesión'
: dto.kind === 'EMPRESA'
? 'Alta manual de Empresa independiente'
: 'Alta manual de estructura de Inventario',
versionNumber,request.requestId,principal.userId,
]);
const created = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_CREATED,
entityType: 'asset',entityId: id,
afterData: created as unknown as Record<string, unknown>,
metadata: {
versionNumber,inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
operatorCompanyId,
concessionTypeId,
},
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw new ConflictException({
code: 'ASSET_CODE_ALREADY_EXISTS',message: 'Ya existe un registro con ese código',
});
throw error;
}
}
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
const sql = kind === 'EMPRESA'
? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
const rows = (await manager.query(sql,params)) as StructureTypeRow[];
if (!rows[0]) throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',message: `El nivel ${kind} no está configurado`,
});
return rows[0];
}
private async requireParent(
manager: EntityManager,
kind: InventoryStructureKind,
parentId: string | null,
): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) {
if (parentId) throw new BadRequestException({
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
message: kind === 'EMPRESA'
? 'Una Empresa es un maestro independiente y no puede tener padre'
: 'Un Departamento es un registro raíz y no puede tener padre',
});
return null;
}
if (!parentId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
});
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId",
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
FOR KEY SHARE
`, [parentId])) as ParentRow[];
const parent = rows[0];
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
if (parent.typeCode.toLowerCase() !== expectedType) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
});
return parent;
}
private async requireYacimientoContext(
manager: EntityManager,
dto: CreateInventoryStructureDto,
): Promise<YacimientoContext | null> {
if (dto.kind !== 'YACIMIENTO') {
if (dto.operatorCompanyId || dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONTEXT_NOT_ALLOWED',
message: 'Empresa relacionada y Tipo de concesión sólo corresponden al Yacimiento',
});
}
return null;
}
if (!dto.operatorCompanyId) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED',
message: 'Seleccioná la Empresa relacionada del Yacimiento',
});
}
if (!dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONCESSION_REQUIRED',
message: 'Seleccioná el Tipo de concesión del Yacimiento',
});
}
const companyRows = (await manager.query(`
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid
AND type.operational_role='COMPANY'
AND type.is_active=true
AND asset.information_status<>'INACTIVE'
FOR KEY SHARE
`,[dto.operatorCompanyId])) as IdRow[];
if (!companyRows[0]) throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_INVALID',
message: 'La Empresa relacionada seleccionada no es válida',
});
const concessionRows = (await manager.query(`
SELECT id,name
FROM concession_types
WHERE id=$1::uuid AND is_active=true
FOR KEY SHARE
`,[dto.concessionTypeId])) as Array<{id:string;name:string}>;
if (!concessionRows[0]) throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONCESSION_INVALID',
message: 'El Tipo de concesión seleccionado no es válido',
});
return {
companyId: dto.operatorCompanyId,
concessionTypeId: dto.concessionTypeId,
concessionName: concessionRows[0].name,
};
}
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
if (parent.typeCode.toLowerCase()==='area') return parent.id;
if (parent.operationalAreaId) return parent.operationalAreaId;
const rows = (await manager.query(`
WITH RECURSIVE lineage AS (
SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid
UNION ALL
SELECT parent.id,parent.parent_id,parent.asset_type_id
FROM assets parent JOIN lineage child ON child.parent_id=parent.id
)
SELECT lineage.id
FROM lineage JOIN asset_types type ON type.id=lineage.asset_type_id
WHERE type.operational_role='AREA'
LIMIT 1
`,[parent.id])) as IdRow[];
const areaId=rows[0]?.id;
if (!areaId) throw new ConflictException({
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
message:'La ubicación seleccionada no pertenece a un Área válida',
});
return areaId;
}
private async requireFamily(
manager: EntityManager,
kind: InventoryStructureKind,
familyId: string | null,
parent: ParentRow | null,
): Promise<FamilyRow | null> {
const expectedLevel = FAMILY_LEVEL_BY_KIND[kind];
if (!expectedLevel) {
if (familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica',
});
return null;
}
if (!familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`,
});
const rows = (await manager.query(`
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",family.information_labels AS "informationLabels",
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY rule.parent_family_id)
FROM inventory_family_parent_rules rule WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
FROM inventory_families family
WHERE family.id=$1::uuid AND family.is_active=true
LIMIT 1
`, [familyId])) as FamilyRow[];
const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La clasificación técnica no existe' });
if (family.level !== expectedLevel) throw new BadRequestException({
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
message: 'La clasificación técnica no corresponde al nivel seleccionado',
});
if (kind === 'SUBINSTALACION') {
if (!parent?.inventoryFamilyId) throw new BadRequestException({
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
message:'La Instalación padre debe tener una clasificación técnica válida',
});
const [compatible]=(await manager.query(`
SELECT 1 AS ok FROM inventory_family_parent_rules
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
LIMIT 1
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
if (!compatible) throw new BadRequestException({
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada',
});
}
return family;
}
private async ensureCompatibilityProjection(
manager: EntityManager,
areaId: string,
companyId: string,
concessionTypeId: string,
concessionName: string,
): Promise<void> {
const companyProjection = await manager.query(`
SELECT id FROM area_company_relations
WHERE area_id=$1::uuid AND company_id=$2::uuid AND relation_role='OPERATOR' AND valid_to IS NULL
LIMIT 1
`,[areaId,companyId]);
if (!companyProjection[0]) {
await manager.query(`
INSERT INTO area_company_relations(area_id,company_id,relation_role,valid_from,start_reason)
VALUES($1::uuid,$2::uuid,'OPERATOR',CURRENT_TIMESTAMP,'Proyección derivada de Yacimiento')
`,[areaId,companyId]);
}
const rightType = concessionName.toLocaleLowerCase('es-AR').includes('explor')
? 'EXPLORATION_PERMIT'
: 'EXPLOITATION_CONCESSION';
const rightProjection = await manager.query(`
SELECT id FROM area_legal_rights
WHERE area_id=$1::uuid AND right_type=$2::area_legal_right_type AND status='ACTIVE'
LIMIT 1
`,[areaId,rightType]);
if (!rightProjection[0]) {
await manager.query(`
INSERT INTO area_legal_rights(area_id,right_type,name,status,notes)
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4)
`,[areaId,rightType,`${concessionName} · proyección de Yacimiento`,`Tipo de concesión canónico: ${concessionTypeId}`]);
}
}
private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'EMPRESA' ? 'EMP'
: kind === 'DEPARTAMENTO' ? 'DEP'
: kind === 'AREA' ? 'AREA'
: kind === 'YACIMIENTO' ? 'YAC'
: kind === 'INSTALACION' ? 'INST'
: 'SUB';
const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
}
private async loadView(manager: EntityManager, id: string) {
const rows = await manager.query(`
SELECT asset.id,asset.code,asset.name,asset.common_name AS "commonName",asset.description,
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany",
CASE WHEN concession.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',concession.id,'code',concession.code,'name',concession.name) END AS "concessionType",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels
) END AS "inventoryFamily",
asset.created_at AS "createdAt",asset.updated_at AS "updatedAt"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN assets company ON company.id=asset.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
LEFT JOIN concession_types concession ON concession.id=asset.concession_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
return rows[0];
}
}
@@ -0,0 +1,28 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, 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 { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
@Controller('assets/:assetId/technical-values')
export class InventoryTechnicalValuesController {
constructor(private readonly technical:InventoryTechnicalValuesService) {}
@Get()
@RequirePermissions('assets.read')
get(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string) {
return this.technical.get(assetId);
}
@Put()
@RequirePermissions('assets.update')
replace(
@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,
@Body() dto:UpdateInventoryTechnicalValuesDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.technical.replace(assetId,dto,principal,request);
}
}
@@ -0,0 +1,118 @@
import { BadRequestException, 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 type { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
type DefinitionRow = {
id:string; code:string; name:string; dataType:'TEXT'|'NUMBER'|'BOOLEAN'|'DATE'|'DATETIME'|'SELECT';
isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number;
};
type AssetRow = { id:string; inventoryFamilyId:string|null; familyCode:string|null; familyName:string|null; familyLevel:string|null };
@Injectable()
export class InventoryTechnicalValuesService {
constructor(private readonly dataSource:DataSource,private readonly audit:AuditService) {}
async get(assetId:string) {
return this.load(this.dataSource.manager,assetId);
}
async replace(assetId:string,dto:UpdateInventoryTechnicalValuesDto,principal:AuthPrincipal,request:RequestWithContext) {
return this.dataSource.transaction(async(manager) => {
const before=await this.load(manager,assetId,true);
const definitions=before.definitions as DefinitionRow[];
const definitionById=new Map(definitions.map((definition)=>[definition.id,definition]));
const normalized:Record<string,unknown>={};
for (const [definitionId,raw] of Object.entries(dto.values)) {
const definition=definitionById.get(definitionId);
if (!definition || !definition.isActive) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen a la clasificación actual',
});
const value=this.normalize(definition,raw);
if (value!==undefined) normalized[definitionId]=value;
}
for (const definition of definitions.filter((item)=>item.isActive && item.isRequired)) {
if (!(definition.id in normalized)) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FIELD_REQUIRED',message:`Completá el campo técnico obligatorio: ${definition.name}`,
});
}
await manager.query('DELETE FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid',[assetId]);
const entries=Object.entries(normalized);
if (entries.length) await manager.query(`
INSERT INTO asset_inventory_attribute_values(asset_id,definition_id,value,updated_by)
SELECT $1::uuid,item.definition_id,item.value,$3::uuid
FROM JSONB_TO_RECORDSET($2::jsonb) AS item(definition_id uuid,value jsonb)
`,[assetId,JSON.stringify(entries.map(([definition_id,value])=>({definition_id,value}))),principal.userId]);
const after=await this.load(manager,assetId);
await this.audit.record({
...administrationAuditContext(principal,request),action:AuditAction.ASSET_UPDATED,
entityType:'asset_inventory_technical_values',entityId:assetId,
beforeData:{values:before.values},afterData:{values:after.values},
metadata:{operation:'INVENTORY_TECHNICAL_VALUES_REPLACED',inventoryFamilyId:after.family.id},
},manager);
return after;
});
}
private async load(manager:EntityManager,assetId:string,lock=false) {
const rows=(await manager.query(`
SELECT asset.id,asset.inventory_family_id AS "inventoryFamilyId",
family.code AS "familyCode",family.name AS "familyName",family.level::text AS "familyLevel"
FROM assets asset LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid ${lock ? 'FOR UPDATE OF asset' : ''}
`,[assetId])) as AssetRow[];
const asset=rows[0];
if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'});
if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene clasificación técnica y no admite campos técnicos por rubro',
});
const definitions=(await manager.query(`
SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive",
unit,options,sort_order AS "sortOrder"
FROM inventory_family_attribute_definitions
WHERE inventory_family_id=$1::uuid
ORDER BY is_active DESC,sort_order,name,code
`,[asset.inventoryFamilyId])) as DefinitionRow[];
const valueRows=await manager.query(`
SELECT definition_id AS id,value FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid
`,[assetId]) as Array<{id:string;value:unknown}>;
return {
assetId,
family:{id:asset.inventoryFamilyId,code:asset.familyCode,name:asset.familyName,level:asset.familyLevel},
definitions,
values:Object.fromEntries(valueRows.map((row)=>[row.id,row.value])),
};
}
private normalize(definition:DefinitionRow,raw:unknown):unknown|undefined {
if (raw===null || raw===undefined || raw==='') return undefined;
switch(definition.dataType) {
case 'TEXT': {
if (typeof raw!=='string') return this.invalid(definition);
const value=raw.trim(); if (!value) return undefined; if (value.length>4000) return this.invalid(definition); return value;
}
case 'NUMBER': {
const value=typeof raw==='number' ? raw : typeof raw==='string' ? Number(raw) : Number.NaN;
if (!Number.isFinite(value)) return this.invalid(definition); return value;
}
case 'BOOLEAN': if (typeof raw!=='boolean') return this.invalid(definition); return raw;
case 'DATE': {
if (typeof raw!=='string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw) || Number.isNaN(Date.parse(`${raw}T00:00:00Z`))) return this.invalid(definition);
return raw;
}
case 'DATETIME': {
if (typeof raw!=='string' || Number.isNaN(Date.parse(raw))) return this.invalid(definition); return new Date(raw).toISOString();
}
case 'SELECT': {
if (typeof raw!=='string' || !definition.options?.includes(raw)) return this.invalid(definition); return raw;
}
}
}
private invalid(definition:DefinitionRow):never {
throw new BadRequestException({code:'INVENTORY_TECHNICAL_VALUE_INVALID',message:`Valor inválido para ${definition.name}`});
}
}
@@ -0,0 +1,199 @@
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<string, any>;
function dedupeById<T extends LooseRecord>(items: T[]): T[] {
const seen = new Set<string>();
const result: T[] = [];
for (const item of items) {
const id = String(item.id ?? '');
if (!id || seen.has(id)) continue;
seen.add(id);
result.push(item);
}
return result;
}
function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[] {
return [...items].sort((a, b) => {
const first = fieldCandidates.map((field) => b[field]).find(Boolean) ?? 0;
const second = fieldCandidates.map((field) => a[field]).find(Boolean) ?? 0;
return new Date(String(first)).getTime() - new Date(String(second)).getTime();
});
}
@Injectable()
export class MergedInventoryDossierService {
constructor(
private readonly assets: AssetsService,
private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService,
) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
const canonical = mergeStatus.canonical as LooseRecord;
const requested = mergeStatus.requested as LooseRecord;
const aliases = (mergeStatus.aliases ?? []) as LooseRecord[];
const inventoryIds = [canonical.id, ...aliases.map((alias) => alias.id)]
.filter(Boolean)
.map(String)
.filter((value, index, all) => all.indexOf(value) === index);
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
const dossier = await this.assets.dossier(assetId) as LooseRecord;
const identity = dossier.asset as LooseRecord;
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier };
}));
const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({
...entry,
historicalInventory: {
id: identity.id,
code: identity.code,
name: identity.name,
commonName: identity.commonName ?? null,
isCanonical: identity.id === canonical.id,
},
});
const collect = (key: string): LooseRecord[] => dedupeById(
dossiers.flatMap(({ identity, dossier }) =>
((dossier[key] ?? []) as LooseRecord[]).map((entry) => enrich(entry, identity)),
),
);
const visits = sortDesc(collect('visits'), ['actualStartedAt', 'plannedStartAt', 'createdAt']);
const acts = sortDesc(collect('acts'), ['occurredAt', 'createdAt']);
const findings = sortDesc(collect('findings'), ['createdAt', 'updatedAt']);
const evidence = sortDesc(collect('evidence'), ['capturedAt', 'createdAt']);
const communications = sortDesc(collect('communications'), ['occurredAt', 'createdAt']);
const verificationResults = sortDesc(collect('verificationResults'), ['verifiedAt', 'resultRecordedAt']);
const documents = sortDesc(collect('documents'), ['documentDate', 'linkedAt']);
const inspectionReports = sortDesc(collect('inspectionReports'), ['generatedAt']);
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,
meta: {
...(event.meta ?? {}),
historicalInventory: {
id: identity.id,
code: identity.code,
name: identity.name,
isCanonical: identity.id === canonical.id,
},
},
})),
);
const timeline: LooseRecord[] = dedupeById<LooseRecord>(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)}`,
kind: 'MERGE',
occurredAt: alias.mergedAt,
title: `Inventario fusionado · ${String(alias.code)}`,
description: alias.reason,
meta: {
sourceAssetId: alias.id,
sourceCode: alias.code,
sourceName: alias.name,
canonicalAssetId: canonical.id,
canonicalCode: canonical.code,
canonicalName: canonical.name,
historyPreserved: true,
},
});
}
timeline.sort((a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime());
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
return {
asset: {
id: canonical.id,
code: canonical.code,
name: canonical.name,
commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null,
currentFunction,
},
requestedAsset: {
id: requested.id,
code: requested.code,
name: requested.name,
},
merge: {
requestedWasMerged: requested.id !== canonical.id,
canonicalAssetId: canonical.id,
aliases,
chain: mergeStatus.chain ?? [],
aggregatedInventoryIds: inventoryIds,
historyPolicy: 'CHRONOLOGICAL_AGGREGATION_WITH_ORIGINAL_IDENTITY',
},
counters: {
inspections: visits.length,
acts: acts.length,
findings: findings.length,
verifications: verificationResults.length,
openFindings,
closedFindings,
evidence: evidence.length,
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,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
reports,
media,
versions,
timeline: timeline.slice(0, 1000),
};
}
}
+4 -1
View File
@@ -5,6 +5,8 @@ import { AuthorizationModule } from '../authorization/authorization.module';
import { PhaseADataModule } from '../core-data/phase-a-data.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { MobileAuthController } from './mobile-auth.controller';
import { MobileAuthService } from './mobile-auth.service';
import { AccessTokenGuard } from './guards/access-token.guard';
import { CsrfGuard } from './guards/csrf.guard';
import { AuthConfigService } from '../common/config/auth-config.service';
@@ -18,6 +20,7 @@ const providers = [
PasswordService,
TokenService,
AuthService,
MobileAuthService,
AccessTokenGuard,
CsrfGuard,
];
@@ -29,7 +32,7 @@ const providers = [
AuditModule,
AuthorizationModule,
],
controllers: [AuthController],
controllers: [AuthController, MobileAuthController],
providers,
exports: [
AuthConfigService,
@@ -0,0 +1,8 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class MobileRefreshDto {
@IsString()
@MinLength(32)
@MaxLength(256)
refreshToken!: string;
}
+56
View File
@@ -0,0 +1,56 @@
import {
Body,
Controller,
HttpCode,
Post,
Req,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import { CurrentAuth } from './decorators/current-auth.decorator';
import { Public } from './decorators/public.decorator';
import { SkipCsrf } from './decorators/skip-csrf.decorator';
import { LoginDto } from './dto/login.dto';
import { MobileRefreshDto } from './dto/mobile-refresh.dto';
import { MobileAuthService } from './mobile-auth.service';
@Controller('auth/mobile')
export class MobileAuthController {
constructor(private readonly mobileAuth: MobileAuthService) {}
@Post('login')
@HttpCode(200)
@Public()
@SkipCsrf()
@Throttle({ default: { limit: 5, ttl: 60_000, blockDuration: 60_000 } })
login(
@Body() dto: LoginDto,
@Req() request: RequestWithContext,
) {
return this.mobileAuth.login(dto, request);
}
@Post('refresh')
@HttpCode(200)
@Public()
@SkipCsrf()
@Throttle({ default: { limit: 20, ttl: 60_000 } })
refresh(
@Body() dto: MobileRefreshDto,
@Req() request: RequestWithContext,
) {
return this.mobileAuth.refresh(dto.refreshToken, request);
}
@Post('logout')
@HttpCode(200)
logout(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.mobileAuth.logout(principal, request);
}
}
+386
View File
@@ -0,0 +1,386 @@
import { isIP } from 'node:net';
import {
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import { AuthSessionsRepository } from '../core-data/repositories/auth-sessions.repository';
import { RolesRepository } from '../core-data/repositories/roles.repository';
import { UsersRepository } from '../core-data/repositories/users.repository';
import {
AuditAction,
AuditSource,
AuthSession,
User,
UserStatus,
} from '../database/entities';
import type { LoginDto } from './dto/login.dto';
import { PasswordService } from './services/password.service';
import {
IssuedRefreshToken,
TokenService,
} from './services/token.service';
interface RequestMetadata {
ip: string | null;
userAgent: string | null;
}
interface SuccessfulAuthentication {
user: User;
accessToken: string;
refreshToken: IssuedRefreshToken;
}
type LoginOutcome = SuccessfulAuthentication | null;
type RefreshOutcome =
| ({ kind: 'ok' } & SuccessfulAuthentication)
| { kind: 'reuse' }
| { kind: 'invalid' };
function invalidCredentials(): UnauthorizedException {
return new UnauthorizedException({
code: 'INVALID_CREDENTIALS',
message: 'Credenciales inválidas',
});
}
function invalidSession(): UnauthorizedException {
return new UnauthorizedException({
code: 'INVALID_SESSION',
message: 'Sesión móvil inválida o vencida',
});
}
function inspectorRequired(): ForbiddenException {
return new ForbiddenException({
code: 'INSPECTION_INSPECTOR_ROLE_REQUIRED',
message: 'Sólo un usuario con rol inspector puede ingresar a la aplicación móvil',
});
}
function requestMetadata(request: RequestWithContext): RequestMetadata {
const candidateIp = request.ip || request.socket.remoteAddress || '';
const ip = isIP(candidateIp) ? candidateIp : null;
const rawUserAgent = request.header('user-agent')?.trim();
return {
ip,
userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null,
};
}
@Injectable()
export class MobileAuthService {
constructor(
private readonly dataSource: DataSource,
private readonly users: UsersRepository,
private readonly sessions: AuthSessionsRepository,
private readonly roles: RolesRepository,
private readonly passwords: PasswordService,
private readonly tokens: TokenService,
private readonly audit: AuditService,
) {}
async login(dto: LoginDto, request: RequestWithContext) {
const identifier = dto.identifier.trim().toLowerCase();
const candidate = await this.users.findForAuthentication(identifier);
const metadata = requestMetadata(request);
if (!candidate) {
await this.passwords.verifyUnknown(dto.password);
await this.audit.record({
action: AuditAction.AUTH_LOGIN_FAILED,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
metadata: { identifier, reason: 'UNKNOWN_IDENTIFIER' },
});
throw invalidCredentials();
}
const outcome = await this.dataSource.transaction<LoginOutcome>(
async (manager) => {
const user = await manager
.getRepository(User)
.createQueryBuilder('user')
.addSelect('user.passwordHash')
.where('user.id = :id', { id: candidate.id })
.setLock('pessimistic_write')
.getOne();
if (!user) return null;
const now = new Date();
const passwordMatches = await this.passwords.verify(
user.passwordHash,
dto.password,
);
const locked = Boolean(user.lockedUntil && user.lockedUntil > now);
const active = user.status === UserStatus.ACTIVE;
if (!passwordMatches || locked || !active) {
if (!passwordMatches && active && !locked) {
await manager.query(
`
UPDATE users
SET
failed_login_attempts = failed_login_attempts + 1,
locked_until = CASE
WHEN failed_login_attempts + 1 >= $2
THEN CURRENT_TIMESTAMP + ($3 * INTERVAL '1 second')
ELSE locked_until
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`,
[
user.id,
this.tokens.maxLoginAttempts,
this.tokens.lockoutSeconds,
],
);
}
await this.audit.record(
{
actorUserId: user.id,
actorUsername: user.username,
action: AuditAction.AUTH_LOGIN_FAILED,
entityType: 'user',
entityId: user.id,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
metadata: {
reason: !active
? 'INACTIVE_USER'
: locked
? 'LOCKED_USER'
: 'INVALID_PASSWORD',
},
},
manager,
);
return null;
}
if (this.passwords.needsRehash(user.passwordHash)) {
user.passwordHash = await this.passwords.hash(dto.password);
}
user.failedLoginAttempts = 0;
user.lockedUntil = null;
user.lastLoginAt = now;
await manager.getRepository(User).save(user);
const refreshToken = this.tokens.issueRefreshToken();
const session = manager.getRepository(AuthSession).create({
id: refreshToken.sessionId,
userId: user.id,
refreshTokenHash: refreshToken.tokenHash,
expiresAt: this.tokens.refreshExpiresAt(now),
lastUsedAt: now,
revokedAt: null,
replacedBySessionId: null,
ip: metadata.ip,
userAgent: metadata.userAgent,
deviceLabel: dto.deviceLabel?.trim() || 'DH Android',
});
await manager.getRepository(AuthSession).save(session);
const accessToken = await this.tokens.issueAccessToken({
userId: user.id,
sessionId: session.id,
username: user.username,
});
await this.audit.record(
{
actorUserId: user.id,
actorUsername: user.username,
action: AuditAction.AUTH_LOGIN_SUCCESS,
entityType: 'auth_session',
entityId: session.id,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
metadata: { deviceLabel: session.deviceLabel },
},
manager,
);
return { user, accessToken, refreshToken };
},
);
if (!outcome) throw invalidCredentials();
return this.complete(outcome);
}
async refresh(rawToken: string, request: RequestWithContext) {
const parsed = this.tokens.parseRefreshToken(rawToken);
if (!parsed) throw invalidSession();
const metadata = requestMetadata(request);
const outcome = await this.dataSource.transaction<RefreshOutcome>(
async (manager) => {
const session = await manager
.getRepository(AuthSession)
.createQueryBuilder('session')
.addSelect('session.refreshTokenHash')
.where('session.id = :id', { id: parsed.sessionId })
.setLock('pessimistic_write')
.getOne();
if (
!session ||
!this.tokens.verifyRefreshToken(rawToken, session.refreshTokenHash)
) {
return { kind: 'invalid' };
}
const user = await manager.getRepository(User).findOne({
where: { id: session.userId },
});
if (session.revokedAt) {
await this.sessions.revokeSessionFamily(session.id, manager);
await this.audit.record(
{
actorUserId: user?.id ?? null,
actorUsername: user?.username ?? null,
action: AuditAction.AUTH_REFRESH_REUSE_DETECTED,
entityType: 'auth_session',
entityId: session.id,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
},
manager,
);
return { kind: 'reuse' };
}
const now = new Date();
if (!user || user.status !== UserStatus.ACTIVE || session.expiresAt <= now) {
session.revokedAt = now;
session.lastUsedAt = now;
await manager.getRepository(AuthSession).save(session);
if (user?.status === UserStatus.INACTIVE) {
await this.sessions.revokeUserSessions(user.id, undefined, manager);
}
return { kind: 'invalid' };
}
const refreshToken = this.tokens.issueRefreshToken();
const replacement = manager.getRepository(AuthSession).create({
id: refreshToken.sessionId,
userId: user.id,
refreshTokenHash: refreshToken.tokenHash,
expiresAt: this.tokens.refreshExpiresAt(now),
lastUsedAt: now,
revokedAt: null,
replacedBySessionId: null,
ip: metadata.ip,
userAgent: metadata.userAgent,
deviceLabel: session.deviceLabel,
});
await manager.getRepository(AuthSession).save(replacement);
session.revokedAt = now;
session.lastUsedAt = now;
session.replacedBySessionId = replacement.id;
await manager.getRepository(AuthSession).save(session);
const accessToken = await this.tokens.issueAccessToken({
userId: user.id,
sessionId: replacement.id,
username: user.username,
});
await this.audit.record(
{
actorUserId: user.id,
actorUsername: user.username,
action: AuditAction.AUTH_REFRESH,
entityType: 'auth_session',
entityId: replacement.id,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
metadata: { replacedSessionId: session.id },
},
manager,
);
return { kind: 'ok', user, accessToken, refreshToken };
},
);
if (outcome.kind !== 'ok') throw invalidSession();
return this.complete(outcome);
}
async logout(
principal: AuthPrincipal,
request: RequestWithContext,
) {
const metadata = requestMetadata(request);
await this.dataSource.transaction(async (manager) => {
await this.sessions.revokeSession(
principal.sessionId,
principal.userId,
manager,
);
await this.audit.record(
{
actorUserId: principal.userId,
actorUsername: principal.username,
action: AuditAction.AUTH_LOGOUT,
entityType: 'auth_session',
entityId: principal.sessionId,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
},
manager,
);
});
return { status: 'ok' };
}
private async complete(authentication: SuccessfulAuthentication) {
const [roleCodes, permissionCodes] = await Promise.all([
this.roles.findRoleCodesForUser(authentication.user.id),
this.roles.findPermissionCodesForUser(authentication.user.id),
]);
if (!roleCodes.includes('inspector')) {
await this.sessions.revokeSession(
authentication.refreshToken.sessionId,
authentication.user.id,
);
throw inspectorRequired();
}
return {
user: {
id: authentication.user.id,
username: authentication.user.username,
firstName: authentication.user.firstName,
lastName: authentication.user.lastName,
email: authentication.user.email,
mustChangePassword: authentication.user.mustChangePassword,
roles: roleCodes,
permissions: permissionCodes,
},
accessToken: authentication.accessToken,
refreshToken: authentication.refreshToken.token,
accessExpiresInSeconds: this.tokens.accessTokenTtlSeconds,
};
}
}
+104 -51
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface DashboardAuditItem {
export interface DashboardInspectorActivityItem {
id: string;
occurredAt: Date;
actorUsername: string | null;
@@ -19,12 +19,13 @@ interface DashboardCountsRow {
inactiveUsers: number | string;
activeSessions: number | string;
openFindings: number | string;
awaitingCompanyResponse: number | string;
overdueCompanyResponses: number | string;
companyResponsesDueNext7Days: number | string;
awaitingVerificationSchedule: number | string;
actsInFollowUp: number | string;
findingsWithoutControlDate: number | string;
overdueControls: number | string;
controlsNext30Days: number | string;
reportsWorking: number | string;
reportsOfficialized: number | string;
sealedActsWithoutReport: number | string;
}
export interface DashboardUpcomingControl {
@@ -49,9 +50,14 @@ export class DashboardService {
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
const [counts] = (await manager.query(`
SELECT
(SELECT COUNT(*) FROM assets WHERE information_status <> 'INACTIVE') AS "totalAssets",
(SELECT COUNT(*) FROM assets WHERE information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation",
(SELECT COUNT(*) FROM assets asset WHERE asset.information_status <> 'INACTIVE' AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)) AS "assetsWithoutGeometry",
(SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status <> 'INACTIVE') AS "totalAssets",
(SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation",
(
SELECT COUNT(*) FROM assets asset
WHERE asset.is_inventory_instance=true
AND asset.information_status <> 'INACTIVE'
AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)
) AS "assetsWithoutGeometry",
(SELECT COUNT(*) FROM inspection_visits WHERE status='PLANNED') AS "plannedInspections",
(SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers",
(SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers",
@@ -61,50 +67,74 @@ export class DashboardService {
WHERE revoked_at IS NULL
AND expires_at > CURRENT_TIMESTAMP
) AS "activeSessions",
(SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN') AS "openFindings",
(
SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN'
) AS "openFindings",
SELECT COUNT(*)
FROM inspection_acts act
WHERE act.status IN ('SEALED','CLOSED','RECTIFIED')
AND NOT (
EXISTS (
SELECT 1 FROM inspection_findings finding
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
)
AND NOT EXISTS (
SELECT 1 FROM inspection_findings finding
WHERE finding.act_id=act.id AND finding.status='OPEN'
)
)
) AS "actsInFollowUp",
(
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(`
const recentInspectorActivity = (await manager.query(`
SELECT
event.id,
event.occurred_at AS "occurredAt",
@@ -113,9 +143,25 @@ export class DashboardService {
event.entity_type AS "entityType",
event.entity_id AS "entityId"
FROM audit_events event
WHERE event.actor_user_id IS NOT NULL
AND (
EXISTS (
SELECT 1 FROM inspection_visit_members member
WHERE member.user_id=event.actor_user_id AND member.included=true
)
OR EXISTS (
SELECT 1 FROM inspection_visits visit
WHERE visit.lead_inspector_user_id=event.actor_user_id
)
)
AND (
event.action LIKE 'INSPECTION_%'
OR event.action LIKE 'ASSET_%'
OR event.action LIKE 'FINDING_%'
)
ORDER BY event.occurred_at DESC, event.id DESC
LIMIT 6
`)) as DashboardAuditItem[];
LIMIT 8
`)) as DashboardInspectorActivityItem[];
const upcomingControls = (await manager.query(`
SELECT
@@ -135,8 +181,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,14 +203,15 @@ 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),
actsInFollowUp: Number(counts?.actsInFollowUp ?? 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,
recentInspectorActivity,
upcomingControls,
generatedAt: new Date().toISOString(),
};
@@ -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',
+12 -1
View File
@@ -33,6 +33,8 @@ export enum AssetDataOrigin {
@Index('idx_assets_parent_id', ['parentId'])
@Index('idx_assets_operational_area_id', ['operationalAreaId'])
@Index('idx_assets_operator_company_id', ['operatorCompanyId'])
@Index('idx_assets_concession_type_id', ['concessionTypeId'])
@Index('idx_assets_inventory_family_id', ['inventoryFamilyId'])
@Index('idx_assets_information_status', ['informationStatus'])
@Index('idx_assets_operational_status', ['operationalStatus'])
@Index('idx_assets_data_origin', ['dataOrigin'])
@@ -53,6 +55,15 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
operatorCompanyId!: string | null;
@Column({ name: 'concession_type_id', type: 'uuid', nullable: true })
concessionTypeId!: string | null;
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
inventoryFamilyId!: string | null;
@Column({ name: 'is_inventory_instance', type: 'boolean', default: false })
isInventoryInstance!: boolean;
@Column({ type: 'varchar', length: 120 })
code!: string;
@@ -112,4 +123,4 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'provenance_updated_by', type: 'uuid', nullable: true })
provenanceUpdatedBy!: string | null;
}
}
@@ -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',
+27 -80
View File
@@ -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,
@@ -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',
@@ -3,12 +3,35 @@ 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',
}
// Una Inspección puede acumular múltiples Actas. El índice parcial de DRAFT es
// deliberado: permite el historial multi-Acta y bloquea dos borradores simultáneos.
@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 +39,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 +53,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 +71,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;
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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;
}
@@ -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;
@@ -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;
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<string, unknown>;
@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;
}
@@ -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;
}
@@ -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;
}
@@ -19,6 +19,18 @@ export class User extends TimestampedEntity {
@Column({ type: 'varchar', length: 320, nullable: true })
email!: string | null;
@Column({ type: 'varchar', length: 32, nullable: true })
dni!: string | null;
@Column({ type: 'varchar', length: 40, nullable: true })
phone!: string | null;
@Column({ name: 'job_title', type: 'varchar', length: 160, nullable: true })
jobTitle!: string | null;
@Column({ name: 'employee_number', type: 'varchar', length: 80, nullable: true })
employeeNumber!: string | null;
@Column({ name: 'password_hash', type: 'text', select: false })
passwordHash!: string;

Some files were not shown because too many files have changed in this diff Show More