Compare commits

..
Author SHA1 Message Date
admin 103ecf2fae fix(api): prevent NaN act version during mobile create
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m19s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m5s
2026-09-14 14:06:26 -03:00
admin 23880521d9 fix(api): align health version with package metadata
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m16s
DH V2 CI / Docker / scripts contract (push) Successful in 50s
2026-09-14 10:54:14 -03:00
admin 637c28dc2f fix(web): align visible version with 0.23.0-2
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m1s
2026-09-14 10:37:58 -03:00
admin fc0c62c70a docs(android): pin 0.19.2 server compatibility
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / Docker / scripts contract (push) Canceled after 0s
Production dependency audit / API · production dependencies (push) Canceled after 0s
Production dependency audit / WEB · production dependencies (push) Canceled after 0s
DH V2 CI / WEB · typecheck, build (push) Canceled after 0s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m34s
2026-09-14 10:31:54 -03:00
admin 3ab10b37ea chore(web): bump version for deferred Acta urgency
DH V2 CI / Docker / scripts contract (pull_request) Skipped
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / WEB · typecheck, build (push) Canceled after 0s
DH V2 CI / Docker / scripts contract (push) Canceled after 0s
Production dependency audit / API · production dependencies (push) Canceled after 0s
Production dependency audit / WEB · production dependencies (push) Canceled after 0s
DH V2 CI / API · typecheck, tests, build (pull_request) Failing after 26s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 20s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
2026-09-14 10:30:15 -03:00
admin c6475c851e fix(api): clear legacy draft urgency during migration
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m17s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 32s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 19s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Successful in 28s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 50s
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / WEB · typecheck, build (push) Canceled after 0s
DH V2 CI / Docker / scripts contract (push) Canceled after 0s
Production dependency audit / API · production dependencies (push) Canceled after 0s
Production dependency audit / WEB · production dependencies (push) Canceled after 0s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Canceled after 3m58s
2026-09-14 10:13:02 -03:00
admin b757bf7729 ci(gitea): run planning smoke API requests inside container
DH V2 CI / API · typecheck, tests, build (pull_request) Canceled after 0s
DH V2 CI / WEB · typecheck, build (pull_request) Canceled after 0s
DH V2 CI / Docker / scripts contract (pull_request) Canceled after 0s
Production dependency audit / API · production dependencies (pull_request) Canceled after 0s
Production dependency audit / WEB · production dependencies (pull_request) Canceled after 0s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Canceled after 4m11s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Successful in 27s
2026-09-14 10:09:29 -03:00
admin e30e5d90ea fix(android): align Acta flow and defer urgency to close
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m31s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m30s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 33s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Failing after 1m33s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 1m5s
2026-09-14 09:27:30 -03:00
admin d44ae049f3 test(android): align 0.19.1 release contract and record field review
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m11s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m12s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 30s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Production dependency audit / API · production dependencies (pull_request) Successful in 8s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 48s
2026-09-14 00:04:01 -03:00
admin 78e92970ff fix(android): align GPS and technical inventory field workflow
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m12s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m9s
DH V2 CI / API · typecheck, tests, build (pull_request) Failing after 25s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
DH V2 CI / Docker / scripts contract (pull_request) Skipped
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
2026-09-13 23:48:55 -03:00
admin 7fcb1d42b6 fix(android): harden field session and writes for 0.19.1 candidate
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m25s
2026-09-13 23:35:08 -03:00
admin e55684bf4f fix(web): compact installation parent checkbox options
DH V2 CI / API · typecheck, tests, build (push) Successful in 29s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 58s
2026-09-13 18:37:25 -03:00
admin ddf3b158e7 deploy: serialize Gitea production deployments
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 48s
2026-09-13 17:11:51 -03:00
admin 8564f1933c deploy: add native Gitea production workflow
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 48s
2026-09-13 16:56:54 -03:00
admin ea890cb807 ci(gitea): make API health probe DinD-compatible
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
Production dependency audit / API · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m5s
2026-09-13 16:40:46 -03:00
admin ce27cd01e9 ci(gitea): pin setup-gradle v4.4.3 for act compatibility
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m39s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
DH V2 CI / Docker / scripts contract (push) Failing after 1m38s
2026-09-13 13:03:21 -03:00
admin a155d9e075 Merge PR #59 · reparar Actividad global de Inventarios
Corrige la consulta SQL común del expediente de Actividad y aísla las facetas auxiliares para evitar errores globales.
2026-09-12 12:23:15 -03:00
admin e89d815868 test(api): actualizar contrato de Actividad territorial 2026-09-12 12:14:57 -03:00
admin ff0b6d83db test(api): cubrir fallo global de actividad 2026-09-12 12:11:55 -03:00
admin d894391d97 fix(api): registrar servicio de actividad resiliente 2026-09-12 12:11:40 -03:00
admin c79537def9 fix(api): usar dossier de actividad resiliente 2026-09-12 12:11:27 -03:00
admin 83c9e2ca3e fix(api): aislar y reparar consultas de actividad de inventario 2026-09-12 12:11:02 -03:00
admin 9254583154 Merge PR #58 · reparar Actividad de Yacimiento
Desacopla el dossier territorial del subsistema de fusión y evita que merge-status bloquee la pestaña Actividad.
2026-09-12 11:40:55 -03:00
admin 0b21e6bc50 test(f7): cubrir Actividad de Yacimiento sin dependencia de merge 2026-09-12 11:33:59 -03:00
admin 350e26566f fix(web): no bloquear Actividad por estado de fusión 2026-09-12 11:33:45 -03:00
admin ba79fbf9ae fix(api): desacoplar dossier territorial de merge 2026-09-12 11:32:50 -03:00
admin 77def3b511 Merge PR #56 · simplificación integral de Inventarios
F7 · modelo simple y experiencia cotidiana de Inventarios
2026-09-11 22:20:02 -03:00
admin ba01bc844e test(f7): cover simple inventory detail experience 2026-09-11 22:12:28 -03:00
admin fa6d4bd06a refactor(web): route canonical inventory to simple detail 2026-09-11 22:11:55 -03:00
admin f54e41d9c9 style(web): add simple inventory detail layout 2026-09-11 22:11:46 -03:00
admin 1b769a0ff4 feat(web): simplify canonical inventory details 2026-09-11 22:11:28 -03:00
admin 5695643fa8 fix(inventory): keep operator snapshot audit marker 2026-09-11 21:26:32 -03:00
admin a6e71fa299 test(inventory): move Yacimiento creation contract to F7 Area companies 2026-09-11 21:24:04 -03:00
admin 02091e14e6 test(inventory): align F5 source guards with F7 simple UX 2026-09-11 21:23:47 -03:00
admin 827b172c66 test(inventory): retain historical Area relation invariant 2026-09-11 21:23:28 -03:00
admin 45a1a08b16 test(web): replace obsolete F3 inventory contract with F7 simplicity guard 2026-09-11 21:20:16 -03:00
admin 3b9153f30a fix(inventory): use simple F7 create endpoint 2026-09-11 21:18:46 -03:00
admin 4f90032d00 feat(inventory): register simple inventory service 2026-09-11 21:18:21 -03:00
admin f621677527 feat(inventory): expose simple F7 create endpoint 2026-09-11 21:18:10 -03:00
admin eeffdc441e feat(inventory): add simple F7 create service 2026-09-11 21:17:59 -03:00
admin 351752256e feat(inventory): align Yacimiento context with simple F7 model 2026-09-11 21:17:19 -03:00
admin abe7e40c99 fix(inventory): allow multiple companies per Area 2026-09-11 21:16:20 -03:00
admin 3b7db0a869 docs: define F7 simple inventory model 2026-09-11 21:13:57 -03:00
admin 0213c29465 ux(admin): replace inventory model screen with simple type and field editor 2026-09-11 21:13:39 -03:00
admin fce370b287 ux(inventory): simplify manual create flow 2026-09-11 21:13:00 -03:00
admin 537b0a6f42 refactor(inventory): expose area companies for simple Yacimiento create 2026-09-11 21:12:24 -03:00
admin a57d6633bd Merge PR #55 · activar ficha compacta de Instalación de superficie
Corrige el matcher de tipo para que Instalación de superficie y Subinstalación usen realmente el Resumen compacto.
2026-09-11 14:50:17 -03:00
admin e18e86e918 fix(web): reconocer Instalación de superficie en ficha compacta 2026-09-11 14:44:42 -03:00
admin cadf766d3c Merge PR #54 · simplificar ficha de Instalación/Subinstalación
Compacta el Resumen de Instalaciones y Subinstalaciones, mueve el contexto físico al Historial y mantiene las funciones avanzadas accesibles sin alargar la ficha principal.
2026-09-11 14:32:07 -03:00
admin 1b151fe964 feat(web): simplificar ficha de instalación y subinstalación 2026-09-11 14:26:42 -03:00
admin c748aba650 style(web): compactar resumen de instalaciones 2026-09-11 14:25:43 -03:00
admin b8b578a3f0 Merge pull request #53 from enlineawork/release/android-0.19.0-final-field-flow
release(android): 0.19.0 flujo final de campo
2026-09-11 12:53:16 -03:00
admin 2455a89c3e test(android): fijar selección estructural por código 2026-09-11 12:43:11 -03:00
DH Android Finalizer 2a99183d31 fix(android): usar código estructural para Instalación y Subinstalación 2026-09-11 15:42:40 +00:00
admin 675d01bea7 chore: corregir clasificación estructural Android 2026-09-11 12:42:32 -03:00
admin 26d79f1e4d docs(android): fijar checklist de validación final 0.19.0 2026-09-11 12:37:16 -03:00
DH Android Finalizer 496bb785c1 fix(android): preservar ViewModel estable en release final 2026-09-11 15:36:42 +00:00
admin f513817388 chore: restaurar MainViewModel y aplicar cambios mínimos 0.19.0 2026-09-11 12:36:32 -03:00
admin 92a21d8f15 fix(android): limpiar firma y terminología final de Actas 2026-09-11 12:35:02 -03:00
admin 753fa9915f test(android): actualizar metadata de release 0.19.0 2026-09-11 12:33:09 -03:00
admin ec0ef732f7 test: actualizar contrato Android a 0.19.0 2026-09-11 12:27:42 -03:00
DH Android Finalizer 87adf71114 android-finalizer: finish Spanish visible copy 2026-09-11 15:24:59 +00:00
admin 0a856a1857 chore: completar limpieza de castellano Android 2026-09-11 12:24:51 -03:00
admin 247c652d40 fix(android): eliminar anglicismo visible en firma 2026-09-11 12:24:33 -03:00
admin b5ee17ce95 docs(android): documentar release final 0.19.0 2026-09-11 12:24:21 -03:00
admin cca2d38b98 test(android): fijar flujo final de inspección y castellano 2026-09-11 12:24:06 -03:00
DH Android Finalizer ebce2bb957 android-finalizer: cleanup final and remove temporary workflow 2026-09-11 15:23:15 +00:00
admin 963485618b chore: cerrar finalizador temporal Android 2026-09-11 12:23:06 -03:00
DH Android Finalizer 8f051158c1 android-finalizer: apply final field flow and Spanish UX 2026-09-11 15:21:04 +00:00
admin 2d8425dec5 chore: corregir ejecutor final Android 2026-09-11 12:20:55 -03:00
admin cfae2bb9ac chore: aplicar cierre final Android de campo 2026-09-11 12:19:50 -03:00
admin 5d8949d7d2 fix(android): mostrar estados de inspección en castellano 2026-09-11 12:18:25 -03:00
admin 304adcd9b0 feat(android): centralizar estados visibles en castellano 2026-09-11 12:17:52 -03:00
admin 20dd110928 release(android): preparar 0.19.0 final de campo 2026-09-11 12:17:35 -03:00
admin 80ffb35065 Merge pull request #51 from enlineawork/fix/yacimiento-companies-admin-advanced
fix(web): empresas en yacimientos y acceso avanzado de administrador
2026-09-11 11:01:30 -03:00
admin 6f79abbcff fix(web): mostrar empresas heredadas en yacimientos y proteger avanzado 2026-09-11 10:56:16 -03:00
admin d18375b85b fix(web): reservar ficha avanzada al administrador 2026-09-11 10:55:26 -03:00
admin 85a84e0f63 fix(web): centralizar acceso a administración avanzada 2026-09-11 10:55:08 -03:00
admin 03f42209b9 Merge pull request #50 from enlineawork/feature/territorial-simple-profile
feat(web): simplificar fichas territoriales
2026-09-11 10:39:50 -03:00
admin 74ff64182c feat(web): route territorial records to simplified profile 2026-09-11 10:34:54 -03:00
admin a9e300d6a4 style(web): simplify territorial profile layout 2026-09-11 10:34:42 -03:00
admin 143db53552 feat(web): add simplified territorial inventory profile 2026-09-11 10:34:19 -03:00
admin d9fbd95da3 Merge pull request #49 from enlineawork/feature/company-simple-profile-docs
feat(web): simplificar ficha de empresa y centralizar actas e informes
2026-09-11 10:11:05 -03:00
admin b744a77f49 fix(web): allow media panel without refresh callback 2026-09-11 10:05:34 -03:00
admin 082354320b fix(web): allow history panel without manual refresh key 2026-09-11 10:04:06 -03:00
admin 71dda05c20 feat(web): use contextual inventory detail route 2026-09-11 10:02:49 -03:00
admin b88855860d style(web): simplify company profile presentation 2026-09-11 10:02:35 -03:00
admin ec84f42a74 feat(web): add simplified company workspace 2026-09-11 10:02:20 -03:00
admin b859278ad0 feat(web): route companies to simplified profile 2026-09-11 10:01:32 -03:00
admin 32fc5bc60d Merge pull request #48 from enlineawork/fix/android-actas-picker-modern-ui
fix(android): Actas estables, selector de Instalación y UI moderna 0.18.0
2026-09-11 09:33:54 -03:00
admin 7c7eff3f93 docs: record F6.4 Android modern UI acceptance scope 2026-09-11 08:57:36 -03:00
admin 7e87306c9d docs(android): document modern field release 0.18.0 2026-09-11 08:56:59 -03:00
admin 88906e827b test(api): lock modern Android picker and mobile Act read model 2026-09-11 08:56:26 -03:00
admin 2aa4fd21b1 test(api): follow Android 0.18.0 test cut 2026-09-11 08:56:13 -03:00
admin 3089dd3724 test(android): lock 0.18.0 release metadata 2026-09-11 08:56:00 -03:00
admin d412399d13 chore(android): bump modern field release to 0.18.0 2026-09-11 08:55:51 -03:00
admin c14582e92c fix(android): use lightweight mobile Act list 2026-09-11 08:55:17 -03:00
admin c10248be2c feat(android): route field app through modern theme 2026-09-11 08:54:29 -03:00
admin 7217c11e8b feat(android): modernize field inventory and live Installation picker 2026-09-11 08:54:10 -03:00
admin 95012cf373 feat(android): modernize Actas field workspace 2026-09-11 08:52:56 -03:00
admin fc8c3ba7ef fix(api): register mobile Act read model 2026-09-11 08:47:48 -03:00
admin 95c5f4d0ca fix(api): expose lightweight mobile Act list 2026-09-11 08:47:39 -03:00
admin ca36140255 fix(api): decouple mobile Act list from document workflow 2026-09-11 08:47:25 -03:00
admin ed3c2c295a feat(android): add modern field theme 2026-09-11 08:46:51 -03:00
admin ea46be666f Merge pull request #47 from enlineawork/feature/android-field-fast-subinstallation-rc
feat(android): alta rápida y dinámica de Subinstalaciones en campo
2026-09-11 02:10:10 -03:00
admin 147e26b887 test(android): align API contract with 0.17.0 field APK 2026-09-11 02:02:09 -03:00
admin 9d0ca7ebd8 test(android): lock fast subinstallation field UX 2026-09-11 01:57:31 -03:00
admin f23f29b546 docs(android): describe dynamic field inventory release 2026-09-11 01:57:21 -03:00
admin c2c917e440 test(android): lock 0.17.0 release metadata 2026-09-11 01:57:07 -03:00
admin 51f476ce3e chore(android): bump field UX release to 0.17.0 2026-09-11 01:57:00 -03:00
admin 0a1c382254 feat(android): route inspections through dynamic field flow 2026-09-11 01:56:49 -03:00
admin e3231c5d04 feat(android): add fast field subinstallation flow 2026-09-11 01:56:33 -03:00
admin 326cf6681e Merge pull request #46 from enlineawork/feature/authoritative-inventory-model
feat: modelo autoritativo de Inventarios desde SQL definitivos
2026-09-11 01:34:57 -03:00
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
353 changed files with 29805 additions and 8209 deletions
+11 -1
View File
@@ -21,10 +21,20 @@ ACCESS_COOKIE_NAME=dhv2_access
REFRESH_COOKIE_NAME=dhv2_refresh REFRESH_COOKIE_NAME=dhv2_refresh
CSRF_COOKIE_NAME=dhv2_csrf 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_WORD_ROOT=/app/storage/asset-media/inspection-reports-word
INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions
INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf 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_HOST=
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=false SMTP_SECURE=false
+105 -18
View File
@@ -1,31 +1,36 @@
name: Android APK name: Android CI / RC
# F3.2: genera la APK debug verificable antes de promover la integración multi-Acta. # F6.1 presentation barrier: lint + real tests + debug artifact + release compile.
on: on:
push: push:
branches: branches:
- 'feature/f2-2*' - 'main'
- 'feature/f2-3*' - 'release/f6-1*'
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
paths: paths:
- 'android-app/**' - 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml' - '.github/workflows/android.yml'
pull_request: pull_request:
branches:
- 'main'
paths: paths:
- 'android-app/**' - 'android-app/**'
- 'api-v3/src/auth/**' - 'api-v3/src/**'
- '.github/workflows/android.yml' - '.github/workflows/android.yml'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build-debug-apk: android:
name: Android · lint, tests, debug APK, release compile
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 35
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -43,22 +48,104 @@ jobs:
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0' run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- name: Gradle 8.13 - name: Gradle 8.13
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
with: with:
gradle-version: '8.13' gradle-version: '8.13'
- name: Assemble debug - name: Validate mobile security and identity contract
working-directory: android-app run: |
run: gradle --no-daemon :app:assembleDebug 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: Unit tests - name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug :app:lintRelease
- 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 working-directory: android-app
run: gradle --no-daemon :app:testDebugUnitTest run: gradle --no-daemon :app:testDebugUnitTest
- name: Upload APK - 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 uses: actions/upload-artifact@v4
with: with:
name: DH-Inspeccion-F3.2-0.12.0-debug name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk path: android-app/dist/*
if-no-files-found: error if-no-files-found: error
retention-days: 30
- name: Upload unsigned release candidate
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-UNSIGNED
path: android-app/app/build/outputs/apk/release/app-release-unsigned.apk
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 retention-days: 14
+272 -1
View File
@@ -62,9 +62,277 @@ jobs:
while IFS= read -r -d '' script; do while IFS= read -r -d '' script; do
bash -n "$script" bash -n "$script"
done < <(find scripts -type f -name '*.sh' -print0) 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 - name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null run: docker compose --env-file .env.example config >/dev/null
- name: VPS-equivalent isolated API preflight - 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
# In containerized runners (Gitea DinD), 127.0.0.1 of the job
# is not the Docker daemon host. Probe the production API from
# inside its own container so this barrier works on GitHub and Gitea.
api_ready=0
for _ in $(seq 1 30); do
if docker compose --env-file .env.example exec -T api \
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}/api/v3/health`).then(async r => { const t = await r.text(); process.stdout.write(t); if (!r.ok) process.exit(1); }).catch(() => process.exit(1))' \
>/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: | run: |
set -Eeuo pipefail set -Eeuo pipefail
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}" image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
@@ -72,6 +340,9 @@ jobs:
docker run --rm \ docker run --rm \
-v "$PWD/api-v3/test:/app/test:ro" \ -v "$PWD/api-v3/test:/app/test:ro" \
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json: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 "$image" npm test
docker image rm "$image" >/dev/null 2>&1 || true docker image rm "$image" >/dev/null 2>&1 || true
- name: Build production images - name: Build production images
@@ -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
+26 -5
View File
@@ -2,9 +2,30 @@
Repositorio del sistema DH Inspección V2. Repositorio del sistema DH Inspección V2.
## Estructura ## Componentes
- `api-v3/`: API v3. - `api-v3/`: API NestJS/TypeORM y contratos de dominio.
- `web-v2/`: aplicación web. - `web-v2/`: aplicación WEB React/Vite.
- `scripts/`: utilidades operativas y de despliegue. - `android-app/`: aplicación Android para trabajo de campo.
- `docs/`: documentación técnica. - `scripts/`: utilidades operativas, preflight y despliegue.
- `docs/`: documentación funcional y técnica.
## Documentación vigente
- [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)
## Contratos que no deben romperse
- 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.
Las reglas detalladas, el procedimiento de cambio seguro y el recorrido de demo están en los documentos enlazados arriba.
+11
View File
@@ -0,0 +1,11 @@
# F6.4 · Android Campo Moderno
Criterios de aceptación:
- el buscador de Instalación padre consulta mientras se escribe y cada resultado se puede seleccionar directamente;
- la selección de Instalación abre la clasificación compatible de Subinstalación;
- Actas utiliza un read-model móvil desacoplado del módulo documental de oficina;
- la APK conserva GPS + foto antes de habilitar Hallazgos sobre Inventario nacido en campo;
- el lifecycle Acta DRAFT → LOCKED → SEALED no cambia;
- la interfaz de campo usa el tema DH moderno y mantiene acceso claro a Actas, Inventario y Hallazgos;
- Android lint/tests/debug/release, API tests/build, WEB build y Docker contract deben quedar verdes.
+16
View File
@@ -0,0 +1,16 @@
# Checklist final Android 0.19.2
La candidata sólo se promueve si el mismo SHA cumple:
- iniciar Inspección;
- crear/abrir Acta sin pedir urgencia ni Inventario;
- entrar a **Agregar Hallazgo** y recién allí seleccionar o crear Instalación/Subinstalación;
- exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos;
- registrar Hallazgos sobre el elemento seleccionado;
- al cerrar el contenido, exigir Urgente/No urgente, persistir esa decisión y mostrar **Pendiente de firma**;
- completar firma del Inspector y manifestación/firma o negativa de la empresa;
- mostrar el Acta como **Firmada y cerrada**;
- permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas;
- no mostrar **Inventario de campo** como acción paralela de la Inspección;
- no exponer estados internos en inglés en la interfaz de campo;
- Android CI / RC, DH V2 CI y auditoría de dependencias en verde.
+127
View File
@@ -0,0 +1,127 @@
# DH Inspección Android · release final de campo 0.19.2
## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.2**.
- `versionCode`: **30**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
- Servidor compatible de esta candidata: **API 0.29.0-2 / WEB 0.23.0-2**.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
## Procedimiento operativo validado
La APK 0.19.2 fija como recorrido principal de campo:
1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección.
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo. La urgencia todavía no se define.
3. **Agregar Hallazgos**. Recién desde esta acción se selecciona la Instalación/Subinstalación afectada.
4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección.
5. Si no existe, se da de alta desde campo sin abandonar el Acta.
6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
7. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar Acta y dejar pendiente de firma**. La urgencia queda fijada con el cierre; desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
8. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**.
9. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
## Alta de Instalaciones y Subinstalaciones
El alta móvil queda alineada con el Dashboard y con las validaciones del servidor.
### Instalación
- contexto territorial: Yacimiento/Área de la Inspección;
- clasificación técnica compatible;
- **Nombre técnico** obligatorio;
- Nombre habitual opcional;
- Descripción opcional;
- atributos técnicos definidos por el tipo; sólo los marcados como obligatorios bloquean el guardado;
- código DH generado por el sistema cuando corresponde;
- GPS de la tablet;
- fotografía obligatoria antes de crear Hallazgos.
### Subinstalación
Además de lo anterior requiere elegir primero la **Instalación padre**. El buscador consulta mientras se escribe y cada resultado es seleccionable tocando la tarjeta.
La clasificación y los atributos se obtienen dinámicamente desde el mismo catálogo administrado por el Dashboard; la APK no mantiene listas técnicas paralelas.
## Actas y firma
La terminología visible se simplifica:
- `DRAFT`**En elaboración**;
- `LOCKED`**Pendiente de firma**;
- `SEALED`**Firmada y cerrada**.
Cerrar el contenido no cierra automáticamente la Inspección y tampoco obliga a terminar la firma en ese instante: puede continuarse con otra Acta. La urgencia se decide en ese cierre, nunca al crear el borrador. Una vez cerrado el contenido, el Acta es inmutable.
Después de la firma/cierre definitivo se mantiene el circuito documental existente: PDF del Acta e informe INF editable (DOCX) para oficina.
## Castellano e identidad visual
Se revisaron las pantallas activas de campo para evitar exponer estados y opciones internas en inglés. Entre otros cambios: Pasaporte/Otro en documentos, estados de Inspección/Acta/Hallazgo en castellano, Correo electrónico, Huella de integridad y terminología de cierre orientada al usuario.
La pantalla de ingreso y el desbloqueo biométrico utilizan la identidad visual de Mendoza ya incorporada al proyecto.
## 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. pruebas unitarias 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.
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover cambios que afecten al servidor.
## Firma release
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero una APK productiva que deba actualizar `com.korexlabs.dhinspeccion` tiene que firmarse con la clave histórica.
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 si se distribuirá como actualización productiva;
- realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda;
- ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección;
- registrar el SHA Git y SHA-256 de la APK distribuida.
## Revisión 0.19.1
- Renovación de sesión coordinada entre los cuatro clientes API; no cierra sesión por pérdida de red, HTTP 429/500 o cancelación.
- Una respuesta tardía no puede restaurar una sesión cerrada ni usar la de otro inspector.
- Guardia de escrituras contra doble toque y conteo correcto de operaciones pendientes.
- Búsquedas descartan respuestas antiguas. Cambio de inspección limpia el Acta anterior.
- Mensajes visibles de permisos/GPS en evidencia y respeto de barras de sistema/teclado.
- Diez nuevas pruebas de comportamiento sobre sesión y concurrencia.
### Alcance real
Esta candidata requiere conexión. No implementa trabajo offline ni cola persistente de sincronización; un fallo de red no equivale a guardado. Tras un timeout de escritura debe verificarse el registro antes de repetir. No se presenta el artefacto debug como una release productiva. La firma histórica y el smoke en tablet siguen siendo requisitos para distribuir la release final.
### Segunda pasada funcional
- Coordenadas normalizadas al contrato API (6 decimales, precisión 3).
- Yacimiento seleccionable para Hallazgos y como padre explícito de nuevas Instalaciones. Se elimina el fallback que podía presentar un tipo Yacimiento como alta de Instalación.
- Formulario Datos técnicos sobre el elemento seleccionado: carga y guarda las definiciones/valores por familia mediante los endpoints existentes del Dashboard; valida obligatorios, números, Sí/No, fechas y opciones. Es un paso separado del alta estructural y GPS/foto.
- Se agregan siete pruebas para coordenadas y valores técnicos.
## Revisión 0.19.2 · flujo conceptual corregido
- Se elimina **Inventario de campo** como acción independiente de la pantalla de Inspección.
- El alta o selección de Inventario existe únicamente dentro de **Agregar Hallazgo**.
- El borrador de Acta nace vacío, sin Inventario y sin urgencia predeterminada.
- La urgencia se exige al cerrar el Acta y se persiste en la misma operación que la vuelve inmutable.
- `inspection_acts.urgency` puede quedar pendiente durante el borrador; el bloqueo exige la decisión y calcula el vencimiento desde allí.
+49
View File
@@ -0,0 +1,49 @@
# DH Android 0.19.1 · revisión de candidata
## Idea funcional verificada contra código y API
La app es la herramienta del inspector en campo. Oficina administra territorio, operadoras, clasificaciones, campos y catálogo; la APK consulta esas fuentes. Una Inspección contiene varias Actas, con una sola en elaboración simultánea. El Hallazgo pertenece al Acta y al elemento seleccionado. Cerrar contenido lo deja inmutable y pendiente de firma. Firma del inspector y manifestación/firma o negativa de empresa permiten sellar el Acta. La Inspección exige todas sus Actas activas selladas.
Yacimiento, Instalación y Subinstalación son destinos de Hallazgos. Una Instalación nueva debe crearse dentro de un Yacimiento y una Subinstalación dentro de una Instalación compatible. El inventario nacido en campo necesita GPS y foto antes del Hallazgo.
## Hallazgos corregidos
| Problema observado | Corrección |
| --- | --- |
| Cuatro clientes renovaban por separado la misma sesión | Coordinador compartido; protege rotación, logout y cambio de usuario |
| Cortes de red durante refresh borraban la sesión | Sólo una respuesta 401 de la sesión vigente la invalida |
| Operaciones solapadas liberaban ocupado antes de terminar | Conteo de pendientes y rechazo del segundo guardado |
| Respuestas de búsquedas antiguas sobrescribían resultados recientes | Generación de búsqueda y verificación del ID de inspección |
| Un Acta anterior podía persistir al cambiar de inspección | Limpieza de estado de Acta al abrir otra inspección |
| Coordenadas crudas excedían decimales admitidos | Normalización 6 decimales para coordenadas y 3 para precisión |
| Inventario móvil ocultaba Yacimientos | Selección para Hallazgos y alta de Instalación dentro de Yacimiento |
| Alta de Instalación podía adoptar otro tipo por fallback | Eliminado; pide elegir el Yacimiento padre correcto |
| Datos técnicos por familia no estaban editables | Formulario conectado a technical-values; mismos campos de oficina |
| Fallos de GPS/permisos en evidencia sin mensaje | Error visible y accionable |
| Barras del sistema/teclado podían tapar controles | Insets de sistema y teclado en la raíz |
## Evidencia automática
25 pruebas Android: 8 contratos existentes y 17 pruebas nuevas de comportamiento (sesión 7, concurrencia 3, GPS 3, campos técnicos 4). Lint debug/release y compilación debug/release en workflow Android. CI general comprueba API, WEB, scripts y migraciones; la auditoría de dependencias se ejecuta separadamente. Consultar las ejecuciones del SHA de la entrega, no sólo un resultado anterior.
## Límites de esta candidata
- Requiere conexión. No existe cola persistente offline ni resolución de conflictos sin señal.
- Un timeout de escritura puede ocurrir después de guardar en servidor. Revisar el registro antes de repetir; no se afirma idempotencia general de todas las escrituras.
- Los datos técnicos por familia se completan en un paso separado tras seleccionar/crear el elemento. GPS/foto continúa siendo la barrera de Hallazgos del servidor.
- No se ejecutó aceptación en tablet física: quedan por comprobar cámara, GPS, biometría, rotación/recreación de actividad, firmas y actualización sobre la APK instalada.
- La release compila sin firma. Se requiere la clave histórica y su certificado de referencia para distribuir una actualización productiva. No crear otra clave como sustituto.
- La candidata debug usa com.korexlabs.dhinspeccion.debug y no reemplaza la app productiva. La compatibilidad con una debug anterior depende también de su certificado; no desinstalar una app con información pendiente sin revisar antes.
## Recorrido de aceptación en tablet
1. Ingresar con inspector y cargar inspecciones asignadas.
2. Iniciar una inspección, crear Acta, seleccionar Yacimiento/Instalación/Subinstalación.
3. Crear Instalación bajo Yacimiento y Subinstalación compatible; probar GPS permitido/denegado y foto tomada/cancelada.
4. Completar campos técnicos requeridos, incluyendo No booleano, decimal con coma, fecha y opción de lista; volver a abrir para comprobar persistencia.
5. Crear Hallazgo catalogado y OTROS, adjuntar evidencia.
6. Cerrar contenido, comprobar inmutabilidad y estado Pendiente de firma.
7. Registrar firmas o negativa de empresa; sellar Acta.
8. Crear otra Acta, comprobar que una pendiente impide cerrar la Inspección; completar y cerrar.
9. Cortar conexión y recuperarla: errores visibles, sin pérdida de sesión por red ni falso aviso de guardado.
10. Verificar en Dashboard los mismos registros y documentos.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 19 versionCode = 30
versionName = "0.12.0" versionName = "0.19.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -3,11 +3,14 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" /> <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_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@drawable/ic_mendoza_launcher_exact"
android:roundIcon="@drawable/ic_mendoza_launcher_exact"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.DHInspeccion" android:theme="@style/Theme.DHInspeccion"
@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository import com.korexlabs.dhinspeccion.data.DhRepository
@@ -24,11 +25,14 @@ import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.StoredSession import com.korexlabs.dhinspeccion.data.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.time.Instant import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) { class MainViewModel(application: Application) : AndroidViewModel(application) {
private val operations = OperationTracker()
private var inventorySearchGeneration = 0
private val repository = DhRepository(application) private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application) private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application) private val actsRepository = MobileActsRepository(application)
@@ -49,6 +53,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList()) var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList())
private set private set
var inventoryParentId: String? by mutableStateOf(null)
private set
var fieldTypes: List<FieldType> by mutableStateOf(emptyList()) var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
private set private set
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null) var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
@@ -82,7 +88,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Ingresá usuario y contraseña." error = "Ingresá usuario y contraseña."
return return
} }
launchBusy { launchBusy(mutation = true) {
session = repository.login(identifier, password) session = repository.login(identifier, password)
notice = "Sesión iniciada." notice = "Sesión iniciada."
loadVisitsInternal() loadVisitsInternal()
@@ -96,6 +102,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
visits = emptyList() visits = emptyList()
visit = null visit = null
inventory = emptyList() inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearActState() clearActState()
@@ -110,8 +117,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun openVisit(id: String) = launchBusy { fun openVisit(id: String) = launchBusy {
visit = repository.visit(id) val loadedVisit = repository.visit(id)
clearActState()
visit = loadedVisit
inventory = emptyList() inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearFindingState() clearFindingState()
@@ -119,8 +129,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun closeVisitView() { fun closeVisitView() {
if (busy) return
inventorySearchGeneration += 1
visit = null visit = null
inventory = emptyList() inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearActState() clearActState()
@@ -130,8 +143,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun startVisit() { fun startVisit() {
val id = visit?.id ?: return val id = visit?.id ?: return
launchBusy { launchBusy(mutation = true) {
visit = repository.startVisit(id) visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada." notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true) loadActsInternal(id, selectDraft = true)
loadVisitsInternal() loadVisitsInternal()
@@ -151,53 +165,56 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun createActForSelectedInventory() { fun createAct() {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val asset = selectedFieldAsset?.asset
if (currentVisit.status != "IN_PROGRESS") { if (currentVisit.status != "IN_PROGRESS") {
error = "La inspección debe estar en curso para crear un Acta." error = "La Inspección debe estar en curso para crear un Acta."
return
}
if (asset == null) {
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
return return
} }
if (acts.any { it.status == "DRAFT" }) { if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Cerrala o cancelala antes de crear la siguiente." error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
return return
} }
launchBusy { launchBusy(mutation = true) {
val created = actsRepository.create(currentVisit.id, asset.id, currentVisit.code) val created = actsRepository.create(currentVisit.id, currentVisit.code)
selectedAct = created selectedAct = created
actClosure = actsRepository.closure(created.id) actClosure = actsRepository.closure(created.id)
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
notice = "${created.code} creada. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta." notice = "${created.code} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
} }
} }
fun searchInventory(search: String, parentId: String? = null) { fun searchInventory(search: String, parentId: String? = null) {
val id = visit?.id ?: return val currentVisit = visit ?: return
val generation = ++inventorySearchGeneration
launchBusy { launchBusy {
inventory = repository.fieldInventory(id, search, parentId).data val result = repository.fieldInventory(currentVisit.id, search, parentId).data
if (generation == inventorySearchGeneration && visit?.id == currentVisit.id) inventory = result
} }
} }
fun loadFieldTypes(parentId: String? = null) { fun loadFieldTypes(parentId: String? = null) {
val id = visit?.id ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
fieldTypes = repository.fieldTypes(id, parentId).data val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventorySearchGeneration += 1
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) { fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy { launchBusy(mutation = true) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la inspección." notice = "Inventario agregado a la Inspección."
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, item.id) selectedAct = actsRepository.ensureAsset(draft.id, item.id)
@@ -219,32 +236,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
latitude: Double, latitude: Double,
longitude: Double, longitude: Double,
accuracyM: Double?, accuracyM: Double?,
description: String? = null,
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") { if (visit?.status != "IN_PROGRESS") {
error = "La inspección debe estar en curso para dar de alta Inventario." error = "La Inspección debe estar en curso para dar de alta Inventario."
return return
} }
if (type.familyRequired && familyId == null) { if (type.familyRequired && familyId == null) {
error = "Elegí una familia técnica o la opción Otro / no catalogado." error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
return return
} }
launchBusy { launchBusy(mutation = true) {
val request = CreateFieldInventoryRequest( val request = CreateFieldInventoryRequest(
typeId = type.id, typeId = type.id,
parentId = parentId, parentId = parentId,
familyId = familyId, familyId = familyId,
name = name.trim(), name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() }, commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes, attributes = attributes,
deviceLatitude = latitude, deviceLatitude = FieldCoordinates.latitude(latitude),
deviceLongitude = longitude, deviceLongitude = FieldCoordinates.longitude(longitude),
deviceAccuracyM = accuracyM, deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
deviceCapturedAt = Instant.now().toString(), deviceCapturedAt = Instant.now().toString(),
) )
selectedFieldAsset = repository.createFieldAsset(visitId, request) 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." notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
} }
} }
@@ -259,7 +278,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Explicá brevemente por qué se trata de un duplicado." error = "Explicá brevemente por qué se trata de un duplicado."
return return
} }
launchBusy { launchBusy(mutation = true) {
val result = repository.mergeFieldAsset( val result = repository.mergeFieldAsset(
visitId = visitId, visitId = visitId,
assetId = source.id, assetId = source.id,
@@ -268,7 +287,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) )
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id) 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." notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id) selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
@@ -286,7 +305,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
val asset = selectedFieldAsset?.asset ?: return val asset = selectedFieldAsset?.asset ?: return
launchBusy { launchBusy(mutation = true) {
val response = repository.uploadFieldPhoto( val response = repository.uploadFieldPhoto(
visitId = visitId, visitId = visitId,
assetId = asset.id, assetId = asset.id,
@@ -301,7 +320,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} else { } else {
"Fotografía registrada." "Fotografía registrada."
} }
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (response.capture.readyForFinding && draft != null) { if (response.capture.readyForFinding && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id) selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
@@ -318,10 +337,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (draft == null) { if (draft == null) {
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos." error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
return return
} }
launchBusy { launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(draft.id, assetId) selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false) loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, assetId, draft.id) loadFindingOptionsInternal(visitId, assetId, draft.id)
@@ -340,7 +359,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return
val actId = selectedDraftAct()?.id val actId = selectedDraftAct()?.id
if (actId == null) { if (actId == null) {
error = "No hay un Acta en borrador seleccionada." error = "No hay un Acta en elaboración seleccionada."
return return
} }
if (description.isBlank()) { if (description.isBlank()) {
@@ -355,7 +374,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "La gravedad debe estar entre 1 y 10." error = "La gravedad debe estar entre 1 y 10."
return return
} }
launchBusy { launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(actId, assetId) selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create( val response = findingsRepository.create(
visitId, visitId,
@@ -386,7 +405,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
) { ) {
launchBusy { launchBusy(mutation = true) {
findingsRepository.uploadObservationPhoto( findingsRepository.uploadObservationPhoto(
findingId = findingId, findingId = findingId,
file = file, file = file,
@@ -418,7 +437,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Completá nombre, documento y cargo del responsable de la empresa." error = "Completá nombre, documento y cargo del responsable de la empresa."
return return
} }
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible( actClosure = actsRepository.setResponsible(
actId, actId,
MobileResponsibleRequest( MobileResponsibleRequest(
@@ -441,7 +460,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de ausencia de al menos 10 caracteres." error = "Indicá un motivo de ausencia de al menos 10 caracteres."
return return
} }
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible( actClosure = actsRepository.setResponsible(
actId, actId,
MobileResponsibleRequest( MobileResponsibleRequest(
@@ -449,26 +468,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
absenceReason = reason.trim(), absenceReason = reason.trim(),
), ),
) )
notice = "Ausencia del responsable registrada." notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta."
} }
} }
fun prepareSelectedAct() { fun prepareSelectedAct(urgency: String) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { if (urgency !in setOf("URGENT", "NON_URGENT")) {
actClosure = actsRepository.prepare(actId) error = "Definí la urgencia antes de cerrar el Acta."
return
}
launchBusy(mutation = true) {
actClosure = actsRepository.lock(actId, urgency)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
notice = "Acta preparada. Su contenido quedó congelado para las firmas." val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "Acta cerrada como $urgencyLabel y pendiente de firma. Su contenido quedó inmutable."
} }
} }
fun reopenSelectedAct() { fun reopenSelectedAct() {
val actId = selectedAct?.id ?: return error = "Un Acta pendiente de firma es inmutable y no puede volver a edición."
launchBusy {
actClosure = actsRepository.reopen(actId)
refreshSelectedActInternal(actId)
notice = "Acta reabierta. Podés corregirla antes de volver a preparar."
}
} }
fun signSelectedActAsInspector( fun signSelectedActAsInspector(
@@ -478,7 +497,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
accuracyM: Double?, accuracyM: Double?,
) { ) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM) actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
notice = "Firma del inspector incorporada al Acta." notice = "Firma del inspector incorporada al Acta."
} }
@@ -493,7 +512,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
statement: String?, statement: String?,
) { ) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.signCompany( actClosure = actsRepository.signCompany(
actId, png, latitude, longitude, accuracyM, manifestation, statement, actId, png, latitude, longitude, accuracyM, manifestation, statement,
) )
@@ -507,34 +526,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun recordCompanyOutcome(status: String, reason: String) { fun recordCompanyOutcome(status: String, reason: String) {
val actId = selectedAct?.id ?: return 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) { if (reason.trim().length < 10) {
error = "Indicá un motivo de al menos 10 caracteres." error = "Indicá un motivo de al menos 10 caracteres."
return return
} }
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.companyOutcome(actId, status, reason) actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = if (status == "ABSENT") "Ausencia de empresa asentada." else "Negativa a firmar asentada." notice = "Negativa a firmar asentada."
} }
} }
fun closeSelectedAct() { fun closeSelectedAct() {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.closeAct(actId) actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState() clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} cerrada e inmutable. Podés crear otra Acta o finalizar la inspección." notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
} }
} }
fun closeInspection() { fun closeInspection() {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy { launchBusy(mutation = true) {
visit = actsRepository.closeVisit(visitId) visit = actsRepository.closeVisit(visitId)
loadVisitsInternal() loadVisitsInternal()
notice = "Inspección cerrada. Las Actas y documentos quedan disponibles para oficina." notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
} }
} }
@@ -614,17 +637,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fieldFindingEvidence = emptyMap() fieldFindingEvidence = emptyMap()
} }
private fun launchBusy(block: suspend () -> Unit) { private fun launchBusy(mutation: Boolean = false, block: suspend () -> Unit) {
if (!operations.begin(mutation)) return
busy = operations.busy
viewModelScope.launch { viewModelScope.launch {
busy = true
error = null error = null
try { try {
block() block()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
error = DhRepository.humanError(throwable) error = DhRepository.humanError(throwable)
if (repository.currentSession() == null) session = null if (repository.currentSession() == null) session = null
} finally { } finally {
busy = false operations.end()
busy = operations.busy
} }
} }
} }
@@ -0,0 +1,16 @@
package com.korexlabs.dhinspeccion
/** Main-thread gate: reads may overlap; a second write is never queued by a double tap. */
internal class OperationTracker {
private var pending = 0
val busy: Boolean get() = pending > 0
fun begin(mutation: Boolean): Boolean {
if (mutation && busy) return false
pending += 1
return true
}
fun end() {
check(pending > 0)
pending -= 1
}
}
@@ -7,8 +7,6 @@ import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -90,7 +88,6 @@ data class PersonSummary(
data class VisitSummary( data class VisitSummary(
val id: String, val id: String,
val code: String, val code: String,
val title: String? = null,
val objective: String? = null, val objective: String? = null,
val status: String, val status: String,
val scopeAsset: AssetSummary? = null, val scopeAsset: AssetSummary? = null,
@@ -153,9 +150,9 @@ data class ChecklistSummary(
data class VisitDetail( data class VisitDetail(
val id: String, val id: String,
val code: String, val code: String,
val title: String? = null,
val objective: String? = null, val objective: String? = null,
val status: String, val status: String,
val scopeAsset: AssetSummary? = null,
val operationalArea: AssetSummary? = null, val operationalArea: AssetSummary? = null,
val operatorCompany: AssetSummary? = null, val operatorCompany: AssetSummary? = null,
val leadInspector: PersonSummary? = null, val leadInspector: PersonSummary? = null,
@@ -452,7 +449,6 @@ class SecureSessionStore(context: Context) {
class DhRepository(context: Context) { class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder() private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -531,13 +527,13 @@ class DhRepository(context: Context) {
visitId = visitId, visitId = visitId,
assetId = assetId, assetId = assetId,
file = part, file = part,
latitude = latitude.toString().toRequestBody(text), latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text), longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text), accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text), exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text), exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text), exifCapturedAt = capturedAt.toRequestBody(text),
) )
} }
@@ -549,23 +545,19 @@ class DhRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session.refreshToken) session = refresh(session)
return block(session) return block(session)
} }
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { private suspend fun refresh(previous: StoredSession): StoredSession =
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
companion object { companion object {
fun humanError(error: Throwable): String { fun humanError(error: Throwable): String {
if (error is java.io.IOException) {
return "No se pudo conectar. Revisá la señal. Si estabas guardando, comprobá el registro antes de reintentar."
}
if (error is HttpException) { if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull() val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull() val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
@@ -0,0 +1,14 @@
package com.korexlabs.dhinspeccion.data
import java.math.BigDecimal
import java.math.RoundingMode
internal object FieldCoordinates {
private fun normalized(value: Double, scale: Int, min: Double, max: Double): Double {
require(value.isFinite() && value in min..max) { "La ubicación GPS recibida no es válida." }
return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).toDouble()
}
fun latitude(value: Double) = normalized(value, 6, -90.0, 90.0)
fun longitude(value: Double) = normalized(value, 6, -180.0, 180.0)
fun accuracy(value: Double) = normalized(value, 3, 0.0, 100000.0)
}
@@ -4,8 +4,6 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -176,7 +174,6 @@ private interface FieldFindingsApi {
*/ */
class FieldFindingsRepository(context: Context) { class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder() private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -227,9 +224,9 @@ class FieldFindingsRepository(context: Context) {
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text), latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text), longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text), accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text),
) )
} }
@@ -241,18 +238,11 @@ class FieldFindingsRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session.refreshToken) session = refresh(session)
return block(session) return block(session)
} }
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { private suspend fun refresh(previous: StoredSession): StoredSession =
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
} }
@@ -4,8 +4,6 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -37,6 +35,15 @@ data class MobileActSummary(
val title: String, val title: String,
val summary: String, val summary: String,
val observations: String? = null, val observations: String? = null,
val urgency: String? = null,
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 currentVersion: Int = 0,
val closedAt: String? = null, val closedAt: String? = null,
val closureSha256: String? = null, val closureSha256: String? = null,
@@ -53,6 +60,15 @@ data class MobileActDetail(
val title: String, val title: String,
val summary: String, val summary: String,
val observations: String? = null, val observations: String? = null,
val urgency: String? = null,
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 currentVersion: Int = 0,
val closedAt: String? = null, val closedAt: String? = null,
val closureSha256: String? = null, val closureSha256: String? = null,
@@ -81,6 +97,10 @@ data class CreateMobileActRequest(
val assetIds: List<String>, val assetIds: List<String>,
) )
data class PrepareMobileActRequest(
val urgency: String,
)
data class UpdateMobileActRequest( data class UpdateMobileActRequest(
val assetIds: List<String>, val assetIds: List<String>,
) )
@@ -113,6 +133,15 @@ data class MobileActClosureHeader(
val code: String, val code: String,
val status: String, val status: String,
val visitId: String, val visitId: String,
val urgency: String? = null,
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 currentVersion: Int = 0,
val closedAt: String? = null, val closedAt: String? = null,
val closureSha256: String? = null, val closureSha256: String? = null,
@@ -169,7 +198,7 @@ data class MobileCompanyOutcomeRequest(
val reason: String, val reason: String,
) )
data class MobileCloseActRequest( data class MobileSealActRequest(
val clientClosedAt: String = Instant.now().toString(), val clientClosedAt: String = Instant.now().toString(),
val uploadMode: String = "ONLINE", val uploadMode: String = "ONLINE",
) )
@@ -179,7 +208,7 @@ data class MobileCloseVisitRequest(
) )
private interface MobileActsApi { private interface MobileActsApi {
@GET("inspection-visits/{visitId}/acts") @GET("inspection-visits/{visitId}/acts/mobile")
suspend fun listActs( suspend fun listActs(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("visitId") visitId: String, @Path("visitId") visitId: String,
@@ -219,16 +248,11 @@ private interface MobileActsApi {
@Body request: MobileResponsibleRequest, @Body request: MobileResponsibleRequest,
): MobileActClosure ): MobileActClosure
@POST("inspection-acts/{actId}/ready") @POST("inspection-acts/{actId}/lock")
suspend fun ready( suspend fun lock(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActClosure
@POST("inspection-acts/{actId}/reopen")
suspend fun reopen(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("actId") actId: String, @Path("actId") actId: String,
@Body request: PrepareMobileActRequest,
): MobileActClosure ): MobileActClosure
@Multipart @Multipart
@@ -268,11 +292,11 @@ private interface MobileActsApi {
@Body request: MobileCompanyOutcomeRequest, @Body request: MobileCompanyOutcomeRequest,
): MobileActClosure ): MobileActClosure
@POST("inspection-acts/{actId}/close") @POST("inspection-acts/{actId}/seal")
suspend fun closeAct( suspend fun sealAct(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("actId") actId: String, @Path("actId") actId: String,
@Body request: MobileCloseActRequest, @Body request: MobileSealActRequest,
): MobileActClosure ): MobileActClosure
@POST("inspection-visits/{visitId}/close") @POST("inspection-visits/{visitId}/close")
@@ -288,7 +312,6 @@ private interface MobileActsApi {
class MobileActsRepository(context: Context) { class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder() private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -305,15 +328,18 @@ class MobileActsRepository(context: Context) {
api.act("Bearer ${session.accessToken}", actId) api.act("Bearer ${session.accessToken}", actId)
} }
suspend fun create(visitId: String, assetId: String, visitCode: String): MobileActDetail = authorized { session -> suspend fun create(
visitId: String,
visitCode: String,
): MobileActDetail = authorized { session ->
api.createAct( api.createAct(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
visitId, visitId,
CreateMobileActRequest( CreateMobileActRequest(
occurredAt = Instant.now().toString(), occurredAt = Instant.now().toString(),
title = "Acta de inspección $visitCode", title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la visita.", summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = listOf(assetId), assetIds = emptyList(),
), ),
) )
} }
@@ -335,12 +361,8 @@ class MobileActsRepository(context: Context) {
api.responsible("Bearer ${session.accessToken}", actId, request) api.responsible("Bearer ${session.accessToken}", actId, request)
} }
suspend fun prepare(actId: String): MobileActClosure = authorized { session -> suspend fun lock(actId: String, urgency: String): MobileActClosure = authorized { session ->
api.ready("Bearer ${session.accessToken}", actId) api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency))
}
suspend fun reopen(actId: String): MobileActClosure = authorized { session ->
api.reopen("Bearer ${session.accessToken}", actId)
} }
suspend fun signInspector( suspend fun signInspector(
@@ -378,8 +400,8 @@ class MobileActsRepository(context: Context) {
) )
} }
suspend fun closeAct(actId: String): MobileActClosure = authorized { session -> suspend fun seal(actId: String): MobileActClosure = authorized { session ->
api.closeAct("Bearer ${session.accessToken}", actId, MobileCloseActRequest()) api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
} }
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session -> suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
@@ -405,9 +427,9 @@ class MobileActsRepository(context: Context) {
val consent = "true".toRequestBody(text) val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text) val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text) val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text) val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text) val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text) val accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text)
if (company) { if (company) {
api.signCompany( api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt, "Bearer ${session.accessToken}", actId, file, consent, signedAt,
@@ -430,18 +452,11 @@ class MobileActsRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session.refreshToken) session = refresh(session)
return block(session) return block(session)
} }
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { private suspend fun refresh(previous: StoredSession): StoredSession =
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
} }
@@ -0,0 +1,93 @@
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 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 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)
return block(session)
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
}
@@ -0,0 +1,32 @@
package com.korexlabs.dhinspeccion.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import retrofit2.HttpException
/** One refresh lock for every API client sharing the encrypted session. */
internal object MobileSessionCoordinator {
private val mutex = Mutex()
suspend fun refresh(
previous: StoredSession,
load: () -> StoredSession?,
save: (MobileSessionResponse) -> StoredSession,
clear: () -> Unit,
request: suspend (RefreshRequest) -> MobileSessionResponse,
): StoredSession = mutex.withLock {
val latest = load() ?: throw IllegalStateException("Sesión no iniciada")
check(latest.userId == previous.userId) { "La sesión cambió. Volvé a abrir la operación." }
if (latest.refreshToken != previous.refreshToken) return@withLock latest
val response = try {
request(RefreshRequest(previous.refreshToken))
} catch (error: HttpException) {
// Network failures and server outages must not discard the saved login.
if (error.code() == 401 && load()?.refreshToken == previous.refreshToken) clear()
throw error
}
// A logout or another login during the request must never be undone.
check(load()?.refreshToken == previous.refreshToken) { "La sesión cambió. Volvé a ingresar." }
save(response)
}
}
@@ -0,0 +1,64 @@
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 retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.*
import java.time.LocalDate
import java.time.OffsetDateTime
internal data class TechnicalDefinition(
val id: String, val code: String, val name: String, val dataType: String,
val isRequired: Boolean = false, val isActive: Boolean = true,
val unit: String? = null, val options: List<String>? = null,
)
internal data class TechnicalValues(
val assetId: String, val family: FieldInventoryFamily,
val definitions: List<TechnicalDefinition>, val values: Map<String, Any?>,
)
internal data class TechnicalValuesRequest(val values: Map<String, Any?>)
private interface TechnicalApi {
@GET("assets/{id}/technical-values")
suspend fun get(@Header("Authorization") auth: String, @Path("id") id: String): TechnicalValues
@PUT("assets/{id}/technical-values")
suspend fun put(@Header("Authorization") auth: String, @Path("id") id: String, @Body body: TechnicalValuesRequest): TechnicalValues
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
internal class TechnicalRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val api = Retrofit.Builder().baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()))
.build().create(TechnicalApi::class.java)
suspend fun get(id: String) = authorized { api.get("Bearer ${it.accessToken}", id) }
suspend fun save(id: String, values: Map<String, Any?>) = authorized {
api.put("Bearer ${it.accessToken}", id, TechnicalValuesRequest(values))
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
val previous = store.load() ?: error("Sesión no iniciada")
try { return block(previous) }
catch (error: HttpException) { if (error.code() != 401) throw error }
val current = MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
return block(current)
}
}
/** Keeps IDs and typed JSON values identical to the Dashboard contract. */
internal fun technicalInputValue(definition: TechnicalDefinition, text: String): Any? {
val value = text.trim()
require(value.isNotBlank() || !definition.isRequired) { "Completá ${definition.name}." }
if (value.isBlank()) return null
fun invalid(): Nothing = throw IllegalArgumentException("Revisá el valor de ${definition.name}.")
return when (definition.dataType) {
"NUMBER" -> value.replace(',', '.').toDoubleOrNull()?.takeIf { it.isFinite() } ?: invalid()
"BOOLEAN" -> when (value) { "true" -> true; "false" -> false; else -> invalid() }
"DATE" -> runCatching { LocalDate.parse(value).toString() }.getOrElse { invalid() }
"DATETIME" -> runCatching { OffsetDateTime.parse(value).toInstant().toString() }.getOrElse { invalid() }
"SELECT" -> value.takeIf { it in definition.options.orEmpty() } ?: invalid()
else -> value.takeIf { it.length <= 4000 } ?: invalid()
}
}
@@ -547,13 +547,17 @@ private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellab
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context) val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) try {
.addOnSuccessListener { location -> client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
if (!continuation.isActive) return@addOnSuccessListener .addOnSuccessListener { location ->
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) if (!continuation.isActive) return@addOnSuccessListener
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) 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) } }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } continuation.invokeOnCancellation { source.cancel() }
} }
@@ -0,0 +1,73 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
private val DhLightColors = lightColorScheme(
primary = Color(0xFF075E63),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFD6F0F0),
onPrimaryContainer = Color(0xFF073F43),
secondary = Color(0xFF445E74),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFDCE7F1),
onSecondaryContainer = Color(0xFF243F54),
tertiary = Color(0xFF6A5A42),
background = Color(0xFFF6F8FA),
onBackground = Color(0xFF182025),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF182025),
surfaceVariant = Color(0xFFEDF1F4),
onSurfaceVariant = Color(0xFF455158),
outline = Color(0xFF77848B),
outlineVariant = Color(0xFFD7DEE2),
error = Color(0xFFB3261E),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF6F1713),
)
private val DhDarkColors = darkColorScheme(
primary = Color(0xFF88D4D7),
onPrimary = Color(0xFF00373A),
primaryContainer = Color(0xFF074F53),
onPrimaryContainer = Color(0xFFB7EBED),
secondary = Color(0xFFB6CBE0),
onSecondary = Color(0xFF203646),
secondaryContainer = Color(0xFF344C60),
onSecondaryContainer = Color(0xFFDCE7F1),
background = Color(0xFF101416),
onBackground = Color(0xFFE0E4E6),
surface = Color(0xFF171C1F),
onSurface = Color(0xFFE0E4E6),
surfaceVariant = Color(0xFF263035),
onSurfaceVariant = Color(0xFFC3CDD2),
outline = Color(0xFF8C989E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
)
private val DhShapes = androidx.compose.material3.Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(22.dp),
extraLarge = RoundedCornerShape(28.dp),
)
@Composable
fun DhTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DhDarkColors else DhLightColors,
shapes = DhShapes,
content = content,
)
}
@@ -0,0 +1,972 @@
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.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 DynamicGeoSnapshot(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
private enum class DynamicInventoryMode {
BROWSE,
PICK_PARENT,
CREATE_INSTALLATION,
CREATE_SUBINSTALLATION,
}
@Composable
fun DynamicVisitRoot(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 -> DynamicFieldInventoryScreen(
model = model,
onBack = {
inventoryMode = false
actsMode = true
},
)
else -> DynamicVisitOverview(
model = model,
onInventory = { inventoryMode = true },
onActs = { actsMode = true },
)
}
}
@Composable
private fun DynamicVisitOverview(
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(dynamicStatusLabel(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: ${dynamicShortDate(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)
}
}
}
DynamicMessageStrip(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 se habilitan Actas, Hallazgos y altas rápidas de campo.")
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") {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Modo campo rápido", fontWeight = FontWeight.Bold)
Text(
"Desde Inventario podés cargar una Subinstalación en tres pasos: elegir Instalación, identificarla y capturar GPS + foto.",
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Nueva subinstalación / inventario")
}
}
}
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("Buscar Inventario / Hallazgos")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Text("Ver Actas · ${model.acts.size}")
}
}
DynamicChecklistCard(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 DynamicChecklistCard(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 DynamicFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val visit = model.visit ?: return
var modeName by rememberSaveable(visit.id) { mutableStateOf(DynamicInventoryMode.BROWSE.name) }
val mode = runCatching { DynamicInventoryMode.valueOf(modeName) }.getOrDefault(DynamicInventoryMode.BROWSE)
var search by rememberSaveable(visit.id) { mutableStateOf("") }
var parentSearch by rememberSaveable(visit.id) { mutableStateOf("") }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
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) }
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
val attributeValues = remember { mutableStateMapOf<String, String>() }
var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) }
var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var permissionAction by rememberSaveable(visit.id) { mutableStateOf("PHOTO") }
var mergeSearch by rememberSaveable(visit.id) { mutableStateOf("") }
var mergeCandidateId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var mergeReason by rememberSaveable(visit.id) { mutableStateOf("") }
fun resetForm() {
name = ""
commonName = ""
selectedTypeId = null
selectedFamilyId = null
familySearch = ""
attributeValues.clear()
localError = null
}
fun backToBrowse() {
modeName = DynamicInventoryMode.BROWSE.name
parentId = null
parentLabel = ""
parentSearch = ""
resetForm()
model.loadFieldTypes(null)
}
fun startInstallation() {
model.clearSelectedFieldAsset()
modeName = DynamicInventoryMode.CREATE_INSTALLATION.name
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetForm()
model.loadFieldTypes(null)
}
fun startSubinstallation() {
model.clearSelectedFieldAsset()
modeName = DynamicInventoryMode.PICK_PARENT.name
parentId = null
parentLabel = ""
parentSearch = ""
resetForm()
model.searchInventory("", visit.scopeAsset?.id)
}
fun chooseParent(item: FieldInventoryItem) {
parentId = item.id
parentLabel = "${item.name} (${item.code})"
modeName = DynamicInventoryMode.CREATE_SUBINSTALLATION.name
resetForm()
model.loadFieldTypes(item.id)
}
LaunchedEffect(visit.id) {
model.searchInventory("", visit.scopeAsset?.id)
}
LaunchedEffect(model.fieldTypes, modeName) {
if (mode == DynamicInventoryMode.CREATE_INSTALLATION || mode == DynamicInventoryMode.CREATE_SUBINSTALLATION) {
val preferred = model.fieldTypes.firstOrNull { type ->
val normalized = dynamicNormalize(type.structuralKind ?: type.code.ifBlank { type.name })
when (mode) {
DynamicInventoryMode.CREATE_INSTALLATION -> normalized.contains("instalacion") && !normalized.contains("subinstalacion")
DynamicInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
else -> false
}
} ?: model.fieldTypes.firstOrNull()
if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = preferred?.id
selectedFamilyId = null
familySearch = ""
attributeValues.clear()
}
}
}
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
val filteredFamilies = remember(selectedType, familySearch) {
val needle = dynamicNormalize(familySearch)
selectedType?.families.orEmpty().filter { family ->
needle.isBlank() || dynamicNormalize("${family.code} ${family.name}").contains(needle)
}
}
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<DynamicGeoSnapshot?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
val geo = pendingPhotoGeo
if (success && file != null && geo != null) {
runCatching { writeDynamicExif(file, geo) }
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
} else if (!success) {
localError = "La foto quedó pendiente. Podés tomarla cuando estés listo."
}
pendingPhotoFile = null
pendingPhotoGeo = null
}
val beginPhoto: () -> Unit = {
scope.launch {
runCatching { currentDynamicGeo(context) }
.onSuccess { geo ->
val (file, uri) = newDynamicPhoto(context)
pendingPhotoFile = file
pendingPhotoGeo = geo
takePicture.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo obtener el GPS para la fotografía." }
}
}
fun createWithLocation() {
val type = selectedType ?: return
val effectiveParent = when (mode) {
DynamicInventoryMode.CREATE_SUBINSTALLATION -> parentId
else -> null
}
scope.launch {
runCatching { currentDynamicGeo(context) }
.onSuccess { geo ->
pendingAutoPhoto = true
model.createFieldAsset(
type = type,
parentId = effectiveParent,
familyId = selectedFamilyId,
name = name,
commonName = commonName,
attributes = buildDynamicAttributes(type, attributeValues),
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
)
}
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
}
}
val permissionsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val cameraAllowed = result[Manifest.permission.CAMERA] == true || dynamicHasPermission(context, Manifest.permission.CAMERA)
val locationAllowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || dynamicHasLocation(context)
when (permissionAction) {
"CREATE" -> {
if (cameraAllowed && locationAllowed) createWithLocation()
else localError = "Para el alta rápida se necesitan cámara y ubicación."
}
else -> {
if (cameraAllowed && locationAllowed) beginPhoto()
else localError = "Para completar el alta se necesitan cámara y ubicación."
}
}
}
fun requestCreate() {
permissionAction = "CREATE"
if (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(context)) {
createWithLocation()
} else {
permissionsLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
fun requestPhoto() {
permissionAction = "PHOTO"
if (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(context)) {
beginPhoto()
} else {
permissionsLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
val selectedCapture = model.selectedFieldAsset
LaunchedEffect(selectedCapture?.asset?.id, pendingAutoPhoto) {
val capture = selectedCapture?.capture
if (pendingAutoPhoto && capture?.captureRequired == true && capture.creationGpsCaptured && capture.fieldPhotoCount == 0) {
pendingAutoPhoto = false
requestPhoto()
}
}
val mergeSource = selectedCapture?.takeIf {
it.capture.captureRequired && dynamicItemTypeCode(it.asset) in setOf("instalacion", "subinstalacion")
}
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 = { if (mode == DynamicInventoryMode.BROWSE) onBack() else backToBrowse() }) {
Text(if (mode == DynamicInventoryMode.BROWSE) "Acta" else "Atrás")
}
Column(horizontalAlignment = Alignment.End) {
Text(
when (mode) {
DynamicInventoryMode.PICK_PARENT -> "Elegir Instalación"
DynamicInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación"
DynamicInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación"
else -> "Inventario de campo"
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Text(visit.scopeAsset?.name ?: visit.code, style = MaterialTheme.typography.bodySmall)
}
}
Column(Modifier.padding(horizontal = 16.dp)) {
DynamicMessageStrip(model)
localError?.let {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
onClick = { localError = null },
) { Text(it, Modifier.padding(12.dp)) }
}
}
if (selectedCapture != null) {
DynamicCaptureCard(
item = selectedCapture.asset,
gps = selectedCapture.capture.creationGpsCaptured,
photos = selectedCapture.capture.fieldPhotoCount,
ready = selectedCapture.capture.readyForFinding,
onPhoto = { requestPhoto() },
onClose = { model.clearSelectedFieldAsset() },
onCreateAnother = {
if (dynamicItemTypeCode(selectedCapture.asset) == "subinstalacion" && selectedCapture.asset.parent != null) {
chooseParent(
FieldInventoryItem(
id = selectedCapture.asset.parent.id,
code = selectedCapture.asset.parent.code,
name = selectedCapture.asset.parent.name,
type = null,
),
)
} else {
startSubinstallation()
}
},
)
}
if (mergeSource != null && !mergeSource.capture.readyForFinding) {
Card(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("¿Ya existía?", fontWeight = FontWeight.Bold)
Text("Antes de seguir, podés fusionar el alta con un registro existente compatible.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = mergeSearch,
onValueChange = { mergeSearch = it },
label = { Text("Buscar posible duplicado") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedButton(
onClick = {
mergeCandidateId = null
model.searchInventory(mergeSearch, mergeSource.asset.parent?.id)
},
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar coincidencias") }
mergeCandidates.take(6).forEach { candidate ->
AssistChip(
onClick = { mergeCandidateId = candidate.id },
label = {
Text(if (mergeCandidateId == candidate.id) "${candidate.code} · ${candidate.name}" else "${candidate.code} · ${candidate.name}")
},
)
}
if (mergeCandidateId != null) {
OutlinedTextField(
value = mergeReason,
onValueChange = { mergeReason = it },
label = { Text("Motivo de la fusión") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
Button(
onClick = { model.mergeCreatedFieldAsset(mergeCandidateId!!, mergeReason) },
enabled = mergeReason.trim().length >= 8 && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Fusionar con el existente") }
}
}
}
}
when (mode) {
DynamicInventoryMode.BROWSE -> {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Alta rápida en campo", fontWeight = FontWeight.Bold)
Text("La Subinstalación se carga guiada, sin navegar por formularios largos.", style = MaterialTheme.typography.bodySmall)
Button(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") {
Text("+ Nueva Subinstalación")
}
OutlinedButton(onClick = { startInstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") {
Text("+ Nueva Instalación")
}
}
}
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar instalación, subinstalación o código") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = { model.searchInventory(search) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar") }
}
Text("Inventario disponible", 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 { dynamicItemTypeCode(it) in setOf("instalacion", "subinstalacion") },
key = { it.id },
) { item ->
DynamicInventoryCard(
item = item,
onInspect = { model.selectExisting(item) },
onAddChild = { chooseParent(item) },
)
}
item { Spacer(Modifier.height(30.dp)) }
}
}
DynamicInventoryMode.PICK_PARENT -> {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
DynamicStepHeader(1, "Elegí la Instalación padre")
Text("Solo se muestran Instalaciones del Yacimiento de esta inspección.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = parentSearch,
onValueChange = { parentSearch = it },
label = { Text("Buscar Instalación por nombre o código") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = { model.searchInventory(parentSearch, visit.scopeAsset?.id) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar Instalación") }
}
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
model.inventory.filter { dynamicItemTypeCode(it) == "instalacion" },
key = { it.id },
) { item ->
Card(onClick = { chooseParent(item) }, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall)
Text("Tocar para agregar una Subinstalación", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
}
DynamicInventoryMode.CREATE_INSTALLATION,
DynamicInventoryMode.CREATE_SUBINSTALLATION -> {
val isSubinstallation = mode == DynamicInventoryMode.CREATE_SUBINSTALLATION
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
DynamicStepHeader(if (isSubinstallation) 2 else 1, if (isSubinstallation) "Identificá la Subinstalación" else "Identificá la Instalación")
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold)
if (isSubinstallation) {
OutlinedButton(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth()) {
Text("Cambiar Instalación padre")
}
}
if (model.fieldTypes.isEmpty()) {
Text("No hay un tipo habilitado para esta ubicación.", color = MaterialTheme.colorScheme.error)
}
if (model.fieldTypes.size > 1) {
Text("Nivel de Inventario", fontWeight = FontWeight.Bold)
model.fieldTypes.forEach { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (selectedTypeId == type.id) "${type.name}" else type.name) },
)
}
}
if (selectedType?.familyRequired == true) {
Text("Clasificación técnica *", fontWeight = FontWeight.Bold)
OutlinedTextField(
value = familySearch,
onValueChange = { familySearch = it },
label = { Text("Buscar clasificación") },
supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
filteredFamilies.take(12).forEach { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
}
if (filteredFamilies.size > 12) {
Text("Escribí parte del nombre para reducir la lista.", style = MaterialTheme.typography.bodySmall)
}
selectedFamily?.let { family ->
if (family.isOther) {
Text("Quedará marcado como no catalogado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary)
}
if (family.informationLabels.isNotEmpty()) {
Text("Datos esperados: ${family.informationLabels.joinToString(" · ")}", style = MaterialTheme.typography.bodySmall)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre o código visible *") },
supportingText = { Text("Usá lo que ve el inspector en la placa o en campo.") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
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(),
)
}
HorizontalDivider()
DynamicStepHeader(if (isSubinstallation) 3 else 2, "GPS + foto en un solo paso")
Text(
"Al guardar se captura la ubicación actual y la cámara se abre automáticamente. La foto sigue siendo obligatoria antes de crear Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = { requestCreate() },
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Guardando…" else "Guardar y tomar foto") }
Spacer(Modifier.height(30.dp))
}
}
}
}
}
@Composable
private fun DynamicStepHeader(step: Int, title: String) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
Text(step.toString(), Modifier.padding(horizontal = 10.dp, vertical = 6.dp), fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(8.dp))
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun DynamicCaptureCard(
item: FieldInventoryItem,
gps: Boolean,
photos: Int,
ready: Boolean,
onPhoto: () -> Unit,
onClose: () -> Unit,
onCreateAnother: () -> Unit,
) {
Card(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
colors = CardDefaults.cardColors(
containerColor = if (ready) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(Modifier.weight(1f)) {
Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall)
}
OutlinedButton(onClick = onClose) { Text("Cerrar") }
}
Text("GPS: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) {
Text("Falta la fotografía obligatoria.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto ahora") }
} else {
Text("Alta completa · lista para Hallazgos", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
OutlinedButton(onClick = onCreateAnother, modifier = Modifier.fillMaxWidth()) {
Text("+ Crear otra Subinstalación")
}
}
}
}
}
@Composable
private fun DynamicInventoryCard(
item: FieldInventoryItem,
onInspect: () -> Unit,
onAddChild: () -> Unit,
) {
val typeCode = dynamicItemTypeCode(item)
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(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación", style = MaterialTheme.typography.bodySmall)
}
Text(item.code, style = MaterialTheme.typography.bodySmall)
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
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) "Abrir para Hallazgo" else "Usar para Hallazgo")
}
if (typeCode == "instalacion") {
OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Subinstalación")
}
}
}
}
}
@Composable
private fun DynamicMessageStrip(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 dynamicItemTypeCode(item: FieldInventoryItem): String {
val type = item.type ?: return ""
return dynamicNormalize(type.typeName ?: type.name)
}
private fun dynamicNormalize(value: String): String = value.trim().lowercase()
.replace('ó', 'o')
.replace('í', 'i')
.replace('á', 'a')
.replace('é', 'e')
.replace('ú', 'u')
.replace("_", "")
.replace("-", "")
.replace(" ", "")
private fun dynamicStatusLabel(status: String): String = when (status) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun buildDynamicAttributes(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 coerceDynamicAttribute(definition, raw)
}.toMap()
private fun coerceDynamicAttribute(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 dynamicHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun dynamicHasLocation(context: Context): Boolean =
dynamicHasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
dynamicHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentDynamicGeo(context: Context): DynamicGeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!dynamicHasLocation(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(DynamicGeoSnapshot(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 newDynamicPhoto(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_FAST_${System.currentTimeMillis()}_", ".jpg", directory)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
return file to uri
}
private fun writeDynamicExif(file: File, geo: DynamicGeoSnapshot) {
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 dynamicShortDate(value: String): String = value.replace('T', ' ').take(16)
@@ -86,7 +86,10 @@ fun F3VisitRoot(model: MainViewModel) {
inventoryMode = true inventoryMode = true
}, },
) )
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false }) inventoryMode -> F3FieldInventoryScreen(model, onBack = {
inventoryMode = false
actsMode = true
})
else -> F3VisitOverview( else -> F3VisitOverview(
model = model, model = model,
onInventory = { inventoryMode = true }, onInventory = { inventoryMode = true },
@@ -119,6 +122,9 @@ private fun F3VisitOverview(
} }
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}") Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.scopeAsset?.let {
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
visit.plannedStartAt?.let { visit.plannedStartAt?.let {
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall) Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
} }
@@ -138,10 +144,10 @@ private fun F3VisitOverview(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) { ) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("La inspección todavía no comenzó", fontWeight = FontWeight.Bold) Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.") Text("Al iniciar, pasás directamente a las Actas de esta inspección.")
Button( Button(
onClick = { model.startVisit() }, onClick = { model.startVisit(); onActs() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") } ) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
@@ -150,12 +156,12 @@ private fun F3VisitOverview(
} }
if (visit.status == "IN_PROGRESS") { if (visit.status == "IN_PROGRESS") {
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Text("Abrir Inventario de campo")
}
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" } val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}") Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Inventario / Hallazgos")
} }
} else if (visit.status == "CLOSED") { } else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
@@ -163,7 +169,7 @@ private fun F3VisitOverview(
} }
} else if (visit.status == "PLANNED") { } else if (visit.status == "PLANNED") {
Text( Text(
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.", "Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
@@ -209,7 +215,9 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
var search by rememberSaveable(visit.id) { mutableStateOf("") } var search by rememberSaveable(visit.id) { mutableStateOf("") }
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) } var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("Área de la inspección") } var parentLabel by rememberSaveable(visit.id) {
mutableStateOf(visit.scopeAsset?.name ?: "Yacimiento de la inspección")
}
var name by rememberSaveable(visit.id) { mutableStateOf("") } var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") } var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
@@ -323,10 +331,10 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedButton(onClick = onBack) { Text("Volver") } OutlinedButton(onClick = onBack) { Text("Acta") }
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Área → Yacimiento → Instalación Subinstalación", style = MaterialTheme.typography.bodySmall) Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall)
} }
} }
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) } Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
@@ -410,170 +418,182 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
} }
} }
Row( Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
Modifier.fillMaxWidth().padding(horizontal = 16.dp), Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
verticalAlignment = Alignment.CenterVertically, Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold)
) { OutlinedTextField(
OutlinedTextField( value = search,
value = search, onValueChange = { search = it },
onValueChange = { search = it }, label = { Text("Buscar instalación o subinstalación") },
label = { Text("Buscar por nombre o código") }, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.weight(1f), singleLine = true,
singleLine = true, )
) Row(
Spacer(Modifier.width(8.dp)) Modifier.fillMaxWidth(),
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") } horizontalArrangement = Arrangement.spacedBy(8.dp),
} ) {
Button(
Row( onClick = { model.searchInventory(search) },
Modifier.fillMaxWidth().padding(16.dp), enabled = !model.busy,
horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically, ) { Text("Buscar") }
) { OutlinedButton(
Column { onClick = {
Text("Alta en campo", fontWeight = FontWeight.Bold) showCreate = !showCreate
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall) if (showCreate) model.loadFieldTypes(parentId)
},
modifier = Modifier.weight(1f),
) { Text(if (showCreate) "Cancelar" else "+ Agregar") }
}
} }
OutlinedButton(onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
}) { Text(if (showCreate) "Ocultar" else "Agregar") }
} }
if (showCreate) { if (showCreate) {
Column( Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
Modifier Column(
.fillMaxWidth() Modifier
.padding(horizontal = 16.dp) .fillMaxWidth()
.verticalScroll(rememberScrollState()) .padding(12.dp)
.weight(1f, fill = false), .verticalScroll(rememberScrollState())
verticalArrangement = Arrangement.spacedBy(10.dp), .weight(1f, fill = false),
) { verticalArrangement = Arrangement.spacedBy(10.dp),
if (parentId != null) { ) {
OutlinedButton(onClick = { Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
parentId = null Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall)
parentLabel = "Área de la inspección" if (parentId != null) {
resetCreateForm() OutlinedButton(onClick = {
selectedTypeId = null parentId = null
model.loadFieldTypes(null) parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
}) { Text("Volver al Área") } resetCreateForm()
} selectedTypeId = null
model.loadFieldTypes(null)
}) { Text("Volver al Yacimiento") }
}
if (model.fieldTypes.isEmpty()) { if (model.fieldTypes.isEmpty()) {
Text("Este nivel no admite más hijos estructurales.") Text("No hay un tipo disponible para esta ubicación.")
} else { } else {
Text("Vas a crear", style = MaterialTheme.typography.bodySmall) Text("Tipo de registro", style = MaterialTheme.typography.bodySmall)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type -> items(model.fieldTypes, key = { it.id }) { type ->
AssistChip( AssistChip(
onClick = { onClick = {
selectedTypeId = type.id selectedTypeId = type.id
selectedFamilyId = null selectedFamilyId = null
attributeValues.clear() attributeValues.clear()
}, },
label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) }, label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) },
) )
}
} }
} }
}
if (selectedType?.familyRequired == true) { if (selectedType?.familyRequired == true) {
Text("Familia técnica", fontWeight = FontWeight.Bold) Text("Clasificación", fontWeight = FontWeight.Bold)
Text( Text(
"Elegí la que corresponda al Excel. Si no existe, usá Otro / no catalogado; nunca quedás bloqueado.", "Elegí el tipo técnico. Si no está catalogado, usá Otro / no catalogado.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(selectedType.families, key = { it.id }) { family -> items(selectedType.families, key = { it.id }) { family ->
AssistChip( AssistChip(
onClick = { selectedFamilyId = family.id }, onClick = { selectedFamilyId = family.id },
label = { label = {
val prefix = when { val prefix = when {
selectedFamilyId == family.id -> "" selectedFamilyId == family.id -> ""
family.isOther -> "+ " family.isOther -> "+ "
else -> "" else -> ""
} }
Text(prefix + family.name) Text(prefix + family.name)
}, },
) )
}
}
selectedFamily?.let { family ->
if (family.isOther) {
Text(
"Se registrará como no catalogado para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall,
)
}
if (family.informationLabels.isNotEmpty()) {
Text(
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
style = MaterialTheme.typography.bodySmall,
)
}
} }
} }
selectedFamily?.let { family ->
if (family.isOther) {
Text(
"Se registrará como familia no catalogada para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall,
)
}
if (family.informationLabels.isNotEmpty()) {
Text(
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre identificable *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
)
selectedType?.attributes?.forEach { definition ->
OutlinedTextField( OutlinedTextField(
value = attributeValues[definition.code].orEmpty(), value = name,
onValueChange = { attributeValues[definition.code] = it }, onValueChange = { name = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") }, label = { Text("Nombre o código identificable *") },
supportingText = { modifier = Modifier.fillMaxWidth(),
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ") )
if (details.isNotBlank()) Text(details) OutlinedTextField(
}, value = commonName,
keyboardOptions = KeyboardOptions( onValueChange = { commonName = it },
keyboardType = if ( label = { Text("Nombre habitual") },
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
}
val attributesReady = selectedType?.attributes selectedType?.attributes?.forEach { definition ->
?.filter { it.isRequired } OutlinedTextField(
?.all { attributeValues[it.code].orEmpty().isNotBlank() } value = attributeValues[definition.code].orEmpty(),
?: false onValueChange = { attributeValues[definition.code] = it },
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null label = { Text(definition.name + if (definition.isRequired) " *" else "") },
Button( supportingText = {
onClick = { val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (f3HasLocation(context)) createWithLocation() if (details.isNotBlank()) Text(details)
else locationPermissionLauncher.launch( },
arrayOf( keyboardOptions = KeyboardOptions(
Manifest.permission.ACCESS_FINE_LOCATION, keyboardType = if (
Manifest.permission.ACCESS_COARSE_LOCATION, definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
) KeyboardType.Decimal else KeyboardType.Text,
), ),
modifier = Modifier.fillMaxWidth(),
) )
}, }
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(), val attributesReady = selectedType?.attributes
) { Text("Capturar GPS y crear") } ?.filter { it.isRequired }
HorizontalDivider() ?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = {
if (f3HasLocation(context)) createWithLocation()
else locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
},
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar alta y capturar GPS") }
}
} }
} }
Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold) Text(
"Instalaciones y subinstalaciones",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontWeight = FontWeight.Bold,
)
LazyColumn( LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp), Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(model.inventory, key = { it.id }) { item -> items(
model.inventory.filter { candidate ->
candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion")
},
key = { it.id },
) { item ->
F3InventoryCard( F3InventoryCard(
item = item, item = item,
onInspect = { model.selectExisting(item) }, onInspect = { model.selectExisting(item) },
@@ -611,10 +631,10 @@ private fun F3CaptureCard(
if (captureRequired) { if (captureRequired) {
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos") Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) { if (!ready) {
Text("Antes de registrar Hallazgos, completá GPS + foto.", color = MaterialTheme.colorScheme.error) Text("Antes de registrar el Hallazgo, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") } Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
} else { } else {
Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary) Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary)
} }
} else { } else {
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall) Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
@@ -631,8 +651,8 @@ private fun F3InventoryCard(
) { ) {
val typeCode = item.type?.let(::f3TypeCode).orEmpty() val typeCode = item.type?.let(::f3TypeCode).orEmpty()
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion") val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
val canHaveChild = typeCode in setOf("yacimiento", "instalacion") val canHaveChild = typeCode == "instalacion"
val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí" val childLabel = "+ Agregar subinstalación"
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
@@ -646,12 +666,12 @@ private fun F3InventoryCard(
} }
if (canHaveFinding) { if (canHaveFinding) {
Text( Text(
if (item.readyForFinding) "Disponible para Hallazgos" else "GPS/foto pendiente", if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) { Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección") Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo")
} }
} }
if (canHaveChild) { if (canHaveChild) {
@@ -727,16 +747,20 @@ private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCance
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context) val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) try {
.addOnSuccessListener { location -> client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
if (!continuation.isActive) return@addOnSuccessListener .addOnSuccessListener { location ->
if (location == null) { if (!continuation.isActive) return@addOnSuccessListener
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) if (location == null) {
} else { continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) } else {
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
} }
} .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } } catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } continuation.invokeOnCancellation { source.cancel() }
} }
@@ -75,6 +75,7 @@ fun FieldFindingScreen(model: MainViewModel) {
var severityText by rememberSaveable(asset.id) { mutableStateOf("") } var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") } var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
var localError by rememberSaveable { mutableStateOf<String?>(null) }
var requestedFindingId by remember { mutableStateOf<String?>(null) } var requestedFindingId by remember { mutableStateOf<String?>(null) }
var pendingPhotoFile by remember { mutableStateOf<File?>(null) } var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) } var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
@@ -110,6 +111,7 @@ fun FieldFindingScreen(model: MainViewModel) {
pendingPhotoFindingId = findingId pendingPhotoFindingId = findingId
takePicture.launch(uri) takePicture.launch(uri)
} }
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
} }
} }
@@ -122,6 +124,7 @@ fun FieldFindingScreen(model: MainViewModel) {
val findingId = requestedFindingId val findingId = requestedFindingId
requestedFindingId = null requestedFindingId = null
if (camera && location && findingId != null) beginPhoto(findingId) if (camera && location && findingId != null) beginPhoto(findingId)
else localError = "Permití cámara y ubicación para adjuntar una foto con GPS."
} }
fun requestPhoto(findingId: String) { fun requestPhoto(findingId: String) {
@@ -185,13 +188,14 @@ fun FieldFindingScreen(model: MainViewModel) {
Text(asset.name, fontWeight = FontWeight.Bold) Text(asset.name, fontWeight = FontWeight.Bold)
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall) Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text( Text(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}", "GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
} }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.error?.let { model.error?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(it, Modifier.padding(12.dp)) Text(it, Modifier.padding(12.dp))
@@ -210,7 +214,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold) Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall) Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall)
Text( Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}", "Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -389,13 +393,17 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context) val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) try {
.addOnSuccessListener { location -> client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
if (!continuation.isActive) return@addOnSuccessListener .addOnSuccessListener { location ->
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) if (!continuation.isActive) return@addOnSuccessListener
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) 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) } }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } continuation.invokeOnCancellation { source.cancel() }
} }
@@ -3,12 +3,18 @@ package com.korexlabs.dhinspeccion.ui
import android.content.Context import android.content.Context
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
@@ -29,7 +35,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
@@ -37,6 +45,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.korexlabs.dhinspeccion.MainViewModel import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.R
private const val BIOMETRIC_PREFS = "dh_v2_biometric" private const val BIOMETRIC_PREFS = "dh_v2_biometric"
private const val BIOMETRIC_ENABLED = "enabled" private const val BIOMETRIC_ENABLED = "enabled"
@@ -74,8 +83,8 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
if (model.session == null) unlocked = false if (model.session == null) unlocked = false
} }
MaterialTheme { DhTheme {
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize().safeDrawingPadding().imePadding()) {
when { when {
model.session == null -> EnhancedLoginScreen(model) { model.session == null -> EnhancedLoginScreen(model) {
passwordLoginInFlight = true passwordLoginInFlight = true
@@ -91,8 +100,8 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
}, },
) )
model.fieldFindingOptions != null -> FieldFindingScreen(model) model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> F3VisitRoot(model) model.visit != null -> ModernVisitRoot(model)
else -> DhApp(model) else -> MobileHomeScreen(model)
} }
} }
} }
@@ -101,11 +110,17 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
@Composable @Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) { private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") } var identifier by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") } var password by remember { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) } var passwordVisible by rememberSaveable { mutableStateOf(false) }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(170.dp),
contentScale = ContentScale.Fit,
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos") Text("Aplicación de campo · Dirección de Hidrocarburos")
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -113,7 +128,7 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
OutlinedTextField( OutlinedTextField(
value = identifier, value = identifier,
onValueChange = { identifier = it }, onValueChange = { identifier = it },
label = { Text("Usuario o email") }, label = { Text("Usuario o correo electrónico") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
) )
@@ -192,12 +207,18 @@ private fun BiometricUnlockScreen(
LaunchedEffect(Unit) { authenticate() } LaunchedEffect(Unit) { authenticate() }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(140.dp),
contentScale = ContentScale.Fit,
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella") Text("Ingresá con tu huella")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -60,6 +60,7 @@ fun MobileActsScreen(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var attendance by rememberSaveable(selected?.id) { var attendance by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
} }
@@ -125,23 +126,29 @@ fun MobileActsScreen(
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
} }
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}") Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
visit.scopeAsset?.let { Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodySmall) }
F32ActMessage(model) F32ActMessage(model)
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Actas de esta inspección", fontWeight = FontWeight.Bold) Text("Actas de esta inspección", fontWeight = FontWeight.Bold)
if (model.acts.isEmpty()) { if (model.acts.isEmpty()) {
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.") Text("Todavía no hay Actas. Creá la primera y después agregá los Hallazgos.")
} }
model.acts.forEach { act -> model.acts.forEach { act ->
val active = selected?.id == act.id val active = selected?.id == act.id
val urgency = when (act.urgency) {
"URGENT" -> "URGENTE"
"NON_URGENT" -> "No urgente"
else -> "Urgencia pendiente"
}
OutlinedButton( OutlinedButton(
onClick = { model.selectAct(act.id) }, onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
(if (active) "" else "") + (if (active) "" else "") +
"${act.code} · ${actStatusLabel(act.status)} · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}", "${act.code} · ${actStatusLabel(act.status)} · $urgency · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}",
) )
} }
} }
@@ -154,28 +161,27 @@ fun MobileActsScreen(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) { ) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Nueva Acta", fontWeight = FontWeight.Bold) Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "READY" }) { if (model.acts.any { it.status == "LOCKED" }) {
Text( Text(
"Puede existir una nueva Acta en borrador aunque haya Actas preparadas pendientes de firma de empresa. Sólo se permite un borrador a la vez.", "Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
val selectedInventory = model.selectedFieldAsset?.asset Text(
if (selectedInventory == null) { "La urgencia se define al cerrar el Acta, después de completar los Hallazgos.",
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.") style = MaterialTheme.typography.bodySmall,
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { )
Text("Ir a Inventario y elegir") Text(
} "Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.",
} else { style = MaterialTheme.typography.bodySmall,
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}") )
Button( Button(
onClick = { model.createActForSelectedInventory() }, onClick = { model.createAct() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") } ) { Text("Crear nueva Acta") }
}
} }
} }
} }
@@ -186,15 +192,40 @@ fun MobileActsScreen(
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status)) Text(actStatusLabel(selected.status))
Text(
when (selected.urgency) {
"URGENT" -> "Urgencia: Urgente"
"NON_URGENT" -> "Urgencia: No urgente"
else -> "Urgencia: se define al cerrar el Acta"
},
)
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario") Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
Text(selected.summary, style = MaterialTheme.typography.bodySmall) selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
selected.closureSha256?.let { Text("Hash final: $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) { when (selected.status) {
"DRAFT" -> { "DRAFT" -> {
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Buscá una Instalación o Subinstalación existente. Si no está, podés agregarla en campo en el mismo flujo.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Hallazgo")
}
}
}
HorizontalDivider()
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") }) AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") }) AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
@@ -233,32 +264,34 @@ fun MobileActsScreen(
} }
HorizontalDivider() HorizontalDivider()
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita. Un Acta de verificación puede prepararse sin Hallazgos nuevos si la verificación ya fue registrada.") Text("Al cerrar el Acta, el contenido y los Hallazgos quedan inmutables. Definí ahora la urgencia según lo constatado.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
HorizontalDivider() onClick = { closingUrgency = "NON_URGENT" },
Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) label = { Text(if (closingUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
Text("Al preparar, el contenido se congela y se calcula su hash. El servidor exige al menos un Hallazgo o una verificación registrada.") )
AssistChip(
onClick = { closingUrgency = "URGENT" },
label = { Text(if (closingUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Button( Button(
onClick = { model.prepareSelectedAct() }, onClick = { model.prepareSelectedAct(closingUrgency) },
enabled = !model.busy && closure?.responsible != null, enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Preparar Acta para firmas") } ) { Text("Finalizar y BLOQUEAR Acta") }
} }
"READY" -> { "LOCKED" -> {
val signatures = closure?.signatures.orEmpty() val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" } val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" } val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) } Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
if (signatures.isEmpty()) { closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) {
Text("Volver a borrador")
}
}
HorizontalDivider() HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold) Text("Firma del inspector", fontWeight = FontWeight.Bold)
@@ -274,29 +307,21 @@ fun MobileActsScreen(
} }
HorizontalDivider() HorizontalDivider()
Text("Recepción de la empresa", fontWeight = FontWeight.Bold) Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null) { if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) { val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada" "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar" "REFUSED" -> "Negativa a firmar"
"ABSENT" -> "Responsable ausente"
else -> companyOutcome.status else -> companyOutcome.status
} }
Text("$detail", color = MaterialTheme.colorScheme.primary) Text("$detail", color = MaterialTheme.colorScheme.primary)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") { } else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Text("El responsable fue registrado como ausente.") Text(
Button( "El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
onClick = { style = MaterialTheme.typography.bodyMedium,
model.recordCompanyOutcome( )
"ABSENT",
closure.responsible.absenceReason ?: "Responsable de empresa ausente durante la inspección",
)
},
enabled = !model.busy && inspectorSigned,
modifier = Modifier.fillMaxWidth(),
) { Text("Asentar ausencia en el Acta") }
} else { } else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -333,28 +358,28 @@ fun MobileActsScreen(
} }
HorizontalDivider() HorizontalDivider()
if (inspectorSigned && companyOutcome != null) { if (inspectorSigned && companyResolved) {
Button( Button(
onClick = { model.closeSelectedAct() }, onClick = { model.closeSelectedAct() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta definitivamente") } ) { Text("SELLAR Acta definitivamente") }
} else if (inspectorSigned) { } else {
Text( Text(
"La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.", "Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
} }
"CLOSED" -> { "SEALED" -> {
Card( Card(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) { ) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold) Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)
Text("El PDF, informe Word y entregas documentales se generan desde este cierre.") 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) } closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) }
} }
} }
@@ -366,16 +391,19 @@ fun MobileActsScreen(
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) { if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
HorizontalDivider() HorizontalDivider()
val drafts = model.acts.count { it.status == "DRAFT" } val activeActs = model.acts.filter { it.status != "CANCELLED" }
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) val pendingActs = activeActs.filter { it.status != "SEALED" }
Text( Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
"No puede quedar ninguna Acta en borrador. Al cerrar, el servidor verifica que cada Acta preparada tenga firma de inspector; si falta alguna, indicará cuál debe completarse.", 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( Button(
onClick = { model.closeInspection() }, onClick = { model.closeInspection() },
enabled = !model.busy && drafts == 0, enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar inspección y salir de la empresa") } ) { Text("Cerrar Inspección y salir de la empresa") }
} }
} }
} }
@@ -397,8 +425,8 @@ private fun F32ActMessage(model: MainViewModel) {
private fun actStatusLabel(status: String): String = when (status) { private fun actStatusLabel(status: String): String = when (status) {
"DRAFT" -> "Borrador" "DRAFT" -> "Borrador"
"READY" -> "Preparada para firmas" "LOCKED" -> "BLOQUEADA"
"CLOSED" -> "Cerrada" "SEALED" -> "SELLADA"
"CANCELLED" -> "Cancelada" "CANCELLED" -> "Cancelada"
else -> status else -> status
} }
@@ -413,13 +441,17 @@ private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo =
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
LocationServices.getFusedLocationProviderClient(context) try {
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) LocationServices.getFusedLocationProviderClient(context)
.addOnSuccessListener { location -> .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
if (!continuation.isActive) return@addOnSuccessListener .addOnSuccessListener { location ->
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible")) if (!continuation.isActive) return@addOnSuccessListener
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble())) 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) } }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } 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(visitStatusLabelEs(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,565 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Assignment
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
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.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.style.TextOverflow
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 ModernActSignatureGeo(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun ModernMobileActsScreen(
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 closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
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 { currentModernActSignatureGeo(context) }.getOrNull()
if (company) {
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
} else {
model.signSelectedActAsInspector(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
)
}
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ModernHeader(title = "Actas", subtitle = visit.code, onBack = onBack)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
tonalElevation = 1.dp,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(
visit.scopeAsset?.name ?: "Yacimiento",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
ModernMessage(model)
if (model.acts.isEmpty()) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(
Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Text("Todavía no hay Actas", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Creá la primera Acta y después incorporá Hallazgos seleccionando el Inventario.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
Text("Actas de esta inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
model.acts.forEach { act ->
val active = selected?.id == act.id
Surface(
onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
tonalElevation = if (active) 1.dp else 0.dp,
border = BorderStroke(
if (active) 2.dp else 1.dp,
if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
),
) {
Row(
Modifier.padding(15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
when (act.status) {
"LOCKED" -> Icons.Filled.Lock
"SEALED" -> Icons.Filled.CheckCircle
else -> Icons.Filled.EditNote
},
contentDescription = null,
tint = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(act.code, fontWeight = FontWeight.Bold)
Text(
"${modernActStatusLabel(act.status)} · ${when (act.urgency) { "URGENT" -> "Urgente"; "NON_URGENT" -> "No urgente"; else -> "Urgencia pendiente" }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"} · ${act.assetCount} elemento${if (act.assetCount == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
)
}
if (active) Icon(Icons.Filled.CheckCircle, "Seleccionada", tint = MaterialTheme.colorScheme.primary)
}
}
}
}
val hasDraftAct = model.acts.any { it.status == "DRAFT" }
if (visit.status == "IN_PROGRESS" && !hasDraftAct) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
Icon(Icons.Filled.Add, null, Modifier.padding(10.dp), tint = MaterialTheme.colorScheme.primary)
}
Spacer(Modifier.width(10.dp))
Column {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Un solo borrador activo por vez", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Text(
"La urgencia se define recién al cerrar el Acta, cuando ya se conoce el resultado de la inspección.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.createAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(7.dp))
Text(if (model.busy) "Creando…" else "Crear nueva Acta")
}
}
}
}
if (selected != null) {
HorizontalDivider()
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
StatusPill(modernActStatusLabel(selected.status))
}
Text(
when (selected.urgency) {
"URGENT" -> "Urgencia · Urgente"
"NON_URGENT" -> "Urgencia · No urgente"
else -> "Urgencia · Se define al cerrar el Acta"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO", style = MaterialTheme.typography.bodySmall)
}
}
}
when (selected.status) {
"DRAFT" -> {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp))
Text("Agregar Hallazgo")
}
}
}
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") })
FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") })
}
if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) })
}
}
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
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") }
}
}
}
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.width(8.dp))
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia según lo constatado en campo.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = closingUrgency == "NON_URGENT",
onClick = { closingUrgency = "NON_URGENT" },
label = { Text("No urgente") },
)
FilterChip(
selected = closingUrgency == "URGENT",
onClick = { closingUrgency = "URGENT" },
label = { Text("Urgente") },
)
}
Text(
when (closingUrgency) {
"URGENT" -> "El plazo urgente se computará desde el Acta según la política vigente."
"NON_URGENT" -> "El plazo no urgente comenzará con la oficialización GEDO."
else -> "Elegí una opción para habilitar el cierre."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.prepareSelectedAct(closingUrgency) },
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta y dejar pendiente de firma") }
}
}
}
"LOCKED" -> {
val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(8.dp))
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.")
closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) }
HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold)
if (inspectorSigned) {
SuccessLine("Firma del inspector registrada")
} else {
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
SignaturePad(
label = "Firmá como inspector/a",
enabled = !model.busy,
onCaptured = { file -> signWithGeo(file, company = false) },
)
}
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 -> humanBackendLabelEs(companyOutcome.status)
}
SuccessLine(detail)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
Text(
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
}
}
} else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En 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 se niega a firmar, registrá el motivo.", 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") }
}
if (inspectorSigned && companyResolved) {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text("Firmar y cerrar Acta")
}
} else {
Text(
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
"SEALED" -> {
ElevatedCard(Modifier.fillMaxWidth()) {
Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Acta firmada y cerrada", fontWeight = FontWeight.Bold)
Text("Desde este cierre se genera el PDF del Acta y el informe INF editable (DOCX) para el Inspector.")
closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) }
}
}
}
}
"CANCELLED" -> {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
Text("Esta Acta fue cancelada y permanece como antecedente.", Modifier.padding(14.dp))
}
}
}
}
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
val activeActs = model.acts.filter { it.status != "CANCELLED" }
val pendingActs = activeActs.filter { it.status != "SEALED" }
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (pendingActs.isEmpty()) {
SuccessLine("Todas las Actas están firmadas y cerradas")
} else {
Text(
"Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = { model.closeInspection() },
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Inspección y salir") }
}
}
}
Spacer(Modifier.height(32.dp))
}
}
@Composable
private fun SuccessLine(text: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Text(text, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
}
}
private fun modernActStatusLabel(status: String): String = actStatusLabelEs(status)
private fun hasModernActLocation(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 currentModernActSignatureGeo(context: Context): ModernActSignatureGeo = suspendCancellableCoroutine { continuation ->
if (!hasModernActLocation(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(ModernActSignatureGeo(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() }
}
File diff suppressed because it is too large Load Diff
@@ -49,7 +49,7 @@ fun SignaturePad(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold) Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
Text( Text(
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.", "Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora a la huella de integridad del Acta.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Canvas( Canvas(
@@ -0,0 +1,99 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.data.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@Composable
internal fun TechnicalFieldsButton(assetId: String, enabled: Boolean) {
val context = LocalContext.current
val repository = remember { TechnicalRepository(context) }
val scope = rememberCoroutineScope()
var open by rememberSaveable(assetId) { mutableStateOf(false) }
var busy by remember(assetId) { mutableStateOf(false) }
var snapshot by remember(assetId) { mutableStateOf<TechnicalValues?>(null) }
var error by remember(assetId) { mutableStateOf<String?>(null) }
var saved by remember(assetId) { mutableStateOf(false) }
val values = remember(assetId) { mutableStateMapOf<String, String>() }
OutlinedButton(onClick = { open = true }, enabled = enabled,
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp)) {
Text(if (saved) "Datos técnicos guardados · Revisar" else "Completar datos técnicos")
}
if (!open) return
LaunchedEffect(assetId, open) {
busy = true; error = null; snapshot = null
try {
val loaded = repository.get(assetId)
snapshot = loaded
values.clear()
loaded.values.forEach { (id, value) -> values[id] = value?.toString().orEmpty() }
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
AlertDialog(
onDismissRequest = { if (!busy) open = false },
title = { Text(snapshot?.family?.name ?: "Datos técnicos") },
text = {
Column(Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Son los mismos campos de la clasificación administrada en oficina. Los marcados con * son obligatorios.")
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
snapshot?.definitions?.filter { it.isActive }?.forEach { definition ->
val label = definition.name + (if (definition.isRequired) " *" else "") + (definition.unit?.let { " ($it)" } ?: "")
val current = values[definition.id].orEmpty()
if (definition.dataType in setOf("BOOLEAN", "SELECT")) {
Text(label)
val options = if (definition.dataType == "BOOLEAN") listOf("true", "false") else definition.options.orEmpty()
options.forEach { option ->
FilterChip(selected = current == option, enabled = !busy,
onClick = { values[definition.id] = option },
label = { Text(if (definition.dataType == "BOOLEAN") if (option == "true") "" else "No" else option) })
}
if (!definition.isRequired) TextButton(enabled = !busy, onClick = { values[definition.id] = "" }) { Text("Sin dato") }
} else {
OutlinedTextField(value = current, onValueChange = { values[definition.id] = it },
label = { Text(label) }, enabled = !busy, modifier = Modifier.fillMaxWidth(),
supportingText = {
when (definition.dataType) {
"DATE" -> Text("AAAA-MM-DD")
"DATETIME" -> Text("AAAA-MM-DDTHH:MM:SS-03:00")
else -> Unit
}
})
}
}
if (snapshot?.definitions?.none { it.isActive } == true) Text("Esta clasificación no tiene campos técnicos activos.")
}
},
confirmButton = {
TextButton(enabled = !busy && snapshot != null, onClick = {
val current = snapshot ?: return@TextButton
busy = true; error = null
scope.launch {
try {
val payload = current.definitions.filter { it.isActive }.mapNotNull { definition ->
technicalInputValue(definition, values[definition.id].orEmpty())?.let { definition.id to it }
}.toMap()
snapshot = repository.save(assetId, payload)
saved = true; open = false
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
}) { Text("Guardar datos") }
},
dismissButton = { TextButton(enabled = !busy, onClick = { open = false }) { Text("Cerrar") } },
)
}
@@ -0,0 +1,50 @@
package com.korexlabs.dhinspeccion.ui
import java.util.Locale
internal fun visitStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"DRAFT" -> "En elaboración"
"LOCKED" -> "Pendiente de firma"
"SEALED" -> "Firmada y cerrada"
"CLOSED" -> "Cerrada"
"RECTIFIED" -> "Rectificada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun findingStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"OPEN" -> "Abierto"
"PENDING" -> "Pendiente"
"COMPANY_RESPONSE_PENDING" -> "Pendiente de respuesta de la empresa"
"COMPANY_RESPONDED" -> "Respondido por la empresa"
"VERIFICATION_PENDING" -> "Pendiente de verificación"
"VERIFICATION_SCHEDULED" -> "Verificación programada"
"READY_TO_CLOSE" -> "Listo para cierre"
"CLOSED" -> "Cerrado"
"CANCELLED" -> "Cancelado"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun documentTypeLabelEs(type: String): String = when (type.uppercase(Locale.ROOT)) {
"DNI" -> "DNI"
"CUIL" -> "CUIL"
"PASSPORT" -> "Pasaporte"
"OTHER" -> "Otro"
else -> humanBackendLabelEs(type)
}
internal fun humanBackendLabelEs(value: String): String {
val clean = value.trim().replace('_', ' ').replace('-', ' ').lowercase(Locale("es", "AR"))
return clean.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale("es", "AR")) else it.toString() }
}
@@ -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,26 @@
package com.korexlabs.dhinspeccion
import java.io.File
import org.junit.Assert.assertTrue
import org.junit.Test
class DynamicFieldFlowContractTest {
private val source = File("src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt").readText()
@Test
fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() {
assertTrue(source.contains("Elegí la Instalación padre"))
assertTrue(source.contains("Clasificación técnica *"))
assertTrue(source.contains("Guardar y tomar foto"))
assertTrue(source.contains("model.loadFieldTypes(item.id)"))
assertTrue(source.contains("parentId = item.id"))
}
@Test
fun fieldCaptureStillRequiresGpsAndPhotoBeforeFindings() {
assertTrue(source.contains("La foto sigue siendo obligatoria antes de crear Hallazgos"))
assertTrue(source.contains("creationGpsCaptured"))
assertTrue(source.contains("fieldPhotoCount == 0"))
assertTrue(source.contains("model.uploadFieldPhoto"))
}
}
@@ -0,0 +1,24 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import org.junit.Assert.*
import org.junit.Test
class FieldCoordinatesTest {
@Test fun realGpsIsRoundedToApiPrecision() {
assertEquals(-32.889459, FieldCoordinates.latitude(-32.889458762), 0.0)
assertEquals(-68.845839, FieldCoordinates.longitude(-68.845838912), 0.0)
assertEquals(4.123, FieldCoordinates.accuracy(4.123456789), 0.0)
}
@Test fun boundsAndZeroRemainValid() {
assertEquals(90.0, FieldCoordinates.latitude(90.0), 0.0)
assertEquals(-180.0, FieldCoordinates.longitude(-180.0), 0.0)
assertEquals(0.0, FieldCoordinates.accuracy(0.0), 0.0)
}
@Test fun invalidCoordinatesAreRejected() {
for (value in listOf(Double.NaN, Double.POSITIVE_INFINITY, 90.1, -90.1)) {
try { FieldCoordinates.latitude(value); fail("Invalid latitude accepted") }
catch (_: IllegalArgumentException) { }
}
try { FieldCoordinates.accuracy(-1.0); fail("Negative accuracy accepted") }
catch (_: IllegalArgumentException) { }
}
}
@@ -0,0 +1,57 @@
package com.korexlabs.dhinspeccion
import java.io.File
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FinalFieldFlowContractTest {
private val visit = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt").readText()
private val acts = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt").readText()
private val findings = File("src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt").readText()
private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
private val labels = File("src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt").readText()
@Test
fun inspectionActFindingFlowMatchesFieldProcedure() {
assertTrue(visit.contains("Iniciar inspección"))
assertTrue(acts.contains("Crear nueva Acta"))
assertTrue(acts.contains("Agregar Hallazgo"))
assertTrue(visit.contains("Instalación"))
assertTrue(visit.contains("Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto"))
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\""))
}
@Test
fun fieldCreateKeepsDashboardEquivalentMinimumAndCaptureBarrier() {
assertTrue(visit.contains("Nombre técnico *"))
assertTrue(visit.contains("Nombre habitual (opcional)"))
assertTrue(visit.contains("Descripción (opcional)"))
assertTrue(visit.contains("familyReady"))
assertTrue(visit.contains("attributesReady"))
assertTrue(visit.contains("GPS + foto"))
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
}
@Test
fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"yacimiento\", \"instalacion\", \"subinstalacion\")"))
}
@Test
fun currentFieldUiDoesNotExposeBackendEnglishStates() {
assertTrue(home.contains("visitStatusLabelEs(visit.status)"))
assertTrue(findings.contains("findingStatusLabelEs(finding.status)"))
assertTrue(acts.contains("documentTypeLabelEs(kind)"))
assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\""))
assertTrue(labels.contains("\"OTHER\" -> \"Otro\""))
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
assertFalse(acts.contains("Acta BLOQUEADA"))
assertFalse(acts.contains("SELLAR Acta"))
assertFalse(acts.contains("Acta SELLADA"))
assertFalse(acts.contains("Text(\"Email\")"))
}
}
@@ -0,0 +1,105 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import java.io.IOException
import kotlinx.coroutines.*
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
class MobileSessionCoordinatorTest {
private fun old() = StoredSession("u1", "inspector", "Inspector", "a1", "r1")
private fun response() = MobileSessionResponse(MobileUser("u1", "inspector"), "a2", "r2", 900)
private fun http(code: Int) = HttpException(Response.error<Unit>(code, "error".toResponseBody()))
private class Store(var value: StoredSession?) {
var clears = 0
fun load() = value
fun clear() { clears++; value = null }
fun save(r: MobileSessionResponse): StoredSession =
StoredSession(r.user.id, r.user.username, r.user.username, r.accessToken, r.refreshToken).also { value = it }
}
@Test fun concurrentClientsRotateTheTokenOnlyOnce() = runBlocking {
val previous = old(); val store = Store(previous); var calls = 0
val results = coroutineScope {
(1..4).map {
async {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
calls++; delay(20); response()
}
}
}.awaitAll()
}
assertEquals(1, calls)
assertTrue(results.all { it.refreshToken == "r2" })
assertEquals(0, store.clears)
}
@Test fun networkAndServerFailuresKeepSessionForRetry() = runBlocking {
for (failure in listOf(IOException("offline"), http(500), http(429))) {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw failure }
fail("Expected request failure")
} catch (caught: Exception) { assertSame(failure, caught) }
assertEquals(previous, store.value)
assertEquals(0, store.clears)
}
}
@Test fun invalidRefreshClearsOnlyTheExpiredSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw http(401) }
fail("Expected unauthorized")
} catch (caught: HttpException) { assertEquals(401, caught.code()) }
assertNull(store.value)
assertEquals(1, store.clears)
}
@Test fun cancelledRefreshKeepsSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw CancellationException("cancel") }
fail("Expected cancellation")
} catch (_: CancellationException) { }
assertEquals(previous, store.value)
}
@Test fun logoutDuringRefreshCannotBeUndone() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.clear(); response()
}
fail("Must reject a refresh after logout")
} catch (_: IllegalStateException) { }
assertNull(store.value)
}
@Test fun staleRequestCannotUseAnotherInspectorsSession() = runBlocking {
val previous = old(); val store = Store(previous.copy(userId = "u2", refreshToken = "other"))
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
fail("No refresh allowed for another inspector"); response()
}
fail("Must reject account switch")
} catch (_: IllegalStateException) { }
assertEquals("u2", store.value?.userId)
}
@Test fun lateUnauthorizedDoesNotClearANewLogin() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.value = previous.copy(refreshToken = "new-login")
throw http(401)
}
} catch (_: HttpException) { }
assertEquals("new-login", store.value?.refreshToken)
assertEquals(0, store.clears)
}
}
@@ -0,0 +1,28 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.*
import org.junit.Test
class OperationTrackerTest {
@Test fun doubleTapCannotQueueASecondWrite() {
val tracker = OperationTracker()
assertTrue(tracker.begin(mutation = true))
assertFalse(tracker.begin(mutation = true))
tracker.end()
assertFalse(tracker.busy)
assertTrue(tracker.begin(mutation = true))
}
@Test fun BusyRemainsUntilEveryReadCompletes() {
val tracker = OperationTracker()
assertTrue(tracker.begin(false)); assertTrue(tracker.begin(false))
tracker.end(); assertTrue(tracker.busy)
assertFalse(tracker.begin(true))
tracker.end(); assertFalse(tracker.busy)
}
@Test fun readDuringWriteDoesNotReleaseItsGuardEarly() {
val tracker = OperationTracker()
assertTrue(tracker.begin(true)); assertTrue(tracker.begin(false))
tracker.end(); assertFalse(tracker.begin(true))
tracker.end(); assertTrue(tracker.begin(true))
}
}
@@ -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(30, BuildConfig.VERSION_CODE)
assertEquals("0.19.2-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://"))
}
}
@@ -0,0 +1,28 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import org.junit.Assert.*
import org.junit.Test
class TechnicalValuesTest {
private fun field(type: String, required: Boolean = false) = TechnicalDefinition("id", "code", "Campo", type, required, options = listOf("A", "B"))
@Test fun decimalCommaAndBooleanFalseKeepTheirTypes() {
assertEquals(12.5, technicalInputValue(field("NUMBER"), "12,5"))
assertEquals(false, technicalInputValue(field("BOOLEAN", true), "false"))
assertEquals(0.0, technicalInputValue(field("NUMBER", true), "0"))
}
@Test fun optionalBlankIsOmittedButRequiredBlankFails() {
assertNull(technicalInputValue(field("TEXT"), " "))
try { technicalInputValue(field("TEXT", true), ""); fail("Required missing") }
catch (_: IllegalArgumentException) { }
}
@Test fun invalidNumbersDatesAndOptionsFailBeforeSave() {
for ((type, value) in listOf("NUMBER" to "NaN", "DATE" to "2026-02-30", "SELECT" to "C", "BOOLEAN" to "quizás")) {
try { technicalInputValue(field(type), value); fail("Invalid value accepted") }
catch (_: IllegalArgumentException) { }
}
}
@Test fun validDatesAndSelectRemainCanonical() {
assertEquals("2026-09-14", technicalInputValue(field("DATE"), "2026-09-14"))
assertEquals("A", technicalInputValue(field("SELECT"), "A"))
assertEquals("2026-09-14T13:00:00Z", technicalInputValue(field("DATETIME"), "2026-09-14T10:00:00-03:00"))
}
}
+8 -8
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.20.0-2", "version": "0.29.0-3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.20.0-2", "version": "0.29.0-3",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
@@ -4166,9 +4166,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "2.2.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
@@ -4666,9 +4666,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.3", "version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.25.0-1", "version": "0.29.0-3",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -42,5 +42,9 @@
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.0" "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 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 FROM inspection_findings f WHERE f.act_id = ia.id
) fc ON true ) fc ON true
WHERE ia.status IN ('CLOSED', 'RECTIFIED') WHERE ia.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
), classified AS ( ), classified AS (
SELECT *, CASE SELECT *, CASE
WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED' 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 rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]);
const act = rows[0]; const act = rows[0];
if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' }); 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; return act;
} }
@@ -36,6 +36,20 @@ export interface FieldBriefingAct {
findings: FieldBriefingFinding[]; 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() @Injectable()
export class FieldBriefingService { export class FieldBriefingService {
constructor(private readonly dataSource: DataSource) {} 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(` const rows = (await this.dataSource.query(`
WITH prior_acts AS ( 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 ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC
LIMIT 1 LIMIT 1
) response ON true ) response ON true
WHERE act.status IN ('CLOSED', 'RECTIFIED') WHERE act.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
AND source_visit.id <> $1 AND source_visit.id <> $1
AND source_visit.operational_area_id = $2::uuid AND source_visit.operational_area_id = $2::uuid
AND source_visit.operator_company_id = $3::uuid AND source_visit.operator_company_id = $3::uuid
-4
View File
@@ -15,8 +15,6 @@ import { HealthController } from './health.controller';
import { HealthService } from './health.service'; import { HealthService } from './health.service';
import { DashboardModule } from './dashboard/dashboard.module'; import { DashboardModule } from './dashboard/dashboard.module';
import { AssetMasterModule } from './asset-master/asset-master.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 { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
import { InspectionActsModule } from './inspection-acts/inspection-acts.module'; import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module'; import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
@@ -69,8 +67,6 @@ function required(config: ConfigService, key: string): string {
AdministrationModule, AdministrationModule,
DashboardModule, DashboardModule,
AssetMasterModule, AssetMasterModule,
SurveyPlanningModule,
SurveyExecutionModule,
InspectionVisitsModule, InspectionVisitsModule,
InspectionActsModule, InspectionActsModule,
InspectionFindingsModule, InspectionFindingsModule,
@@ -0,0 +1,407 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
type DossierRecord = Record<string, any>;
@Injectable()
export class ActivityDossierService {
private readonly logger = new Logger(ActivityDossierService.name);
constructor(private readonly dataSource: DataSource) {}
async dossier(id: string): Promise<Record<string, unknown>> {
const assetRows = (await this.dataSource.query(
`SELECT id, code, name, common_name AS "commonName", created_at AS "createdAt"
FROM assets
WHERE id = $1`,
[id],
)) as Array<{ id: string; code: string; name: string; commonName: string | null; createdAt: Date }>;
const asset = assetRows[0];
if (!asset) {
throw new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
const failedFacets: string[] = [];
const safeQuery = async (facet: string, sql: string): Promise<DossierRecord[]> => {
try {
return (await this.dataSource.query(sql, [id])) as DossierRecord[];
} catch (error) {
failedFacets.push(facet);
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`No se pudo cargar la faceta ${facet} del expediente ${id}: ${message}`);
return [];
}
};
const [
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
media,
versions,
] = await Promise.all([
safeQuery('visits', `
SELECT visit.id, visit.code, visit.status,
visit.planned_start_at AS "plannedStartAt",
visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt",
visit.created_at AS "createdAt"
FROM inspection_visits visit
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_visit_assets visit_asset
WHERE visit_asset.visit_id = visit.id
AND visit_asset.asset_id = $1
AND visit_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_act_assets act_asset
ON act_asset.act_id = act.id AND act_asset.included = true
WHERE act.visit_id = visit.id
AND act_asset.asset_id = $1
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_findings finding ON finding.act_id = act.id
WHERE act.visit_id = visit.id
AND finding.asset_id = $1
)
ORDER BY COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at) DESC
LIMIT 200
`),
safeQuery('acts', `
SELECT 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"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY act.occurred_at DESC
LIMIT 200
`),
safeQuery('findings', `
SELECT finding.id, finding.act_id AS "actId", finding.code, finding.status,
finding.title, finding.description,
finding.correction_due_on AS "correctionDueOn",
finding.company_response_received_on AS "companyResponseReceivedOn",
finding.next_control_on AS "nextControlOn",
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"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id = finding.act_id
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE finding.asset_id = $1
ORDER BY finding.created_at DESC
LIMIT 500
`),
safeQuery('evidence', `
SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId",
evidence.kind, evidence.purpose, evidence.original_name AS "originalName",
evidence.title, evidence.description, evidence.captured_at AS "capturedAt",
evidence.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_evidence evidence
JOIN inspection_findings finding ON finding.id = evidence.finding_id
WHERE finding.asset_id = $1
ORDER BY COALESCE(evidence.captured_at, evidence.created_at) DESC
LIMIT 500
`),
safeQuery('communications', `
SELECT communication.id, communication.finding_id AS "findingId",
communication.direction, communication.channel, communication.type,
communication.occurred_at AS "occurredAt", communication.subject,
communication.details, communication.contact_name AS "contactName",
communication.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_communications communication
JOIN inspection_findings finding ON finding.id = communication.finding_id
WHERE finding.asset_id = $1
ORDER BY communication.occurred_at DESC
LIMIT 500
`),
safeQuery('verificationResults', `
SELECT verification_link.id,
verification_link.finding_id AS "findingId",
verification_link.visit_id AS "visitId",
verification_link.target_control_on AS "targetControlOn",
verification_link.outcome,
verification_link.result_notes AS "resultNotes",
verification_link.verified_at AS "verifiedAt",
verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn",
finding.code AS "findingCode", finding.title AS "findingTitle",
visit.code AS "visitCode", visit.status AS "visitStatus",
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence verification_evidence
WHERE verification_evidence.finding_id = finding.id
AND verification_evidence.verification_visit_id = visit.id
AND verification_evidence.purpose = 'VERIFICATION') AS "evidenceCount"
FROM inspection_finding_verification_visits verification_link
JOIN inspection_findings finding ON finding.id = verification_link.finding_id
JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE finding.asset_id = $1 AND verification_link.outcome IS NOT NULL
ORDER BY verification_link.verified_at DESC NULLS LAST, verification_link.result_recorded_at DESC
LIMIT 500
`),
safeQuery('documents', `
SELECT document.id, document.document_type AS "documentType",
document.document_number AS "documentNumber", document.title,
document.issuer, document.document_date AS "documentDate",
document.external_reference AS "externalReference",
link.relation_type AS "relationType", link.notes,
link.created_at AS "linkedAt"
FROM asset_source_documents link
JOIN source_documents document ON document.id = link.document_id
WHERE link.asset_id = $1
ORDER BY COALESCE(document.document_date::timestamptz, link.created_at) DESC
LIMIT 300
`),
safeQuery('inspectionReports', `
SELECT report.id, report.code, report.status,
report.pdf_status AS "pdfStatus", report.title,
report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256",
act.id AS "actId", act.code AS "actCode",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_reports report
JOIN inspection_acts act ON act.id = report.act_id
JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY report.generated_at DESC
LIMIT 200
`),
safeQuery('media', `
SELECT media.id, media.kind, media.original_name AS "originalName",
media.title, media.description, media.captured_at AS "capturedAt",
media.created_at AS "createdAt", media.source
FROM asset_media media
WHERE media.asset_id = $1 AND media.deleted_at IS NULL
ORDER BY COALESCE(media.captured_at, media.created_at) DESC
LIMIT 500
`),
safeQuery('versions', `
SELECT version.id, version.version_number AS "versionNumber",
version.change_type AS "changeType", version.changed_fields AS "changedFields",
version.occurred_at AS "occurredAt", version.actor_username AS "actorUsername",
version.source
FROM asset_versions version
WHERE version.asset_id = $1
ORDER BY version.occurred_at DESC
LIMIT 500
`),
]);
const timeline: DossierRecord[] = [];
const push = (event: DossierRecord) => timeline.push(event);
versions.forEach((version) => push({
id: `version:${String(version.id)}`,
kind: 'INVENTORY_CHANGE',
occurredAt: version.occurredAt,
title: version.changeType === 'CREATED' || version.changeType === 'BASELINE'
? 'Registro incorporado al inventario'
: 'Inventario actualizado',
description: Array.isArray(version.changedFields) && version.changedFields.length > 0
? `Campos: ${(version.changedFields as string[]).join(', ')}`
: null,
meta: {
versionNumber: version.versionNumber,
changeType: version.changeType,
actorUsername: version.actorUsername,
source: version.source,
},
}));
visits.forEach((visit) => push({
id: `visit:${String(visit.id)}`,
kind: 'INSPECTION',
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
title: `Inspección ${String(visit.code)}`,
description: null,
href: `/inspecciones/${String(visit.id)}`,
meta: { status: visit.status },
}));
acts.forEach((act) => push({
id: `act:${String(act.id)}`,
kind: 'ACT',
occurredAt: act.occurredAt,
title: `Acta ${String(act.code)}`,
description: act.title,
href: `/inspecciones/actas/${String(act.id)}`,
meta: { status: act.status, visitCode: act.visitCode },
}));
findings.forEach((finding) => {
push({
id: `finding:${String(finding.id)}`,
kind: 'FINDING',
occurredAt: finding.createdAt,
title: `Hallazgo ${String(finding.code)}`,
description: finding.title,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status, actCode: finding.actCode },
});
if (finding.closedAt) {
push({
id: `finding-close:${String(finding.id)}`,
kind: 'FINDING_CLOSED',
occurredAt: finding.closedAt,
title: `Hallazgo ${String(finding.code)} cerrado`,
description: finding.closureNotes,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status },
});
}
});
verificationResults.forEach((verification) => push({
id: `verification:${String(verification.id)}`,
kind: 'VERIFICATION',
occurredAt: verification.verifiedAt ?? verification.resultRecordedAt,
title: verification.outcome === 'RESOLVED'
? `Verificación conforme · ${String(verification.findingCode)}`
: verification.outcome === 'NOT_RESOLVED'
? `Verificación no conforme · ${String(verification.findingCode)}`
: `Verificación reprogramada · ${String(verification.findingCode)}`,
description: verification.resultNotes,
href: `/hallazgos/${String(verification.findingId)}`,
meta: {
outcome: verification.outcome,
visitCode: verification.visitCode,
targetControlOn: verification.targetControlOn,
rescheduledControlOn: verification.rescheduledControlOn,
evidenceCount: verification.evidenceCount,
},
}));
communications.forEach((communication) => push({
id: `communication:${String(communication.id)}`,
kind: 'COMMUNICATION',
occurredAt: communication.occurredAt,
title: communication.type === 'COMPANY_RESPONSE'
? 'Respuesta de la empresa'
: String(communication.subject),
description: communication.details,
href: `/hallazgos/${String(communication.findingId)}`,
meta: {
findingCode: communication.findingCode,
direction: communication.direction,
channel: communication.channel,
},
}));
evidence.forEach((item) => push({
id: `evidence:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía / evidencia' : 'Documento incorporado',
description: item.title ?? item.originalName,
href: `/hallazgos/${String(item.findingId)}`,
meta: { findingCode: item.findingCode, purpose: item.purpose },
}));
inspectionReports.forEach((report) => push({
id: `inspection-report:${String(report.id)}`,
kind: 'REPORT',
occurredAt: report.generatedAt,
title: `Informe ${String(report.code)}`,
description: report.title,
href: `/informes/${String(report.id)}`,
meta: { status: report.status, pdfStatus: report.pdfStatus, actCode: report.actCode },
}));
documents.forEach((document) => push({
id: `source-document:${String(document.id)}`,
kind: document.documentType === 'TECHNICAL_REPORT' ? 'REPORT' : 'SOURCE_DOCUMENT',
occurredAt: document.documentDate ?? document.linkedAt,
title: String(document.title),
description: document.documentNumber
? `Documento ${String(document.documentNumber)}`
: 'Documento vinculado al inventario',
meta: { documentType: document.documentType, relationType: document.relationType },
}));
media.forEach((item) => push({
id: `asset-media:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía del inventario' : 'Archivo del inventario',
description: item.title ?? item.originalName,
meta: { source: item.source },
}));
timeline.sort(
(a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime(),
);
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
return {
asset: {
id: asset.id,
code: asset.code,
name: asset.name,
commonName: asset.commonName,
},
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,
},
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
reports,
media,
versions,
timeline: timeline.slice(0, 500),
warnings: failedFacets,
};
}
}
+55 -158
View File
@@ -41,14 +41,14 @@ export interface AssetVersionDetail extends AssetVersionSummary {
function assetNotFound(): NotFoundException { function assetNotFound(): NotFoundException {
return new NotFoundException({ return new NotFoundException({
code: 'ASSET_NOT_FOUND', code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado', message: 'Inventario no encontrado',
}); });
} }
function versionNotFound(): NotFoundException { function versionNotFound(): NotFoundException {
return new NotFoundException({ return new NotFoundException({
code: 'ASSET_VERSION_NOT_FOUND', 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); const versionNumber = Number(versionRow.current_version);
if (!Number.isInteger(versionNumber) || versionNumber < 1) { 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); const snapshot = await this.loadCurrentSnapshot(manager, assetId);
@@ -101,17 +101,8 @@ export class AssetHistoryService {
asset_id, version_number, change_type, changed_fields, snapshot, asset_id, version_number, change_type, changed_fields, snapshot,
actor_user_id, actor_username, source, request_id actor_user_id, actor_username, source, request_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[ [assetId, versionNumber, changeType, changedFields, snapshot,
assetId, principal.userId, principal.username, source, request.requestId],
versionNumber,
changeType,
changedFields,
snapshot,
principal.userId,
principal.username,
source,
request.requestId,
],
); );
return versionNumber; return versionNumber;
} }
@@ -123,38 +114,21 @@ export class AssetHistoryService {
parameters.push(value); parameters.push(value);
return `$${parameters.length}`; return `$${parameters.length}`;
}; };
if (query.search?.trim()) { if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`); const search = add(`%${query.search.trim()}%`);
conditions.push(`( conditions.push(`(version.snapshot->>'code' ILIKE ${search} OR version.snapshot->>'name' ILIKE ${search} OR version.actor_username ILIKE ${search})`);
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.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.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`);
if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`); if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`);
return this.listWithConditions(query.page, query.pageSize, conditions, parameters); return this.listWithConditions(query.page, query.pageSize, conditions, parameters);
} }
async listForAsset(assetId: string, query: AssetVersionPageQueryDto) { async listForAsset(assetId: string, query: AssetVersionPageQueryDto) {
await this.requireAsset(assetId); await this.requireAsset(assetId);
return this.listWithConditions( return this.listWithConditions(query.page, query.pageSize, ['version.asset_id = $1'], [assetId]);
query.page,
query.pageSize,
['version.asset_id = $1'],
[assetId],
);
} }
async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> { async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> {
@@ -170,17 +144,9 @@ export class AssetHistoryService {
return row; return row;
} }
private async listWithConditions( private async listWithConditions(page: number, pageSize: number, conditions: string[], parameters: unknown[]) {
page: number,
pageSize: number,
conditions: string[],
parameters: unknown[],
) {
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [countRow] = (await this.dataSource.query( const [countRow] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`, parameters)) as Array<{ total: number }>;
`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0); const total = Number(countRow?.total ?? 0);
const paginated = [...parameters, pageSize, (page - 1) * pageSize]; const paginated = [...parameters, pageSize, (page - 1) * pageSize];
const limit = `$${parameters.length + 1}`; const limit = `$${parameters.length + 1}`;
@@ -194,51 +160,29 @@ export class AssetHistoryService {
LIMIT ${limit} OFFSET ${offset}`, LIMIT ${limit} OFFSET ${offset}`,
paginated, paginated,
)) as AssetVersionSummary[]; )) 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 { private selectSummary(): string {
return `SELECT return `SELECT
version.id, version.id, version.asset_id AS "assetId",
version.asset_id AS "assetId", version.snapshot->>'code' AS "assetCode", version.snapshot->>'name' AS "assetName",
version.snapshot->>'code' AS "assetCode", version.snapshot #>> '{type,id}' AS "typeId", version.snapshot #>> '{type,name}' AS "typeName",
version.snapshot->>'name' AS "assetName",
version.snapshot #>> '{type,id}' AS "typeId",
version.snapshot #>> '{type,name}' AS "typeName",
version.snapshot->>'informationStatus' AS "informationStatus", version.snapshot->>'informationStatus' AS "informationStatus",
version.snapshot->>'operationalStatus' AS "operationalStatus", version.snapshot->>'operationalStatus' AS "operationalStatus",
version.version_number AS "versionNumber", version.version_number AS "versionNumber", version.change_type AS "changeType",
version.change_type AS "changeType", version.changed_fields AS "changedFields", version.occurred_at AS "occurredAt",
version.changed_fields AS "changedFields", version.actor_user_id AS "actorUserId", version.actor_username AS "actorUsername",
version.occurred_at AS "occurredAt", version.source, version.request_id AS "requestId",
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"`; (version.version_number = current_asset.current_version) AS "isCurrent"`;
} }
private async requireAsset(assetId: string): Promise<void> { private async requireAsset(assetId: string): Promise<void> {
const [row] = (await this.dataSource.query( const [row] = (await this.dataSource.query('SELECT 1 FROM assets WHERE id = $1', [assetId])) as unknown[];
'SELECT 1 FROM assets WHERE id = $1',
[assetId],
)) as unknown[];
if (!row) throw assetNotFound(); if (!row) throw assetNotFound();
} }
private async loadCurrentSnapshot( private async loadCurrentSnapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
manager: EntityManager,
assetId: string,
): Promise<Record<string, unknown>> {
const [row] = (await manager.query( const [row] = (await manager.query(
`SELECT JSONB_BUILD_OBJECT( `SELECT JSONB_BUILD_OBJECT(
'id', asset.id, 'id', asset.id,
@@ -246,82 +190,49 @@ export class AssetHistoryService {
'name', asset.name, 'name', asset.name,
'commonName', asset.common_name, 'commonName', asset.common_name,
'description', asset.description, 'description', asset.description,
'type', JSONB_BUILD_OBJECT( 'type', JSONB_BUILD_OBJECT('id', asset_type.id,'code', asset_type.code,'name', asset_type.name,'operationalRole', asset_type.operational_role),
'id', asset_type.id, 'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', parent.id,'code', parent.code,'name', parent.name) END,
'code', asset_type.code, '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,
'name', asset_type.name, '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,
'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, 'informationStatus', asset.information_status,
'operationalStatus', asset.operational_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(( 'attributes', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT( SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'definitionId', definition.id, 'definitionId', definition.id,'code', definition.code,'name', definition.name,
'code', definition.code, 'dataType', definition.data_type,'isRequired', definition.is_required,
'name', definition.name, 'unit', definition.unit,'options', definition.options,'sortOrder', definition.sort_order,'value', value.value
'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) ) ORDER BY definition.sort_order, definition.name)
FROM asset_attribute_definitions definition FROM asset_attribute_definitions definition
LEFT JOIN asset_attribute_values value LEFT JOIN asset_attribute_values value ON value.definition_id = definition.id AND value.asset_id = asset.id
ON value.definition_id = definition.id WHERE definition.asset_type_id = asset.asset_type_id AND definition.is_active = true
AND value.asset_id = asset.id
WHERE definition.asset_type_id = asset.asset_type_id
AND definition.is_active = true
), '[]'::jsonb), ), '[]'::jsonb),
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'assetId', geometry.asset_id, 'assetId', geometry.asset_id,'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,'geometryType', geometry.geometry_type,
'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb, 'source', geometry.source,'accuracyM', geometry.accuracy_m::double precision,'capturedAt', geometry.captured_at,
'geometryType', geometry.geometry_type, 'deviceLabel', geometry.device_label,'createdAt', geometry.created_at,'updatedAt', geometry.updated_at,'updatedBy', geometry.updated_by
'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, ) END,
'media', COALESCE(( 'media', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT( SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', media.id, 'id', media.id,'kind', media.kind,'originalName', media.original_name,'mimeType', media.mime_type,
'kind', media.kind, 'sizeBytes', media.size_bytes,'sha256', media.sha256,'title', media.title,'description', media.description,
'originalName', media.original_name, 'capturedAt', media.captured_at,'latitude', media.latitude,'longitude', media.longitude,'accuracyM', media.accuracy_m,
'mimeType', media.mime_type, 'source', media.source,'uploadedBy', media.uploaded_by,'createdAt', media.created_at,'updatedAt', media.updated_at
'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) ) ORDER BY media.created_at, media.id)
FROM asset_media media FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL
WHERE media.asset_id = asset.id
AND media.deleted_at IS NULL
), '[]'::jsonb), ), '[]'::jsonb),
'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id), 'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id),
'organizationMemberships', COALESCE(( 'organizationMemberships', COALESCE((
@@ -337,22 +248,8 @@ export class AssetHistoryService {
'legalRights', COALESCE(( '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 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), ),'[]'::jsonb),
'provenance', JSONB_BUILD_OBJECT( '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),
'origin', asset.data_origin, 'createdAt', asset.created_at,'updatedAt', asset.updated_at,'createdBy', asset.created_by,'updatedBy', asset.updated_by,'currentVersion', asset.current_version
'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 ) AS snapshot
FROM assets asset FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
@@ -24,11 +24,18 @@ import { AssetRegistryService } from './asset-registry.service';
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service'; import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
import { InventoryStructureController } from './inventory-structure.controller'; import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service'; import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller'; import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; 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 { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
import { ActivityDossierService } from './activity-dossier.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service'; import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service';
@Module({ @Module({
imports: [AuditModule], imports: [AuditModule],
@@ -37,6 +44,8 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
AssetsController, AssetsController,
InventoryStructureController, InventoryStructureController,
InventoryFamilyCatalogController, InventoryFamilyCatalogController,
InventoryTechnicalValuesController,
InventoryBrowserController,
InventoryMergeController, InventoryMergeController,
FieldInventoryMergeController, FieldInventoryMergeController,
AssetGeometriesController, AssetGeometriesController,
@@ -52,8 +61,13 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
AssetTypesService, AssetTypesService,
AssetsService, AssetsService,
InventoryStructureService, InventoryStructureService,
SimpleInventoryStructureService,
InventoryFamilyCatalogService, InventoryFamilyCatalogService,
InventoryTechnicalValuesService,
InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService, InventoryMergeService,
ActivityDossierService,
MergedInventoryDossierService, MergedInventoryDossierService,
AssetGeometriesService, AssetGeometriesService,
AssetHistoryService, AssetHistoryService,
@@ -79,23 +79,15 @@ export class AssetOperationalRelationsService {
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> { async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA); await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
const data = (await this.dataSource.query(` const data = (await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName" SELECT DISTINCT company.id, company.code, company.name,
FROM ( company.common_name AS "commonName", company_type.name AS "typeName"
SELECT relation.company_id FROM area_company_relations relation
FROM area_company_relations relation INNER 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
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
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_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 ORDER BY company.name, company.code
`, [areaId])) as OperationalAssetSummary[]; `, [areaId])) as OperationalAssetSummary[];
return { data }; return { data };
@@ -104,23 +96,15 @@ export class AssetOperationalRelationsService {
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> { async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY); await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
const data = (await this.dataSource.query(` const data = (await this.dataSource.query(`
SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName" SELECT DISTINCT area.id, area.code, area.name,
FROM ( area.common_name AS "commonName", area_type.name AS "typeName"
SELECT relation.area_id FROM area_company_relations relation
FROM area_company_relations relation INNER JOIN assets area ON area.id = relation.area_id
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
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_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 ORDER BY area.name, area.code
`, [companyId])) as OperationalAssetSummary[]; `, [companyId])) as OperationalAssetSummary[];
return { data }; return { data };
@@ -157,12 +141,16 @@ export class AssetOperationalRelationsService {
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]); 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 (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
} }
// F7: un Área puede estar explotada por varias Empresas simultáneamente.
// La unicidad válida es Área + Empresa + rol activo; no existe una única Operadora por Área.
const [row] = (await manager.query(` const [row] = (await manager.query(`
INSERT INTO area_company_relations ( INSERT INTO area_company_relations (
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by 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) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id 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 }>; `, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
const created = await this.loadRelation(manager, row.id); const created = await this.loadRelation(manager, row.id);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -170,6 +158,9 @@ export class AssetOperationalRelationsService {
entityType: 'area_company_relation', entityType: 'area_company_relation',
entityId: row.id, entityId: row.id,
afterData: this.auditView(created), afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, multipleAreaOperatorsAllowed:true }
: undefined,
}, manager); }, manager);
return created; return created;
}); });
@@ -198,12 +189,8 @@ export class AssetOperationalRelationsService {
message: 'La relación ya se encuentra finalizada', message: 'La relación ya se encuentra finalizada',
}); });
} }
if (before.assignedAssetCount > 0) {
throw new ConflictException({ // Ending an operator relation never moves, rewrites or blocks existing Inventory.
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`,
});
}
await manager.query(` await manager.query(`
UPDATE area_company_relations UPDATE area_company_relations
SET valid_until = CURRENT_TIMESTAMP, SET valid_until = CURRENT_TIMESTAMP,
@@ -220,6 +207,11 @@ export class AssetOperationalRelationsService {
entityId: id, entityId: id,
beforeData: this.auditView(before), beforeData: this.auditView(before),
afterData: this.auditView(updated), afterData: this.auditView(updated),
metadata: {
inventoryHierarchyChanged:false,
operatorSnapshotPreserved:true,
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
},
}, manager); }, manager);
return updated; return updated;
}); });
@@ -330,7 +322,8 @@ export class AssetOperationalRelationsService {
(relation.valid_until IS NULL) AS active, (relation.valid_until IS NULL) AS active,
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
WHERE asset.operational_area_id = relation.area_id 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 FROM area_company_relations relation
INNER JOIN assets area ON area.id = relation.area_id INNER JOIN assets area ON area.id = relation.area_id
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_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(); if (!row) throw temporalAssetNotFound();
const asOf = new Date(query.at); const asOf = new Date(query.at);
row.asOf = asOf; 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(` const [context] = (await this.dataSource.query(`
SELECT 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 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 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 FROM asset_context_history history
LEFT JOIN assets parent ON parent.id=history.parent_id LEFT JOIN assets parent ON parent.id=history.parent_id
LEFT JOIN assets area ON area.id=history.operational_area_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 WHERE history.asset_id=$1
AND history.valid_from <= $2 AND history.valid_from <= $2
AND (history.valid_until IS NULL OR history.valid_until > $2) AND (history.valid_until IS NULL OR history.valid_until > $2)
ORDER BY history.valid_from DESC ORDER BY history.valid_from DESC
LIMIT 1 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) { if (context && row.snapshot) {
row.snapshot = { row.snapshot = {
...row.snapshot, ...row.snapshot,
+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([ const [visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions] = await Promise.all([
this.dataSource.query( 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.planned_start_at AS "plannedStartAt",
visit.actual_started_at AS "actualStartedAt", visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt", 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, `SELECT DISTINCT act.id, act.visit_id AS "visitId", act.code, act.status,
act.occurred_at AS "occurredAt", act.title, act.summary, act.occurred_at AS "occurredAt", act.title, act.summary,
act.closed_at AS "closedAt", act.current_version AS "currentVersion", 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 FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id JOIN inspection_visits visit ON visit.id = act.visit_id
LEFT JOIN inspection_act_assets act_asset 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.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt", finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
act.code AS "actCode", act.occurred_at AS "actOccurredAt", 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 FROM inspection_findings finding
JOIN inspection_acts act ON act.id = finding.act_id JOIN inspection_acts act ON act.id = finding.act_id
JOIN inspection_visits visit ON visit.id = act.visit_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.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn", verification_link.rescheduled_control_on AS "rescheduledControlOn",
finding.code AS "findingCode", finding.title AS "findingTitle", 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 (SELECT COUNT(*)::integer
FROM inspection_finding_evidence verification_evidence FROM inspection_finding_evidence verification_evidence
WHERE verification_evidence.finding_id = finding.id WHERE verification_evidence.finding_id = finding.id
@@ -484,7 +484,7 @@ export class AssetsService {
kind: 'INSPECTION', kind: 'INSPECTION',
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt, occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
title: `Inspección ${String(visit.code)}`, title: `Inspección ${String(visit.code)}`,
description: visit.title, description: null,
href: `/inspecciones/${String(visit.id)}`, href: `/inspecciones/${String(visit.id)}`,
meta: { status: visit.status }, meta: { status: visit.status },
})); }));
@@ -867,7 +867,7 @@ export class AssetsService {
private fieldDiscoverySelect(): string { 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", 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',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, 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 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" 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 { 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 { export class ChangeAssetContextDto {
@IsOptional() @IsOptional()
@@ -10,8 +10,13 @@ export class ChangeAssetContextDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId?: string | null; 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() @IsOptional()
@IsUUID('4') @IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
operatorCompanyId?: string | null; operatorCompanyId?: string | null;
@IsOptional() @IsOptional()
@@ -54,6 +54,10 @@ export class CreateAssetDto {
@IsUUID('4') @IsUUID('4')
operatorCompanyId?: string | null; operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional() @IsOptional()
@Transform(({ value }) => @Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null, typeof value === 'string' && value.trim() ? value.trim() : null,
@@ -9,7 +9,12 @@ import {
MinLength, MinLength,
} from 'class-validator'; } 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 = [ export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA',
'DEPARTAMENTO',
'AREA', 'AREA',
'YACIMIENTO', 'YACIMIENTO',
'INSTALACION', 'INSTALACION',
@@ -48,6 +53,14 @@ export class CreateInventoryStructureDto {
@IsUUID('4') @IsUUID('4')
familyId?: string | null; familyId?: string | null;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional() @IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString() @IsString()
@@ -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 { 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'; import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class ListAssetTreeQueryDto { export class ListAssetTreeQueryDto {
@@ -24,8 +24,12 @@ export class ListAssetTreeQueryDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId?: string; operationalAreaId?: string;
/**
* Retenido sólo para compatibilidad tipada. El árbol operativo no puede
* filtrar por el snapshot histórico de Empresa del Inventario.
*/
@IsOptional() @IsOptional()
@IsUUID('4') @IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
operatorCompanyId?: string; operatorCompanyId?: string;
@IsOptional() @IsOptional()
@@ -37,4 +41,9 @@ export class ListAssetTreeQueryDto {
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean() @IsBoolean()
hasGeometry?: boolean; 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 { Transform, Type } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsEmpty,
IsEnum, IsEnum,
IsInt, IsInt,
IsOptional, IsOptional,
@@ -39,7 +40,6 @@ export class ListAssetsQueryDto {
@IsEnum(AssetInformationStatus) @IsEnum(AssetInformationStatus)
status?: AssetInformationStatus; status?: AssetInformationStatus;
@IsOptional() @IsOptional()
@IsEnum(AssetOperationalStatus) @IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus; operationalStatus?: AssetOperationalStatus;
@@ -54,6 +54,11 @@ export class ListAssetsQueryDto {
@IsBoolean() @IsBoolean()
hasGeometry?: boolean; hasGeometry?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
inventoryOnly?: boolean;
@IsOptional() @IsOptional()
@IsUUID('4') @IsUUID('4')
parentId?: string; parentId?: string;
@@ -62,7 +67,12 @@ export class ListAssetsQueryDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId?: string; 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() @IsOptional()
@IsUUID('4') @IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
operatorCompanyId?: string; operatorCompanyId?: string;
} }
@@ -1,5 +1,6 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsEmpty,
IsObject, IsObject,
IsOptional, IsOptional,
IsString, IsString,
@@ -48,9 +49,13 @@ export class UpdateAssetDto {
operationalAreaId?: string | null; operationalAreaId?: string | null;
@IsOptional() @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; 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() @IsOptional()
@Transform(({ value }) => @Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null, 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;
}
}
}
@@ -1,11 +1,99 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common'; 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 { 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'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
@Controller('inventory-families') @Controller('inventory-families')
export class InventoryFamilyCatalogController { export class InventoryFamilyCatalogController {
constructor(private readonly families: InventoryFamilyCatalogService) {} 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') @Get(':familyId/findings')
@RequirePermissions('assets.read') @RequirePermissions('assets.read')
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) { findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
@@ -1,28 +1,69 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { randomUUID } from 'node:crypto';
import { DataSource } from 'typeorm'; 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() @Injectable()
export class InventoryFamilyCatalogService { export class InventoryFamilyCatalogService {
constructor(private readonly dataSource: DataSource) {} 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) { async findings(familyId: string) {
const [family] = await this.dataSource.query(` const family = await this.family(familyId, false);
SELECT id,code,name,level,information_labels AS "informationLabels"
FROM inventory_families
WHERE id=$1::uuid AND is_active=true
`, [familyId]) as Array<{
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
}>;
if (!family) {
throw new NotFoundException({
code: 'INVENTORY_FAMILY_NOT_FOUND',
message: 'La familia técnica no existe',
});
}
const items = await this.dataSource.query(` const items = await this.dataSource.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title, SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity", item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
@@ -36,4 +77,369 @@ export class InventoryFamilyCatalogService {
`, [familyId]); `, [familyId]);
return { family, items, count: items.length }; 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;
}
}
@@ -39,6 +39,22 @@ type MergeRow = {
requestId: string | null; 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']); const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
@Injectable() @Injectable()
@@ -161,6 +177,19 @@ export class InventoryMergeService {
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' }); if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
this.validatePair(source, canonical); 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 const sourceParentCanonical = source.parentId
? await this.resolveCanonicalId(manager, source.parentId) ? await this.resolveCanonicalId(manager, source.parentId)
: null; : null;
@@ -197,6 +226,8 @@ export class InventoryMergeService {
}); });
} }
const affectedAssetIds = [source.id, canonical.id];
const documentInvariantsBefore = await this.documentInvariants(manager, affectedAssetIds);
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([ const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
this.snapshot(manager, source.id), this.snapshot(manager, source.id),
this.snapshot(manager, canonical.id), this.snapshot(manager, canonical.id),
@@ -279,6 +310,17 @@ export class InventoryMergeService {
request, 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 = { const result = {
merge: mergeRecord, merge: mergeRecord,
source: { id: source.id, code: source.code, name: source.name }, source: { id: source.id, code: source.code, name: source.name },
@@ -286,6 +328,7 @@ export class InventoryMergeService {
sourceVersionNumber, sourceVersionNumber,
reparentedChildIds, reparentedChildIds,
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED', historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
documentaryInvariantsVerified: true,
}; };
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -298,10 +341,12 @@ export class InventoryMergeService {
operation: 'CHRONOLOGICAL_MERGE', operation: 'CHRONOLOGICAL_MERGE',
sourceAssetId: source.id, sourceAssetId: source.id,
canonicalAssetId: canonical.id, canonicalAssetId: canonical.id,
physicalAreaId: sourceAreaId,
reason: dto.reason, reason: dto.reason,
sourceVersionNumber, sourceVersionNumber,
reparentedChildIds, reparentedChildIds,
historicalReferencesRewritten: false, historicalReferencesRewritten: false,
documentaryInvariantsVerified: true,
}, },
}, manager); }, manager);
return result; return result;
@@ -322,14 +367,59 @@ export class InventoryMergeService {
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE', code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
message: 'El registro canónico no puede estar inactivo', message: 'El registro canónico no puede estar inactivo',
}); });
if (!source.operationalAreaId || !source.operatorCompanyId }
|| source.operationalAreaId !== canonical.operationalAreaId
|| source.operatorCompanyId !== canonical.operatorCompanyId) { private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise<string | null> {
throw new BadRequestException({ const rows = (await manager.query(`
code: 'INVENTORY_MERGE_CONTEXT_MISMATCH', WITH RECURSIVE lineage AS (
message: 'Los registros deben pertenecer a la misma Área y Operadora', 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> { private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
@@ -4,10 +4,14 @@ import { RequirePermissions } from '../authorization/decorators/require-permissi
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto'; import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
import { InventoryStructureService } from './inventory-structure.service'; import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
@Controller('inventory-structure') @Controller('inventory-structure')
export class InventoryStructureController { export class InventoryStructureController {
constructor(private readonly inventoryStructure: InventoryStructureService) {} constructor(
private readonly inventoryStructure: InventoryStructureService,
private readonly simpleInventoryStructure: SimpleInventoryStructureService,
) {}
@Get() @Get()
@RequirePermissions('assets.read') @RequirePermissions('assets.read')
@@ -24,6 +28,17 @@ export class InventoryStructureController {
return this.inventoryStructure.parents(kind, search); return this.inventoryStructure.parents(kind, search);
} }
@Post('simple')
@RequirePermissions('assets.create')
createSimple(
@Body() dto: CreateInventoryStructureDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.simpleInventoryStructure.create(dto, principal, request);
}
// Compatibilidad temporal con el alta anterior. La WEB F7 utiliza /simple.
@Post() @Post()
@RequirePermissions('assets.create') @RequirePermissions('assets.create')
create( create(
@@ -20,6 +20,8 @@ import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/
import { AssetHistoryService } from './asset-history.service'; import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string }; 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 = { type FamilyRow = {
id: string; id: string;
code: string; code: string;
@@ -27,28 +29,37 @@ type FamilyRow = {
level: 'INSTALLATION' | 'SUBINSTALLATION'; level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null; legacyTypeCode: string | null;
informationLabels: string[]; informationLabels: string[];
parentFamilyId: string | null; parentFamilyIds: string[];
parentFamilyCode: string | null; parentFamilies: FamilyParent[];
parentFamilyName: string | null;
}; };
type ParentRow = { type ParentRow = {
id: string; id: string;
code: string; code: string;
name: string; name: string;
typeCode: string; typeCode: string;
inventoryFamilyId: string | null;
operationalAreaId: string | null; operationalAreaId: string | null;
operatorCompanyId: string | null; operatorCompanyId: string | null;
inventoryFamilyId: string | null; };
type IdRow = { id: string };
type YacimientoContext = {
companyId: string;
concessionTypeId: string;
concessionName: string;
}; };
const TYPE_CODE_BY_KIND: Record<InventoryStructureKind, string> = { const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area', AREA: 'area',
YACIMIENTO: 'yacimiento', YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion', INSTALACION: 'instalacion',
SUBINSTALACION: 'subinstalacion', SUBINSTALACION: 'subinstalacion',
}; };
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = { const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
AREA: null, EMPRESA: null,
DEPARTAMENTO: null,
AREA: 'departamento',
YACIMIENTO: 'area', YACIMIENTO: 'area',
INSTALACION: 'yacimiento', INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion', SUBINSTALACION: 'instalacion',
@@ -70,36 +81,76 @@ export class InventoryStructureService {
const types = (await this.dataSource.query(` const types = (await this.dataSource.query(`
SELECT id,code,name SELECT id,code,name
FROM asset_types FROM asset_types
WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion') WHERE (
AND is_active=true lower(code) IN ('departamento','area','yacimiento','instalacion','subinstalacion')
ORDER BY CASE lower(code) OR operational_role='COMPANY'
WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2 ) AND is_active=true
WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END 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[]; `)) as StructureTypeRow[];
if (types.length !== 4) { 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({ throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE', code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
message: 'La estructura del Inventario todavía no está completamente configurada', message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
}); });
} }
const families = (await this.dataSource.query(` const families = (await this.dataSource.query(`
SELECT family.id,family.code,family.name,family.level, SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode", family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels", family.information_labels AS "informationLabels",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName" 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 FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.is_active=true WHERE family.is_active=true
ORDER BY family.level,family.name,family.code ORDER BY family.level,family.name,family.code
`)) as FamilyRow[]; `)) 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 { return {
levels: [ independentMasters: [
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false }, { kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false },
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true },
], ],
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'), installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'), subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
}; };
@@ -107,7 +158,7 @@ export class InventoryStructureService {
async parents(kindValue: string, search?: string) { async parents(kindValue: string, search?: string) {
const kind = kindValue.toUpperCase() as InventoryStructureKind; const kind = kindValue.toUpperCase() as InventoryStructureKind;
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA') { if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'DEPARTAMENTO' || kind === 'EMPRESA') {
throw new BadRequestException({ throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID', code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
message: 'El nivel indicado no requiere un registro padre', message: 'El nivel indicado no requiere un registro padre',
@@ -139,7 +190,7 @@ export class InventoryStructureService {
AND asset.information_status<>'INACTIVE' AND asset.information_status<>'INACTIVE'
${searchSql} ${searchSql}
ORDER BY asset.name,asset.code ORDER BY asset.name,asset.code
LIMIT 80 LIMIT 100
`, parameters); `, parameters);
return { data: rows }; return { data: rows };
} }
@@ -154,47 +205,82 @@ export class InventoryStructureService {
const type = await this.requireStructureType(manager, dto.kind); const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null); const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent); const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); const yacimientoContext = await this.requireYacimientoContext(manager, dto);
const operationalAreaId = parent?.operationalAreaId ?? null; const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operatorCompanyId = parent?.operatorCompanyId ?? null; 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(` const inserted = (await manager.query(`
INSERT INTO assets ( INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id, 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, code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES ( ) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid, $1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status, $7::varchar,$8::varchar,$9::varchar,$10::text,$11::asset_information_status,$12::asset_operational_status,
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid $13::varchar,$14::varchar,$15::varchar,$16::text,$17::uuid,$17::uuid,$17::uuid
) RETURNING id ) RETURNING id
`, [ `, [
type.id, type.id,
parent?.id ?? null, parent?.id ?? null,
operationalAreaId, operationalAreaId,
operatorCompanyId, operatorCompanyId,
concessionTypeId,
family?.id ?? null, family?.id ?? null,
code, generatedCode,
dto.name, dto.name,
dto.commonName ?? null, dto.commonName ?? null,
dto.description ?? null, dto.description ?? null,
AssetInformationStatus.DRAFT, AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN, AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL, AssetDataOrigin.MANUAL,
'Inventario estructural F3.1', dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
`inventory-structure:${dto.kind.toLowerCase()}`, dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family ? `Familia técnica: ${family.code} · ${family.name}` : null, family
? `Clasificación técnica: ${family.code} · ${family.name}`
: yacimientoContext
? `Tipo de concesión: ${yacimientoContext.concessionName}`
: null,
principal.userId, principal.userId,
])) as Array<{ id: string }>; ])) as Array<{ id: string }>;
const id = inserted[0]?.id; const id = inserted[0]?.id;
if (!id) throw new Error('No se pudo crear el registro estructural'); 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( const versionNumber = await this.history.capture(
manager, manager,id,AssetVersionChangeType.CREATED,principal,request,
id,
AssetVersionChangeType.CREATED,
principal,
request,
); );
await manager.query(` await manager.query(`
INSERT INTO asset_context_history ( INSERT INTO asset_context_history (
@@ -202,53 +288,46 @@ export class InventoryStructureService {
change_reason,asset_version_number,source,request_id,created_by change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8) ) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
`, [ `, [
id, id,parent?.id ?? null,operationalAreaId,operatorCompanyId,
parent?.id ?? null, dto.kind === 'YACIMIENTO'
operationalAreaId, ? 'Alta manual de Yacimiento con Área, Empresa y Tipo de concesión'
operatorCompanyId, : dto.kind === 'EMPRESA'
'Alta guiada de Inventario estructural F3.1', ? 'Alta manual de Empresa independiente'
versionNumber, : 'Alta manual de estructura de Inventario',
request.requestId, versionNumber,request.requestId,principal.userId,
principal.userId,
]); ]);
const created = await this.loadView(manager, id); const created = await this.loadView(manager, id);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
action: AuditAction.ASSET_CREATED, action: AuditAction.ASSET_CREATED,
entityType: 'asset', entityType: 'asset',entityId: id,
entityId: id,
afterData: created as unknown as Record<string, unknown>, afterData: created as unknown as Record<string, unknown>,
metadata: { metadata: {
versionNumber, versionNumber,inventoryStructureKind: dto.kind,
inventoryStructureKind: dto.kind, inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
inventoryFamilyId: family?.id ?? null, operatorCompanyId,
inventoryFamilyCode: family?.code ?? null, concessionTypeId,
}, },
}, manager); }, manager);
return created; return created;
}); });
} catch (error) { } catch (error) {
if (isUniqueViolation(error)) { if (isUniqueViolation(error)) throw new ConflictException({
throw new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS',message: 'Ya existe un registro con ese código',
code: 'ASSET_CODE_ALREADY_EXISTS', });
message: 'Ya existe un registro de Inventario con ese código',
});
}
throw error; throw error;
} }
} }
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> { private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
const rows = (await manager.query(` const sql = kind === 'EMPRESA'
SELECT id,code,name FROM asset_types ? `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`
WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1 : `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
`, [TYPE_CODE_BY_KIND[kind]])) as StructureTypeRow[]; const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
if (!rows[0]) { const rows = (await manager.query(sql,params)) as StructureTypeRow[];
throw new ConflictException({ if (!rows[0]) throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',message: `El nivel ${kind} no está configurado`,
message: `El nivel ${kind} no está configurado`, });
});
}
return rows[0]; return rows[0];
} }
@@ -259,25 +338,23 @@ export class InventoryStructureService {
): Promise<ParentRow | null> { ): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind]; const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) { if (!expectedType) {
if (parentId) { if (parentId) throw new BadRequestException({
throw new BadRequestException({ code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
code: 'INVENTORY_AREA_MUST_BE_ROOT', message: kind === 'EMPRESA'
message: 'Un Área se crea como registro raíz y no puede tener padre', ? '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; return null;
} }
if (!parentId) { if (!parentId) throw new BadRequestException({
throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED', message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`, });
});
}
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode", SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId",
asset.operational_area_id AS "operationalAreaId", asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId", asset.operator_company_id AS "operatorCompanyId"
asset.inventory_family_id AS "inventoryFamilyId"
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE' WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
@@ -285,13 +362,90 @@ export class InventoryStructureService {
`, [parentId])) as ParentRow[]; `, [parentId])) as ParentRow[];
const parent = rows[0]; const parent = rows[0];
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' }); if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
if (parent.typeCode.toLowerCase() !== expectedType) { 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({ throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_INVALID', code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED',
message: 'La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación', message: 'Seleccioná la Empresa relacionada del Yacimiento',
}); });
} }
return parent; 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( private async requireFamily(
@@ -304,49 +458,93 @@ export class InventoryStructureService {
if (!expectedLevel) { if (!expectedLevel) {
if (familyId) throw new BadRequestException({ if (familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED', code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
message: 'Área y Yacimiento no llevan familia técnica', message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica',
}); });
return null; return null;
} }
if (!familyId) throw new BadRequestException({ if (!familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED', code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
message: `Elegí la familia técnica de la ${kind.toLowerCase()}`, message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`,
}); });
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT family.id,family.code,family.name,family.level, SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode", family.legacy_type_code AS "legacyTypeCode",family.information_labels AS "informationLabels",
family.information_labels AS "informationLabels", COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY rule.parent_family_id)
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName" 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 FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid AND family.is_active=true WHERE family.id=$1::uuid AND family.is_active=true
LIMIT 1 LIMIT 1
`, [familyId])) as FamilyRow[]; `, [familyId])) as FamilyRow[];
const family = rows[0]; const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La familia técnica no existe' }); 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({ if (family.level !== expectedLevel) throw new BadRequestException({
code: 'INVENTORY_FAMILY_LEVEL_INVALID', code: 'INVENTORY_FAMILY_LEVEL_INVALID',
message: 'La familia técnica no corresponde al nivel seleccionado', message: 'La clasificación técnica no corresponde al nivel seleccionado',
}); });
if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) { if (kind === 'SUBINSTALACION') {
throw new BadRequestException({ 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', code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
message: 'La Subinstalación elegida no pertenece a la familia de la Instalación seleccionada', message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada',
}); });
} }
return family; 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 { private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA'; const prefix = kind === 'EMPRESA' ? 'EMP'
const readable = name : kind === 'DEPARTAMENTO' ? 'DEP'
.normalize('NFD') : kind === 'AREA' ? 'AREA'
.replace(/[\u0300-\u036f]/g, '') : kind === 'YACIMIENTO' ? 'YAC'
.toUpperCase() : kind === 'INSTALACION' ? 'INST'
.replace(/[^A-Z0-9]+/g, '-') : 'SUB';
.replace(/^-+|-+$/g, '') const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
.slice(0, 48) || 'REGISTRO'; .replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120); return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
} }
@@ -356,6 +554,9 @@ export class InventoryStructureService {
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus", asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, 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 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( CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level, 'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels 'informationLabels',family.information_labels
@@ -364,6 +565,10 @@ export class InventoryStructureService {
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_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 LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid WHERE asset.id=$1::uuid
`, [id]); `, [id]);
@@ -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}`});
}
}
@@ -1,9 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ActivityDossierService } from './activity-dossier.service';
import { AssetsService } from './assets.service'; import { AssetsService } from './assets.service';
import { InventoryFunctionService } from './inventory-function.service';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>; type LooseRecord = Record<string, any>;
const MERGEABLE_DOSSIER_TYPES = new Set(['instalacion', 'subinstalacion']);
function dedupeById<T extends LooseRecord>(items: T[]): T[] { function dedupeById<T extends LooseRecord>(items: T[]): T[] {
const seen = new Set<string>(); const seen = new Set<string>();
const result: T[] = []; const result: T[] = [];
@@ -28,10 +32,22 @@ function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[
export class MergedInventoryDossierService { export class MergedInventoryDossierService {
constructor( constructor(
private readonly assets: AssetsService, private readonly assets: AssetsService,
private readonly activity: ActivityDossierService,
private readonly merges: InventoryMergeService, private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService,
) {} ) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> { async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
const requestedAsset = await this.assets.getById(requestedAssetId);
const requestedTypeCode = requestedAsset.type.code.trim().toLowerCase();
// La conciliación/fusión existe sólo para Instalaciones y Subinstalaciones.
// Los registros territoriales (Departamento, Área, Yacimiento) deben poder
// abrir su Actividad sin depender del subsistema de merge.
if (!MERGEABLE_DOSSIER_TYPES.has(requestedTypeCode)) {
return await this.activity.dossier(requestedAssetId);
}
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord; const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
const canonical = mergeStatus.canonical as LooseRecord; const canonical = mergeStatus.canonical as LooseRecord;
const requested = mergeStatus.requested as LooseRecord; const requested = mergeStatus.requested as LooseRecord;
@@ -42,9 +58,10 @@ export class MergedInventoryDossierService {
.filter((value, index, all) => all.indexOf(value) === index); .filter((value, index, all) => all.indexOf(value) === index);
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => { const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
const dossier = await this.assets.dossier(assetId) as LooseRecord; const dossier = await this.activity.dossier(assetId) as LooseRecord;
const identity = dossier.asset as LooseRecord; const identity = dossier.asset as LooseRecord;
return { assetId, identity, dossier }; const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier };
})); }));
const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({ const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({
@@ -75,6 +92,15 @@ export class MergedInventoryDossierService {
const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']); const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']);
const versions = sortDesc(collect('versions'), ['occurredAt']); 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 }) => const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) =>
((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({ ((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({
...event, ...event,
@@ -91,6 +117,25 @@ export class MergedInventoryDossierService {
); );
const timeline: LooseRecord[] = dedupeById<LooseRecord>(timelineSource); 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) { for (const alias of aliases) {
timeline.push({ timeline.push({
id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`, id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`,
@@ -114,6 +159,9 @@ export class MergedInventoryDossierService {
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT'); const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length; const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length; const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const warnings = dossiers.flatMap(({ assetId, dossier }) =>
((dossier.warnings ?? []) as string[]).map((facet) => `${assetId}:${facet}`),
);
return { return {
asset: { asset: {
@@ -121,6 +169,7 @@ export class MergedInventoryDossierService {
code: canonical.code, code: canonical.code,
name: canonical.name, name: canonical.name,
commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null, commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null,
currentFunction,
}, },
requestedAsset: { requestedAsset: {
id: requested.id, id: requested.id,
@@ -146,7 +195,10 @@ export class MergedInventoryDossierService {
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').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, photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length,
reports: reports.length + inspectionReports.length, reports: reports.length + inspectionReports.length,
functionChanges: functionHistory.length,
}, },
currentFunction,
functionHistory,
visits, visits,
acts, acts,
findings, findings,
@@ -159,6 +211,7 @@ export class MergedInventoryDossierService {
media, media,
versions, versions,
timeline: timeline.slice(0, 1000), timeline: timeline.slice(0, 1000),
warnings,
}; };
} }
} }
@@ -0,0 +1,323 @@
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 ParentRow = {
id: string;
code: string;
name: string;
typeCode: string;
inventoryFamilyId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
};
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
};
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',
};
@Injectable()
export class SimpleInventoryStructureService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
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);
let operationalAreaId: string | null = null;
let operatorCompanyId: string | null = null;
if (dto.kind === 'YACIMIENTO') {
if (!parent) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_AREA_REQUIRED', message: 'Seleccioná el Área del Yacimiento' });
if (!dto.operatorCompanyId) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED', message: 'Seleccioná la Empresa operadora del Yacimiento' });
await this.requireAreaCompanyRelation(manager, parent.id, dto.operatorCompanyId);
operationalAreaId = parent.id;
operatorCompanyId = dto.operatorCompanyId;
} else if (dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') {
operationalAreaId = parent?.operationalAreaId ?? null;
operatorCompanyId = parent?.operatorCompanyId ?? null;
if (!operationalAreaId || !operatorCompanyId) {
throw new ConflictException({
code: 'INVENTORY_YACIMIENTO_CONTEXT_MISSING',
message: 'El Yacimiento de origen debe tener Área y Empresa operadora válidas',
});
}
} else if (dto.operatorCompanyId || dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_SIMPLE_CONTEXT_NOT_ALLOWED',
message: 'Empresa operadora sólo corresponde al Yacimiento',
});
}
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const rows = (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,NULL,$5::uuid,
$6::varchar,$7::varchar,NULL,NULL,$8::asset_information_status,$9::asset_operational_status,
$10::varchar,$11::varchar,$12::varchar,$13::text,$14::uuid,$14::uuid,$14::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
family?.id ?? null,
generatedCode,
dto.name,
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Alta simple de Inventario',
`inventory-simple:${dto.kind.toLowerCase()}`,
family ? `Tipo: ${family.name}` : null,
principal.userId,
])) as Array<{ id: string }>;
const id = rows[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]);
}
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 de Yacimiento con Empresa operadora vinculada al Área'
: 'Alta simple 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 Record<string, unknown>,
metadata: {
versionNumber,
inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
operatorCompanyId,
simpleInventoryFlow: true,
},
}, 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' ? 'Empresa' : 'Departamento'} no admite padre` });
return null;
}
if (!parentId) throw new BadRequestException({ code: 'INVENTORY_PARENT_REQUIRED', message: `Seleccioná el nivel superior para crear ${kind.toLowerCase()}` });
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,lower(type.code) AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId",
CASE WHEN lower(type.code)='area' THEN asset.id ELSE asset.operational_area_id END 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 OF asset
`, [parentId])) as ParentRow[];
const parent = rows[0];
if (!parent || parent.typeCode !== expectedType) {
throw new BadRequestException({ code: 'INVENTORY_PARENT_INVALID', message: `El registro padre debe ser ${expectedType}` });
}
return parent;
}
private async requireAreaCompanyRelation(manager: EntityManager, areaId: string, companyId: string): Promise<void> {
const rows = await manager.query(`
SELECT 1
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE relation.area_id=$1::uuid
AND relation.company_id=$2::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND company.information_status<>'INACTIVE'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
LIMIT 1
FOR KEY SHARE OF relation
`, [areaId, companyId]);
if (!rows[0]) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA',
message: 'La Empresa seleccionada no está vinculada como explotadora del Área',
});
}
}
private async requireFamily(
manager: EntityManager,
kind: InventoryStructureKind,
familyId: string | null,
parent: ParentRow | null,
): Promise<FamilyRow | null> {
if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') {
if (familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_NOT_ALLOWED', message: 'Este nivel no usa Tipo de Instalación/Subinstalación' });
return null;
}
if (!familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_REQUIRED', message: `Seleccioná el tipo de ${kind === 'INSTALACION' ? 'Instalación' : 'Subinstalación'}` });
const rows = (await manager.query(`
SELECT id,code,name,level::text AS level
FROM inventory_families
WHERE id=$1::uuid AND is_active=true
LIMIT 1
FOR KEY SHARE
`, [familyId])) as FamilyRow[];
const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El tipo seleccionado no existe o está oculto' });
const expected = kind === 'INSTALACION' ? 'INSTALLATION' : 'SUBINSTALLATION';
if (family.level !== expected) throw new BadRequestException({ code: 'INVENTORY_FAMILY_LEVEL_INVALID', message: 'El tipo seleccionado no corresponde a este nivel' });
if (kind === 'SUBINSTALACION') {
if (!parent?.inventoryFamilyId) throw new BadRequestException({ code: 'INVENTORY_PARENT_FAMILY_REQUIRED', message: 'La Instalación debe tener un tipo configurado' });
const compatible = await manager.query(`
SELECT 1 FROM inventory_family_parent_rules
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
LIMIT 1
`, [family.id, parent.inventoryFamilyId]);
if (!compatible[0]) throw new BadRequestException({ code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', message: 'Ese tipo de Subinstalación no está habilitado dentro de la Instalación seleccionada' });
}
return family;
}
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 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",
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 inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
return rows[0];
}
}
+104 -51
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
export interface DashboardAuditItem { export interface DashboardInspectorActivityItem {
id: string; id: string;
occurredAt: Date; occurredAt: Date;
actorUsername: string | null; actorUsername: string | null;
@@ -19,12 +19,13 @@ interface DashboardCountsRow {
inactiveUsers: number | string; inactiveUsers: number | string;
activeSessions: number | string; activeSessions: number | string;
openFindings: number | string; openFindings: number | string;
awaitingCompanyResponse: number | string; actsInFollowUp: number | string;
overdueCompanyResponses: number | string; findingsWithoutControlDate: number | string;
companyResponsesDueNext7Days: number | string;
awaitingVerificationSchedule: number | string;
overdueControls: number | string; overdueControls: number | string;
controlsNext30Days: number | string; controlsNext30Days: number | string;
reportsWorking: number | string;
reportsOfficialized: number | string;
sealedActsWithoutReport: number | string;
} }
export interface DashboardUpcomingControl { export interface DashboardUpcomingControl {
@@ -49,9 +50,14 @@ export class DashboardService {
return this.dataSource.transaction('REPEATABLE READ', async (manager) => { return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
const [counts] = (await manager.query(` const [counts] = (await manager.query(`
SELECT SELECT
(SELECT COUNT(*) FROM assets WHERE information_status <> 'INACTIVE') AS "totalAssets", (SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status <> 'INACTIVE') AS "totalAssets",
(SELECT COUNT(*) FROM assets WHERE information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation", (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.information_status <> 'INACTIVE' AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)) AS "assetsWithoutGeometry", (
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 inspection_visits WHERE status='PLANNED') AS "plannedInspections",
(SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers", (SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers",
(SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers", (SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers",
@@ -61,50 +67,74 @@ export class DashboardService {
WHERE revoked_at IS NULL WHERE revoked_at IS NULL
AND expires_at > CURRENT_TIMESTAMP AND expires_at > CURRENT_TIMESTAMP
) AS "activeSessions", ) AS "activeSessions",
(SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN') AS "openFindings",
( (
SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN' SELECT COUNT(*)
) AS "openFindings", 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 SELECT COUNT(*)
WHERE status = 'OPEN' AND company_response_received_on IS NULL FROM inspection_findings finding
) AS "awaitingCompanyResponse", 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 SELECT COUNT(*)
WHERE status = 'OPEN' FROM inspection_findings finding
AND company_response_received_on IS NULL WHERE finding.status = 'OPEN'
AND correction_due_on IS NOT NULL AND finding.next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
AND correction_due_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date AND COALESCE((
) AS "overdueCompanyResponses", SELECT verification.outcome
( FROM inspection_finding_verification_visits verification
SELECT COUNT(*) FROM inspection_findings WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
WHERE status = 'OPEN' ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
AND company_response_received_on IS NULL LIMIT 1
AND correction_due_on BETWEEN ), '') <> 'RESOLVED'
(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
) AS "overdueControls", ) AS "overdueControls",
( (
SELECT COUNT(*) FROM inspection_findings SELECT COUNT(*)
WHERE status = 'OPEN' FROM inspection_findings finding
AND next_control_on BETWEEN WHERE finding.status = 'OPEN'
AND finding.next_control_on BETWEEN
(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 30 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[]; `)) as DashboardCountsRow[];
const recentAudit = (await manager.query(` const recentInspectorActivity = (await manager.query(`
SELECT SELECT
event.id, event.id,
event.occurred_at AS "occurredAt", event.occurred_at AS "occurredAt",
@@ -113,9 +143,25 @@ export class DashboardService {
event.entity_type AS "entityType", event.entity_type AS "entityType",
event.entity_id AS "entityId" event.entity_id AS "entityId"
FROM audit_events event 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 ORDER BY event.occurred_at DESC, event.id DESC
LIMIT 6 LIMIT 8
`)) as DashboardAuditItem[]; `)) as DashboardInspectorActivityItem[];
const upcomingControls = (await manager.query(` const upcomingControls = (await manager.query(`
SELECT SELECT
@@ -135,8 +181,14 @@ export class DashboardService {
INNER JOIN inspection_visits visit ON visit.id = act.visit_id INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE finding.status = 'OPEN' WHERE finding.status = 'OPEN'
AND finding.company_response_received_on IS NOT NULL
AND finding.next_control_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 ORDER BY finding.next_control_on, finding.code
LIMIT 8 LIMIT 8
`)) as DashboardUpcomingControl[]; `)) as DashboardUpcomingControl[];
@@ -151,14 +203,15 @@ export class DashboardService {
inactiveUsers: Number(counts?.inactiveUsers ?? 0), inactiveUsers: Number(counts?.inactiveUsers ?? 0),
activeSessions: Number(counts?.activeSessions ?? 0), activeSessions: Number(counts?.activeSessions ?? 0),
openFindings: Number(counts?.openFindings ?? 0), openFindings: Number(counts?.openFindings ?? 0),
awaitingCompanyResponse: Number(counts?.awaitingCompanyResponse ?? 0), actsInFollowUp: Number(counts?.actsInFollowUp ?? 0),
overdueCompanyResponses: Number(counts?.overdueCompanyResponses ?? 0), findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0),
companyResponsesDueNext7Days: Number(counts?.companyResponsesDueNext7Days ?? 0),
awaitingVerificationSchedule: Number(counts?.awaitingVerificationSchedule ?? 0),
overdueControls: Number(counts?.overdueControls ?? 0), overdueControls: Number(counts?.overdueControls ?? 0),
controlsNext30Days: Number(counts?.controlsNext30Days ?? 0), controlsNext30Days: Number(counts?.controlsNext30Days ?? 0),
reportsWorking: Number(counts?.reportsWorking ?? 0),
reportsOfficialized: Number(counts?.reportsOfficialized ?? 0),
sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0),
}, },
recentAudit, recentInspectorActivity,
upcomingControls, upcomingControls,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
}; };
@@ -5,6 +5,7 @@ export enum AssetVersionChangeType {
CREATED = 'CREATED', CREATED = 'CREATED',
UPDATED = 'UPDATED', UPDATED = 'UPDATED',
CONTEXT_CHANGED = 'CONTEXT_CHANGED', CONTEXT_CHANGED = 'CONTEXT_CHANGED',
FUNCTION_CHANGED = 'FUNCTION_CHANGED',
STATUS_CHANGED = 'STATUS_CHANGED', STATUS_CHANGED = 'STATUS_CHANGED',
OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED', OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED',
REGISTRY_UPDATED = 'REGISTRY_UPDATED', REGISTRY_UPDATED = 'REGISTRY_UPDATED',
@@ -33,6 +33,7 @@ export enum AssetDataOrigin {
@Index('idx_assets_parent_id', ['parentId']) @Index('idx_assets_parent_id', ['parentId'])
@Index('idx_assets_operational_area_id', ['operationalAreaId']) @Index('idx_assets_operational_area_id', ['operationalAreaId'])
@Index('idx_assets_operator_company_id', ['operatorCompanyId']) @Index('idx_assets_operator_company_id', ['operatorCompanyId'])
@Index('idx_assets_concession_type_id', ['concessionTypeId'])
@Index('idx_assets_inventory_family_id', ['inventoryFamilyId']) @Index('idx_assets_inventory_family_id', ['inventoryFamilyId'])
@Index('idx_assets_information_status', ['informationStatus']) @Index('idx_assets_information_status', ['informationStatus'])
@Index('idx_assets_operational_status', ['operationalStatus']) @Index('idx_assets_operational_status', ['operationalStatus'])
@@ -54,9 +55,15 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true }) @Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
operatorCompanyId!: string | null; operatorCompanyId!: string | null;
@Column({ name: 'concession_type_id', type: 'uuid', nullable: true })
concessionTypeId!: string | null;
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true }) @Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
inventoryFamilyId!: string | null; inventoryFamilyId!: string | null;
@Column({ name: 'is_inventory_instance', type: 'boolean', default: false })
isInventoryInstance!: boolean;
@Column({ type: 'varchar', length: 120 }) @Column({ type: 'varchar', length: 120 })
code!: string; code!: string;
@@ -35,6 +35,9 @@ export enum AuditAction {
ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED', ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED',
ASSET_UPDATED = 'ASSET_UPDATED', ASSET_UPDATED = 'ASSET_UPDATED',
ASSET_CONTEXT_CHANGED = 'ASSET_CONTEXT_CHANGED', 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_INFORMATION_STATUS_CHANGED = 'ASSET_INFORMATION_STATUS_CHANGED',
ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED', ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED',
ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED', ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED',
@@ -82,15 +85,24 @@ export enum AuditAction {
INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED', INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED',
INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED', INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED',
INSPECTION_ACT_RESPONSIBLE_UPDATED = 'INSPECTION_ACT_RESPONSIBLE_UPDATED', 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_READY = 'INSPECTION_ACT_READY',
INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED', INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED',
INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED', INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED',
INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED', INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED',
INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED', INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED',
INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED', INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED',
INSPECTION_REPORT_UPDATED = 'INSPECTION_REPORT_UPDATED',
INSPECTION_REPORT_REVISION_ADDED = 'INSPECTION_REPORT_REVISION_ADDED', 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_APPROVED = 'INSPECTION_REPORT_APPROVED',
INSPECTION_REPORT_SIGNED = 'INSPECTION_REPORT_SIGNED', 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_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED',
DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED', DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT', 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 { UserRole } from './user-role.entity';
export { User, UserStatus } from './user.entity'; export { User, UserStatus } from './user.entity';
export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity'; export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity';
export { export { AssetVersion, AssetVersionChangeType } from './asset-version.entity';
AssetVersion, export { AssetAttributeDataType, AssetAttributeDefinition } from './asset-attribute-definition.entity';
AssetVersionChangeType,
} from './asset-version.entity';
export {
AssetAttributeDataType,
AssetAttributeDefinition,
} from './asset-attribute-definition.entity';
export { AssetAttributeValue } from './asset-attribute-value.entity'; export { AssetAttributeValue } from './asset-attribute-value.entity';
export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity'; export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity';
export { OrganizationProfile, OrganizationKind } from './organization-profile.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 { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity';
export { AssetTypeParentRule } from './asset-type-parent-rule.entity'; export { AssetTypeParentRule } from './asset-type-parent-rule.entity';
export { AssetType, AssetTypeOperationalRole } from './asset-type.entity'; export { AssetType, AssetTypeOperationalRole } from './asset-type.entity';
export { export { AssetGeometry, AssetGeometrySource, AssetGeometryType } from './asset-geometry.entity';
AssetGeometry, export { AssetMedia, AssetMediaKind, AssetMediaSource } from './asset-media.entity';
AssetGeometrySource, export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entity';
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 { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity'; export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
export { InspectionVisitMember } from './inspection-visit-member.entity'; export { InspectionVisitMember } from './inspection-visit-member.entity';
export { export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity';
DocumentAnnualSequence,
DocumentSequenceType,
} from './document-annual-sequence.entity';
export { export {
InspectionAct, InspectionAct,
InspectionActStatus, InspectionActStatus,
InspectionActUrgency,
InspectionDeadlineBasis,
InspectionDeadlineDayType,
} from './inspection-act.entity'; } from './inspection-act.entity';
export { InspectionActAsset } from './inspection-act-asset.entity'; export { InspectionActAsset } from './inspection-act-asset.entity';
export { export {
InspectionReport, InspectionReport,
InspectionReportPdfStatus, InspectionReportPdfStatus,
InspectionReportReviewStatus,
InspectionReportStatus, InspectionReportStatus,
InspectionReportWordStatus, InspectionReportWordStatus,
} from './inspection-report.entity'; } from './inspection-report.entity';
export { export { InspectionActVersion, InspectionActVersionEvent } from './inspection-act-version.entity';
InspectionActVersion,
InspectionActVersionEvent,
} from './inspection-act-version.entity';
export { export {
InspectionActResponsible, InspectionActResponsible,
InspectionResponsibleAttendanceStatus, InspectionResponsibleAttendanceStatus,
InspectionResponsibleDocumentType, InspectionResponsibleDocumentType,
} from './inspection-act-responsible.entity'; } from './inspection-act-responsible.entity';
export { export { InspectionActClosure, InspectionActUploadMode } from './inspection-act-closure.entity';
InspectionActClosure,
InspectionActUploadMode,
} from './inspection-act-closure.entity';
export { export {
InspectionActSignature, InspectionActSignature,
InspectionActSignatureSource, InspectionActSignatureSource,
@@ -95,21 +53,18 @@ export {
InspectionActSignerType, InspectionActSignerType,
InspectionCompanySignatureManifestation, InspectionCompanySignatureManifestation,
} from './inspection-act-signature.entity'; } 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 { FindingCategory } from './finding-category.entity';
export { FindingCatalogItem } from './finding-catalog-item.entity'; export { FindingCatalogItem } from './finding-catalog-item.entity';
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity'; export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity'; export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity'; export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity'; export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
export { export { InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus } from './inspection-finding.entity';
InspectionFinding, export { InspectionFindingVersion, InspectionFindingVersionEvent } from './inspection-finding-version.entity';
InspectionFindingResponseDueBasis,
InspectionFindingStatus,
} from './inspection-finding.entity';
export {
InspectionFindingVersion,
InspectionFindingVersionEvent,
} from './inspection-finding-version.entity';
export { export {
InspectionCommunicationChannel, InspectionCommunicationChannel,
InspectionCommunicationDirection, InspectionCommunicationDirection,
@@ -122,14 +77,8 @@ export {
InspectionEvidenceSource, InspectionEvidenceSource,
InspectionFindingEvidence, InspectionFindingEvidence,
} from './inspection-finding-evidence.entity'; } from './inspection-finding-evidence.entity';
export { export { InspectionFindingVerificationVisit, InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity';
InspectionFindingVerificationVisit, export { InspectionFindingVerificationEvent, InspectionFindingVerificationEventType } from './inspection-finding-verification-event.entity';
InspectionVerificationOutcome,
} from './inspection-finding-verification-visit.entity';
export {
InspectionFindingVerificationEvent,
InspectionFindingVerificationEventType,
} from './inspection-finding-verification-event.entity';
import { AuditEvent } from './audit-event.entity'; import { AuditEvent } from './audit-event.entity';
import { AuthSession } from './auth-session.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 { AssetGeometry } from './asset-geometry.entity';
import { AssetVersion } from './asset-version.entity'; import { AssetVersion } from './asset-version.entity';
import { AssetMedia } from './asset-media.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 { InspectionVisit } from './inspection-visit.entity';
import { InspectionVisitAsset } from './inspection-visit-asset.entity'; import { InspectionVisitAsset } from './inspection-visit-asset.entity';
import { InspectionVisitMember } from './inspection-visit-member.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 { InspectionActResponsible } from './inspection-act-responsible.entity';
import { InspectionActClosure } from './inspection-act-closure.entity'; import { InspectionActClosure } from './inspection-act-closure.entity';
import { InspectionActSignature } from './inspection-act-signature.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 { FindingCategory } from './finding-category.entity';
import { FindingCatalogItem } from './finding-catalog-item.entity'; import { FindingCatalogItem } from './finding-catalog-item.entity';
import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity'; import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
@@ -207,11 +155,6 @@ export const PHASE_A_ENTITIES = [
AssetGeometry, AssetGeometry,
AssetVersion, AssetVersion,
AssetMedia, AssetMedia,
SurveyCampaign,
SurveyCampaignTarget,
SurveyTargetReport,
SurveyTargetReportMedia,
SurveyTargetReportVersion,
InspectionVisit, InspectionVisit,
InspectionVisitAsset, InspectionVisitAsset,
InspectionVisitMember, InspectionVisitMember,
@@ -223,6 +166,10 @@ export const PHASE_A_ENTITIES = [
InspectionActResponsible, InspectionActResponsible,
InspectionActClosure, InspectionActClosure,
InspectionActSignature, InspectionActSignature,
InspectionDeadlinePolicy,
InspectionBusinessCalendarDay,
InspectionReportFollowUp,
SystemSmtpSettings,
FindingCategory, FindingCategory,
FindingCatalogItem, FindingCatalogItem,
FindingCatalogItemAssetType, FindingCatalogItemAssetType,
@@ -3,6 +3,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum InspectionActVersionEvent { export enum InspectionActVersionEvent {
CREATED = 'CREATED', CREATED = 'CREATED',
UPDATED = 'UPDATED', UPDATED = 'UPDATED',
LOCKED = 'LOCKED',
SEALED = 'SEALED',
READY = 'READY', READY = 'READY',
REOPENED = 'REOPENED', REOPENED = 'REOPENED',
CLOSED = 'CLOSED', CLOSED = 'CLOSED',
@@ -3,12 +3,35 @@ import { TimestampedEntity } from './timestamped.entity';
export enum InspectionActStatus { export enum InspectionActStatus {
DRAFT = 'DRAFT', DRAFT = 'DRAFT',
LOCKED = 'LOCKED',
SEALED = 'SEALED',
READY = 'READY', READY = 'READY',
CLOSED = 'CLOSED', CLOSED = 'CLOSED',
CANCELLED = 'CANCELLED', CANCELLED = 'CANCELLED',
RECTIFIED = 'RECTIFIED', 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' }) @Entity({ name: 'inspection_acts' })
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" }) @Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true }) @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_visit_status', ['visitId', 'status'])
@Index('idx_inspection_acts_occurred_at', ['occurredAt']) @Index('idx_inspection_acts_occurred_at', ['occurredAt'])
@Index('idx_inspection_acts_created_by', ['createdBy']) @Index('idx_inspection_acts_created_by', ['createdBy'])
@Index('idx_inspection_acts_deadline', ['deadlineAt'])
export class InspectionAct extends TimestampedEntity { export class InspectionAct extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@@ -29,7 +53,7 @@ export class InspectionAct extends TimestampedEntity {
@Column({ name: 'act_number', type: 'integer' }) @Column({ name: 'act_number', type: 'integer' })
actNumber!: number; actNumber!: number;
@Column({ type: 'varchar', length: 24 }) @Column({ type: 'varchar', length: 40 })
code!: string; code!: string;
@Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT }) @Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT })
@@ -47,6 +71,39 @@ export class InspectionAct extends TimestampedEntity {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
observations!: string | null; observations!: string | null;
@Column({ name: 'urgency', type: 'varchar', length: 24, nullable: true })
urgency!: InspectionActUrgency | null;
@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 }) @Column({ name: 'current_version', type: 'integer', default: 0 })
currentVersion!: number; 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 { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity'; import { TimestampedEntity } from './timestamped.entity';
/** @deprecated Los plazos de respuesta pasan al nivel Acta/Informe en F4. */
export enum InspectionFindingResponseDueBasis { export enum InspectionFindingResponseDueBasis {
FINDING_DATE = 'FINDING_DATE', FINDING_DATE = 'FINDING_DATE',
REPORT_NOTIFICATION = 'REPORT_NOTIFICATION', REPORT_NOTIFICATION = 'REPORT_NOTIFICATION',
@@ -18,6 +19,7 @@ export enum InspectionFindingStatus {
@Index('idx_inspection_findings_status_control', ['status', 'nextControlOn']) @Index('idx_inspection_findings_status_control', ['status', 'nextControlOn'])
@Index('idx_inspection_findings_asset_status', ['assetId', 'status']) @Index('idx_inspection_findings_asset_status', ['assetId', 'status'])
@Index('idx_inspection_findings_catalog_item', ['catalogItemId']) @Index('idx_inspection_findings_catalog_item', ['catalogItemId'])
@Index('idx_inspection_findings_recurrence_of', ['recurrenceOfFindingId'])
export class InspectionFinding extends TimestampedEntity { export class InspectionFinding extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@@ -61,9 +63,16 @@ export class InspectionFinding extends TimestampedEntity {
@Column({ type: 'smallint', nullable: true }) @Column({ type: 'smallint', nullable: true })
severity!: number | null; 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 }) @Column({ name: 'correction_due_on', type: 'date', nullable: true })
correctionDueOn!: string | null; correctionDueOn!: string | null;
// Campos legacy conservados temporalmente para migrar datos sin pérdida.
@Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true }) @Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true })
responseDueBasis!: InspectionFindingResponseDueBasis | null; 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'; import { TimestampedEntity } from './timestamped.entity';
export enum InspectionReportStatus { export enum InspectionReportStatus {
WORKING = 'WORKING',
OFFICIALIZED = 'OFFICIALIZED',
FROZEN = 'FROZEN', FROZEN = 'FROZEN',
CANCELLED = 'CANCELLED', CANCELLED = 'CANCELLED',
} }
@@ -18,12 +20,6 @@ export enum InspectionReportWordStatus {
FAILED = 'FAILED', FAILED = 'FAILED',
} }
export enum InspectionReportReviewStatus {
PENDING_REVIEW = 'PENDING_REVIEW',
APPROVED = 'APPROVED',
SIGNED = 'SIGNED',
}
@Entity({ name: 'inspection_reports' }) @Entity({ name: 'inspection_reports' })
@Index('idx_inspection_reports_visit_id', ['visitId']) @Index('idx_inspection_reports_visit_id', ['visitId'])
@Index('uq_inspection_reports_act', ['actId'], { unique: true }) @Index('uq_inspection_reports_act', ['actId'], { unique: true })
@@ -31,6 +27,7 @@ export enum InspectionReportReviewStatus {
@Index('uq_inspection_reports_code', ['code'], { unique: true }) @Index('uq_inspection_reports_code', ['code'], { unique: true })
@Index('idx_inspection_reports_generated_at', ['generatedAt']) @Index('idx_inspection_reports_generated_at', ['generatedAt'])
@Index('idx_inspection_reports_status', ['status', 'pdfStatus']) @Index('idx_inspection_reports_status', ['status', 'pdfStatus'])
@Index('idx_inspection_reports_gedo_officialized_at', ['gedoOfficializedAt'])
export class InspectionReport extends TimestampedEntity { export class InspectionReport extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@@ -47,12 +44,39 @@ export class InspectionReport extends TimestampedEntity {
@Column({ name: 'report_number', type: 'integer' }) @Column({ name: 'report_number', type: 'integer' })
reportNumber!: number; reportNumber!: number;
@Column({ type: 'varchar', length: 24 }) @Column({ type: 'varchar', length: 40 })
code!: string; code!: string;
@Column({ type: 'varchar', length: 24, default: InspectionReportStatus.FROZEN }) @Column({ type: 'varchar', length: 24, default: InspectionReportStatus.WORKING })
status!: InspectionReportStatus; 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 }) @Column({ name: 'pdf_status', type: 'varchar', length: 24, default: InspectionReportPdfStatus.PENDING })
pdfStatus!: InspectionReportPdfStatus; pdfStatus!: InspectionReportPdfStatus;
@@ -80,27 +104,9 @@ export class InspectionReport extends TimestampedEntity {
@Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true }) @Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true })
wordError!: string | null; 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 }) @Column({ name: 'current_revision_number', type: 'integer', default: 0 })
currentRevisionNumber!: number; 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 }) @Column({ type: 'varchar', length: 220 })
title!: string; title!: string;
@@ -14,7 +14,7 @@ export enum InspectionVisitStatus {
@Index('idx_inspection_visits_status', ['status']) @Index('idx_inspection_visits_status', ['status'])
@Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId']) @Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId'])
@Index('idx_inspection_visits_lead_inspector_user_id', ['leadInspectorUserId']) @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']) @Index('idx_inspection_visits_operational_context', ['operationalAreaId', 'operatorCompanyId'])
export class InspectionVisit extends TimestampedEntity { export class InspectionVisit extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@@ -23,9 +23,6 @@ export class InspectionVisit extends TimestampedEntity {
@Column({ type: 'varchar', length: 80 }) @Column({ type: 'varchar', length: 80 })
code!: string; code!: string;
@Column({ type: 'varchar', length: 200 })
title!: string;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
objective!: string | null; objective!: string | null;
@@ -47,9 +44,6 @@ export class InspectionVisit extends TimestampedEntity {
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true }) @Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
plannedStartAt!: Date | null; plannedStartAt!: Date | null;
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
plannedEndAt!: Date | null;
@Column({ name: 'actual_started_at', type: 'timestamptz', nullable: true }) @Column({ name: 'actual_started_at', type: 'timestamptz', nullable: true })
actualStartedAt!: Date | null; 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;
}
@@ -0,0 +1,161 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4DocumentWorkflowFoundation1790000000000 implements MigrationInterface {
name = 'F4DocumentWorkflowFoundation1790000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE inspection_acts ALTER COLUMN code TYPE varchar(40)`);
await queryRunner.query(`ALTER TABLE inspection_reports ALTER COLUMN code TYPE varchar(40)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS urgency varchar(24) NOT NULL DEFAULT 'NON_URGENT'`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_days integer`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_day_type varchar(24)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_basis varchar(24)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_base_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_by uuid`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_sha256 char(64)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_by uuid`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_acts_deadline ON inspection_acts(deadline_at)`);
await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS is_recurrence boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS recurrence_of_finding_id uuid`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_findings_recurrence_of ON inspection_findings(recurrence_of_finding_id)`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE inspection_findings
ADD CONSTRAINT fk_inspection_findings_recurrence_of
FOREIGN KEY (recurrence_of_finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS executive_summary text`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS report_description text`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_if_identifier varchar(255)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_officialized_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_original_name varchar(255)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_stored_name varchar(255)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_mime_type varchar(120)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_size_bytes integer`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_sha256 char(64)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_reports_gedo_officialized_at ON inspection_reports(gedo_officialized_at)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inspection_deadline_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
urgent_days integer NOT NULL DEFAULT 5 CHECK (urgent_days > 0),
urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS',
non_urgent_days integer NOT NULL DEFAULT 10 CHECK (non_urgent_days > 0),
non_urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS',
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_inspection_deadline_policy_urgent_type CHECK (urgent_day_type IN ('BUSINESS','CALENDAR')),
CONSTRAINT ck_inspection_deadline_policy_non_urgent_type CHECK (non_urgent_day_type IN ('BUSINESS','CALENDAR'))
)
`);
await queryRunner.query(`
INSERT INTO inspection_deadline_policies (urgent_days, urgent_day_type, non_urgent_days, non_urgent_day_type)
SELECT 5, 'BUSINESS', 10, 'BUSINESS'
WHERE NOT EXISTS (SELECT 1 FROM inspection_deadline_policies)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inspection_business_calendar_days (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
date date NOT NULL,
is_business_day boolean NOT NULL DEFAULT false,
label varchar(200) NOT NULL,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_inspection_business_calendar_day UNIQUE(date)
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inspection_report_follow_ups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
report_id uuid NOT NULL,
type varchar(40) NOT NULL,
external_reference varchar(255),
occurred_at timestamptz NOT NULL,
description text,
original_name varchar(255),
stored_name varchar(255),
mime_type varchar(120),
size_bytes integer,
sha256 char(64),
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT fk_inspection_report_follow_ups_report FOREIGN KEY(report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE,
CONSTRAINT ck_inspection_report_follow_up_type CHECK (type IN ('COMPANY_NOTE','COMPANY_DOCUMENT','INTERNAL_NOTE','VERIFICATION','OTHER'))
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_report_follow_ups_report_created ON inspection_report_follow_ups(report_id, created_at)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS system_smtp_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
host varchar(255) NOT NULL,
port integer NOT NULL CHECK (port > 0 AND port <= 65535),
security_mode varchar(24) NOT NULL DEFAULT 'STARTTLS',
username varchar(255),
password_enc text,
from_name varchar(200) NOT NULL,
from_email varchar(320) NOT NULL,
reply_to varchar(320),
enabled boolean NOT NULL DEFAULT true,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_system_smtp_security_mode CHECK (security_mode IN ('NONE','STARTTLS','TLS'))
)
`);
await queryRunner.query(`
UPDATE inspection_acts
SET
deadline_days = COALESCE(deadline_days, 10),
deadline_day_type = COALESCE(deadline_day_type, 'BUSINESS'),
deadline_basis = COALESCE(deadline_basis, 'GEDO_DATE')
WHERE deadline_days IS NULL OR deadline_day_type IS NULL OR deadline_basis IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS system_smtp_settings`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_ups`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_business_calendar_days`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_deadline_policies`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_sha256`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_size_bytes`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_mime_type`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_stored_name`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_original_name`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_officialized_at`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_if_identifier`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS report_description`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS executive_summary`);
await queryRunner.query(`ALTER TABLE inspection_findings DROP CONSTRAINT IF EXISTS fk_inspection_findings_recurrence_of`);
await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS recurrence_of_finding_id`);
await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS is_recurrence`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_by`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_sha256`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_by`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_base_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_basis`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_day_type`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_days`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS urgency`);
}
}
@@ -0,0 +1,80 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4InventoryFunctionHistory1790000100000 implements MigrationInterface {
name = 'F4InventoryFunctionHistory1790000100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inventory_functions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(120) NOT NULL UNIQUE,
name varchar(240) NOT NULL,
description text,
is_active boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inventory_functions_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_inventory_functions_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_inventory_functions_active_sort
ON inventory_functions(is_active,sort_order,name)
`);
await queryRunner.query(`
CREATE TABLE inventory_function_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL,
function_id uuid NOT NULL,
valid_from timestamptz NOT NULL,
valid_until timestamptz,
change_reason text,
changed_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inventory_function_assignment_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE CASCADE,
CONSTRAINT fk_inventory_function_assignment_function FOREIGN KEY (function_id)
REFERENCES inventory_functions(id) ON DELETE RESTRICT,
CONSTRAINT fk_inventory_function_assignment_user FOREIGN KEY (changed_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_inventory_function_assignment_dates CHECK (
valid_until IS NULL OR valid_until > valid_from
)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_inventory_function_assignment_current
ON inventory_function_assignments(asset_id)
WHERE valid_until IS NULL
`);
await queryRunner.query(`
CREATE INDEX idx_inventory_function_assignment_history
ON inventory_function_assignments(asset_id,valid_from DESC,created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_inventory_function_assignment_function
ON inventory_function_assignments(function_id,valid_from DESC)
`);
await queryRunner.query(`
INSERT INTO inventory_functions(code,name,description,sort_order)
VALUES
('BOMBEO_MECANICO_AIB','Bombeo mecánico AIB',
'Función técnica de bombeo mecánico mediante aparato individual de bombeo (AIB).',10),
('BOMBEO_MECANICO_ROTAFLEX','Bombeo mecánico Rotaflex',
'Función técnica de bombeo mecánico mediante sistema Rotaflex.',20)
ON CONFLICT (code) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE IF EXISTS inventory_function_assignments');
await queryRunner.query('DROP TABLE IF EXISTS inventory_functions');
}
}
@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4CompanySignatureInvites1790000200000 implements MigrationInterface {
name = 'F4CompanySignatureInvites1790000200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inspection_act_company_signature_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
act_id uuid NOT NULL,
token_sha256 char(64) NOT NULL UNIQUE,
recipient_email varchar(320) NOT NULL,
recipient_name varchar(200),
recipient_document_type varchar(20),
recipient_document_number varchar(40),
recipient_position varchar(200),
status varchar(20) NOT NULL DEFAULT 'PENDING',
expires_at timestamptz NOT NULL,
sent_at timestamptz,
used_at timestamptz,
revoked_at timestamptz,
created_by uuid NOT NULL,
revoked_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_company_signature_invite_act FOREIGN KEY (act_id)
REFERENCES inspection_acts(id) ON DELETE CASCADE,
CONSTRAINT fk_company_signature_invite_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT fk_company_signature_invite_revoked_by FOREIGN KEY (revoked_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_company_signature_invite_status CHECK (
status IN ('PENDING','USED','REVOKED','EXPIRED')
)
)
`);
await queryRunner.query(`
CREATE INDEX idx_company_signature_invites_act_created
ON inspection_act_company_signature_invites(act_id,created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_company_signature_invites_pending_expiry
ON inspection_act_company_signature_invites(status,expires_at)
WHERE status='PENDING'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE IF EXISTS inspection_act_company_signature_invites');
}
}

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