Compare commits

...
Author SHA1 Message Date
ChatGPT DH 1beba14c3b feat(web): make active tables sortable
DH V2 CI / API · typecheck, tests, build (push) Successful in 53s
Production dependency audit / API · production dependencies (push) Successful in 19s
Production dependency audit / WEB · production dependencies (push) Successful in 16s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m34s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m49s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 7m22s
2026-09-16 19:20:41 -03:00
ChatGPT DH fa6ba7f31e fix(web): render operational map as GPS points
DH V2 CI / API · typecheck, tests, build (push) Successful in 40s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m38s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m55s
DH V2 CI / Promote verified main to deploy (push) Successful in 4s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m24s
2026-09-16 08:55:54 -03:00
admin a414d0ed36 fix(web): clarify act context and operational map
DH V2 CI / API · typecheck, tests, build (push) Successful in 36s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m37s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m52s
DH V2 CI / Promote verified main to deploy (push) Successful in 4s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m11s
2026-09-16 08:29:19 -03:00
admin 0b731b722f docs(android): document F6.14 mobile scope
Production dependency audit / API · production dependencies (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m29s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m15s
DH V2 CI / Promote verified main to deploy (push) Successful in 6s
DH V2 CI / API · typecheck, tests, build (push) Successful in 44s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 6m2s
2026-09-16 07:46:09 -03:00
admin 678b0f1792 ci(android): retry F6.14 artifact upload
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
DH V2 CI / API · typecheck, tests, build (push) Successful in 57s
DH V2 CI / Docker / migrations / production images (push) Skipped
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 07:43:56 -03:00
admin c71afab9af fix(android): scope mobile inspections to yacimiento
DH V2 CI / API · typecheck, tests, build (push) Successful in 5m16s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 6m13s
DH V2 CI / WEB · typecheck, build (push) Successful in 4m52s
DH V2 CI / Docker / migrations / production images (push) Skipped
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 07:33:03 -03:00
admin 29208f4e1f release(android): prepare client demo 0.19.12
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / API · typecheck, tests, build (push) Successful in 36s
Production dependency audit / API · production dependencies (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m33s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m3s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m5s
2026-09-16 00:42:29 -03:00
admin 4523aef932 fix(map): serve MapLibre module worker as JavaScript
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
Production dependency audit / API · production dependencies (push) Successful in 49s
DH V2 CI / Docker / migrations / production images (push) Successful in 50s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m9s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
2026-09-16 00:32:03 -03:00
admin 011b3e7fcb fix(web): include map worker build script in image
Production dependency audit / API · production dependencies (push) Successful in 48s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m10s
DH V2 CI / Docker / migrations / production images (push) Successful in 59s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
2026-09-16 00:23:57 -03:00
admin 9b7d67bea2 test(web): align F6.13 presentation phase
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
Production dependency audit / API · production dependencies (push) Successful in 49s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m11s
DH V2 CI / Docker / migrations / production images (push) Failing after 1m1s
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 00:18:53 -03:00
admin f0b92c00e1 fix(map): publish MapLibre worker
DH V2 CI / WEB · typecheck, build (push) Successful in 21s
DH V2 CI / API · typecheck, tests, build (push) Failing after 46s
DH V2 CI / Docker / migrations / production images (push) Skipped
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 00:14:17 -03:00
admin cab9cd7c01 fix: restore map geometries and SMTP configuration
DH V2 CI / API · typecheck, tests, build (push) Successful in 38s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m36s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m45s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m5s
2026-09-15 23:58:49 -03:00
admin c9575cc520 feat(informes): manage manual GEDO responses and shared due date
DH V2 CI / WEB · typecheck, build (push) Successful in 27s
Production dependency audit / API · production dependencies (push) Successful in 15s
Production dependency audit / WEB · production dependencies (push) Successful in 15s
DH V2 CI / API · typecheck, tests, build (push) Successful in 2m3s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m58s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m55s
2026-09-15 23:35:49 -03:00
admin 69aeb52040 feat(actas): render complete self-contained PDF
DH V2 CI / API · typecheck, tests, build (push) Successful in 43s
Production dependency audit / API · production dependencies (push) Successful in 15s
Production dependency audit / WEB · production dependencies (push) Successful in 14s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m36s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m45s
DH V2 CI / Promote verified main to deploy (push) Successful in 6s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m59s
2026-09-15 23:09:16 -03:00
admin 8d5ffc86f9 ci: trust Gitea gate during VPS deploy
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
Production dependency audit / API · production dependencies (push) Successful in 9s
DH V2 CI / Docker / migrations / production images (push) Successful in 49s
2026-09-15 21:47:41 -03:00
admin 8266bd8669 ci: use Gitea Actions as delivery gate
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 9s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m23s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
2026-09-15 21:38:30 -03:00
Maximo 510a5fbca3 fix(release): align F6.9 visible versions 2026-09-15 21:14:25 -03:00
Maximo 215f443f71 fix(actas): keep sealed act content findings-only 2026-09-15 20:59:59 -03:00
DH V2 Dev ff297c8d93 test(f6.9): align Android contracts with narrative and version 0.19.11
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
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 50s
2026-09-15 19:55:05 -03:00
DH V2 c78c2b8029 F6.9 capture act narrative before seal in offline Android flow
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m11s
DH V2 CI / API · typecheck, tests, build (push) Failing after 25s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
DH V2 CI / Docker / scripts contract (push) Skipped
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
2026-09-15 19:44:02 -03:00
DH V2 60ba37c287 fix(f6.9): include every field photo and preserve document revisions
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m8s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 59s
2026-09-15 19:18:31 -03:00
DH V2 669c0d2656 fix(f6.9): load PDF and image converters in production runtime
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m16s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
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 50s
2026-09-15 19:00:12 -03:00
DH V2 5cf99442a8 feat(f6.9): consolidate act and report documents and simplify follow-up
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m24s
DH V2 CI / API · typecheck, tests, build (push) Successful in 40s
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 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m22s
2026-09-15 18:48:12 -03:00
DH V2 079728aa6d feat(f6.8): harden offline field flow and act documents
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m41s
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 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m14s
2026-09-15 15:56:50 -03:00
DH V2 47cd985931 test(android): align F6.7 field flow contract
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 2m49s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
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 48s
2026-09-15 11:12:51 -03:00
DH V2 da7c1ddb55 fix(actas): move closure to reusable inspector signing
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 2m3s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m18s
2026-09-15 08:55:43 -03:00
DH V2 a2ec846721 fix(mobile): stabilize finding creation and narrow-screen actions
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m3s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
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 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m3s
2026-09-15 00:10:18 -03:00
DH V2 9eef156197 fix(field): repair finding creation and simplify mobile UX
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m17s
DH V2 CI / API · typecheck, tests, build (push) Successful in 35s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m7s
2026-09-14 23:24:41 -03:00
DH V2 08f164a209 fix(web): package Mendoza favicon in production image
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / Docker / scripts contract (push) Successful in 1m2s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
2026-09-14 19:36:36 -03:00
DH V2 23450bb6d0 fix(field): repair installation creation and streamline mobile form
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m4s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m0s
Production dependency audit / API · production dependencies (push) Successful in 9s
2026-09-14 19:25:20 -03:00
admin 20ccba5292 Android 0.19.5: tipo de instalación y alta corregida (#2)
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m15s
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 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 51s
Unifica Tipo de instalación en un desplegable con buscador y corrige el payload de atributos técnicos para usar IDs de definición. Android CI: 26 pruebas, lint y compilaciones debug/release aprobadas. CI general y auditoría de dependencias aprobadas.
2026-09-14 18:48:26 -03:00
admin c0a5367920 test(android): align 0.19.5 version code
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 2m57s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 31s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 49s
Production dependency audit / API · production dependencies (pull_request) Successful in 8s
2026-09-14 18:42:46 -03:00
admin ff30462259 fix(android): send inventory attributes by definition id
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Failing after 2m13s
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
DH V2 CI / API · typecheck, tests, build (pull_request) Canceled after 29s
2026-09-14 18:40:06 -03:00
admin 975f9ee13e fix(android): match dropdown anchor to installed Material version
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 3m5s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 36s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 24s
Production dependency audit / API · production dependencies (pull_request) Successful in 16s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 15s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 1m45s
2026-09-14 18:02:11 -03:00
admin 39dcd6bd4a fix(ci): skip unavailable legacy Android tools package
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Failing after 1m4s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 37s
DH V2 CI / Docker / scripts contract (pull_request) Canceled after 0s
Production dependency audit / API · production dependencies (pull_request) Canceled after 0s
DH V2 CI / WEB · typecheck, build (pull_request) Canceled after 22s
Production dependency audit / WEB · production dependencies (pull_request) Canceled after 0s
2026-09-14 17:59:57 -03:00
admin ffeae96530 fix(android): unify installation type in searchable dropdown
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Failing after 22s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 36s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 22s
Production dependency audit / API · production dependencies (pull_request) Successful in 15s
DH V2 CI / Docker / scripts contract (pull_request) Canceled after 0s
Production dependency audit / WEB · production dependencies (pull_request) Canceled after 14s
2026-09-14 17:58:16 -03:00
admin 9b12144052 test(android): align final field contract with f6.2
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m23s
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 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 50s
2026-09-14 16:07:53 -03:00
admin becf9fc050 fix(android): use valid email regex in act representative flow
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 2m38s
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 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 49s
2026-09-14 16:01:49 -03:00
admin 7fe59bccd2 feat(f6.2): add per-act representative signing and user smtp
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 1m25s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m16s
2026-09-14 15:53:55 -03:00
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
198 changed files with 8187 additions and 1874 deletions
+4 -2
View File
@@ -1,5 +1,5 @@
name: Android CI / RC name: Android CI / RC
# F6.1 presentation barrier: lint + real tests + debug artifact + release compile. # F6.14 yacimiento-scope barrier: lint + real tests + debug artifact + release compile.
on: on:
push: push:
@@ -43,6 +43,8 @@ jobs:
- name: Android SDK - name: Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with:
packages: platform-tools
- name: Android API 36 - name: Android API 36
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0' run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
@@ -109,7 +111,7 @@ jobs:
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk" cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256" sha256sum "$apk" > "${apk}.sha256"
{ {
echo "phase=F6.1" echo "phase=F6.14"
echo "version=$version" echo "version=$version"
echo "versionCode=$code" echo "versionCode=$code"
echo "commit=$GITHUB_SHA" echo "commit=$GITHUB_SHA"
+28 -2
View File
@@ -4,7 +4,13 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
push: push:
branches: [main] branches:
- main
- 'feature/**'
- 'fix/**'
- 'chore/**'
- 'release/**'
workflow_dispatch:
permissions: permissions:
contents: read contents: read
@@ -52,9 +58,10 @@ jobs:
- run: npm run build - run: npm run build
contract: contract:
name: Docker / scripts contract name: Docker / migrations / production images
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [api, web] needs: [api, web]
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Validate shell scripts - name: Validate shell scripts
@@ -347,3 +354,22 @@ jobs:
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
run: docker compose --env-file .env.example build api migrate web run: docker compose --env-file .env.example build api migrate web
promote-deploy:
name: Promote verified main to deploy
runs-on: ubuntu-latest
needs: [api, web, contract]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fast-forward deploy to the verified commit
run: |
set -Eeuo pipefail
git fetch origin deploy main
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
git merge-base --is-ancestor origin/deploy "$GITHUB_SHA"
git push origin "$GITHUB_SHA:refs/heads/deploy"
+9 -4
View File
@@ -1,15 +1,20 @@
# Checklist final Android 0.19.1 # Checklist final Android 0.19.3
La candidata sólo se promueve si el mismo SHA cumple: La candidata sólo se promueve si el mismo SHA cumple:
- iniciar Inspección; - iniciar Inspección;
- crear/abrir Acta; - crear/abrir Acta sin pedir urgencia ni Inventario;
- seleccionar o crear Instalación/Subinstalación; - 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; - exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos;
- registrar Hallazgos sobre el elemento seleccionado; - registrar Hallazgos sobre el elemento seleccionado;
- cerrar contenido del Acta y mostrar **Pendiente de firma**; - al cerrar el contenido, exigir Urgente/No urgente, persistir esa decisión y mostrar **Pendiente de firma**;
- exigir por cada Acta los datos del **Representante de la empresa**: nombres y apellidos, DNI, cargo/función y email;
- permitir precargar esos datos desde el Acta anterior de la misma Inspección, pero exigir confirmación y firma nueva en cada Acta;
- resolver por Acta **firma en conformidad**, **firma en disconformidad con motivo** o **negativa a firmar con motivo**;
- verificar que la urgencia existe sólo a nivel **Acta** y nunca a nivel Hallazgo;
- completar firma del Inspector y manifestación/firma o negativa de la empresa; - completar firma del Inspector y manifestación/firma o negativa de la empresa;
- mostrar el Acta como **Firmada y cerrada**; - mostrar el Acta como **Firmada y cerrada**;
- permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas; - 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; - 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. - Android CI / RC, DH V2 CI y auditoría de dependencias en verde.
+64 -11
View File
@@ -1,29 +1,62 @@
# DH Inspección Android · release final de campo 0.19.1 # DH Inspección Android · release de campo 0.19.13
## Candidata vigente ## Candidata vigente
- `versionName`: **0.19.13**; `versionCode`: **41**.
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-14 / WEB 0.23.0-10.
- Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado.
- La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión.
- Un acta anterior, ya sellada, conserva el texto originalmente registrado.
## Referencia de 0.19.10
### Candidata anterior
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**. - Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.1**. - `versionName`: **0.19.10**.
- `versionCode`: **29**. - `versionCode`: **38**.
- Application ID release: `com.korexlabs.dhinspeccion`. - Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`. - API: `https://dhv2.korexlabs.com/api/v3/`.
- Servidor compatible de esta candidata: **API 0.29.0-8 / WEB 0.23.0-5**.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica. La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
### Ajustes 0.19.10
- **Trabajo offline seguro:** Room conserva operaciones pendientes y WorkManager las sincroniza cuando vuelve la red, respetando el orden en que fueron creadas.
- La cola offline queda separada por usuario autenticado para impedir que una sesión sincronice trabajo de otra persona en el mismo dispositivo.
- Inspecciones, Actas, Inventario, Hallazgos, fotos, representante, manifestación y cierre conservan una identidad de cliente estable para reintentos idempotentes.
- Las listas de trabajo, Inventario, Actas, cierres, Hallazgos y estado de firma usan caché local por usuario para poder consultar contexto ya descargado sin conectividad.
- Antes de incorporar una foto de Hallazgo se muestra **Revisar fotografía** con **Usar esta foto**, **Volver a tomar** y **Eliminar foto**.
- Corrige el cierre que enviaba `uploadMode=ONLINE`; el contrato vigente usa `IMMEDIATE` o `DEFERRED`.
- La APK consulta el estado real de la firma reutilizable del Inspector y no afirma que existe cuando todavía no fue configurada.
- El Dashboard de Actas prioriza el **PDF real del Acta** y muestra directamente las fotografías de Hallazgos y del Inventario relacionado.
- En toda la interfaz activa, la antigua expresión “clasificación técnica” se presenta como **Tipo de instalación** o **Tipo de subinstalación**.
- Corrige el cierre de Actas evitando `NaN` en el versionado de LOCKED/SEALED.
- El cierre pasa a **Para firmar**; la APK captura sólo la manifestación/firma del acompañante.
- La firma del Inspector se guarda una vez en Mi perfil y se copia de forma inmutable al sellar cada Acta.
- Elimina la tarjeta duplicada del Acta seleccionada y simplifica la jerarquía visual inspirada en la referencia Figma.
- El encabezado de **Mis inspecciones** separa las acciones para que **Salir** nunca se comprima ni se corte en pantallas angostas.
- El alta de Hallazgos corrige la captura de versión que podía transformar el número en `NaN` después del INSERT y devolver error 500.
- Un Hallazgo admite **múltiples fotografías con GPS**; después de cada captura queda disponible **Agregar otra foto con GPS** y se muestra el contador.
## Procedimiento operativo validado ## Procedimiento operativo validado
La APK 0.19.1 fija como recorrido principal de campo: La APK 0.19.10 fija como recorrido principal de campo:
1. **Iniciar Inspección**. Al iniciarla se habilitan Actas, Hallazgos e Inventario 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. 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** sobre una Instalación o Subinstalación seleccionada. 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. 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. 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. 6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
7. Al terminar el contenido del Acta se usa **Cerrar Acta y dejar pendiente de firma**. Desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**. 7. Antes del cierre se identifican, para esa Acta, los datos del **Representante de la empresa** que acompañó el recorrido: nombres y apellidos, DNI, cargo/función y email. Si coincide con el Acta anterior, la APK puede precargarlos, pero deben confirmarse nuevamente.
8. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**. 8. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar contenido y pasar a firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Para firmar**.
9. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas. 9. En **Para firmar**, el acompañante de la empresa registra su manifestación: **conformidad**, **disconformidad con motivo obligatorio**, **negativa a firmar con motivo obligatorio** o **ausencia documentada**. La firma del Inspector no se dibuja en la tablet: se guarda una sola vez desde **Mi perfil** del Dashboard y el servidor copia esa firma de forma inmutable a cada Acta al usar **Aplicar mi firma y cerrar Acta**.
10. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano. La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
@@ -49,6 +82,10 @@ Además de lo anterior requiere elegir primero la **Instalación padre**. El bus
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. 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.
## Alta de Hallazgos
El alta de Hallazgos usa un selector desplegable con búsqueda por nombre. La pantalla muestra sólo la información necesaria para decidir, mantiene **Otro / No está en la lista** como salida manual y evita exponer códigos o leyendas internas del catálogo. La fecha de corrección no se solicita al inspector al crear un Hallazgo; los plazos administrativos se gestionan fuera de este alta de campo. Los campos de carga son compactos y el teclado avanza con **Siguiente** entre ellos.
## Actas y firma ## Actas y firma
La terminología visible se simplifica: La terminología visible se simplifica:
@@ -57,7 +94,7 @@ La terminología visible se simplifica:
- `LOCKED`**Pendiente de firma**; - `LOCKED`**Pendiente de firma**;
- `SEALED`**Firmada y cerrada**. - `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. Una vez cerrado el contenido, el Acta es inmutable. 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. Después de la firma/cierre definitivo se mantiene el circuito documental existente: PDF del Acta e informe INF editable (DOCX) para oficina.
@@ -115,3 +152,19 @@ Esta candidata requiere conexión. No implementa trabajo offline ni cola persist
- 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. - 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. - 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. - Se agregan siete pruebas para coordenadas y valores técnicos.
## Revisión 0.19.3 · 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í.
## F6.14 · alcance territorial móvil
- Una nueva Inspección abierta desde la APK exige seleccionar Departamento → Área → Yacimiento → Operadora.
- El Yacimiento elegido queda congelado como alcance de la Inspección y del Acta.
- Los Hallazgos pueden registrarse sobre ese Yacimiento, sus Instalaciones o sus Subinstalaciones; nunca fuera de esa rama.
- Al abrir una Inspección que ya está en curso, la APK entra directamente a Actas y Hallazgos.
+8 -2
View File
@@ -2,18 +2,20 @@ plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.kapt")
} }
android { android {
namespace = "com.korexlabs.dhinspeccion" namespace = "com.korexlabs.dhinspeccion"
compileSdk = 36 compileSdk = 36
buildToolsVersion = "36.0.0"
defaultConfig { defaultConfig {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 29 versionCode = 41
versionName = "0.19.1" versionName = "0.19.13"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -73,6 +75,10 @@ dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.android.gms:play-services-location:21.3.0") implementation("com.google.android.gms:play-services-location:21.3.0")
implementation("androidx.exifinterface:exifinterface:1.4.1") implementation("androidx.exifinterface:exifinterface:1.4.1")
implementation("androidx.room:room-runtime:2.7.2")
implementation("androidx.room:room-ktx:2.7.2")
kapt("androidx.room:room-compiler:2.7.2")
implementation("androidx.work:work-runtime-ktx:2.10.3")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.ext:junit:1.2.1")
@@ -6,7 +6,9 @@ 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.AssetSummary
import com.korexlabs.dhinspeccion.data.FieldCoordinates import com.korexlabs.dhinspeccion.data.FieldCoordinates
import com.korexlabs.dhinspeccion.data.CaptureStatus
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
@@ -18,10 +20,14 @@ import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.MobileActClosure import com.korexlabs.dhinspeccion.data.MobileActClosure
import com.korexlabs.dhinspeccion.data.MobileActClosureHeader
import com.korexlabs.dhinspeccion.data.MobileActDetail import com.korexlabs.dhinspeccion.data.MobileActDetail
import com.korexlabs.dhinspeccion.data.MobileActSummary import com.korexlabs.dhinspeccion.data.MobileActSummary
import com.korexlabs.dhinspeccion.data.MobileActsRepository import com.korexlabs.dhinspeccion.data.MobileActsRepository
import com.korexlabs.dhinspeccion.data.MobileVisitClosureHeader
import com.korexlabs.dhinspeccion.data.MobileResponsible
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.offline.OfflineMutationQueue
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
@@ -36,6 +42,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
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)
private val offlineQueue = OfflineMutationQueue(application)
var session: StoredSession? by mutableStateOf(repository.currentSession()) var session: StoredSession? by mutableStateOf(repository.currentSession())
private set private set
@@ -45,6 +52,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private set private set
var notice: String? by mutableStateOf(null) var notice: String? by mutableStateOf(null)
private set private set
var pendingSyncCount by mutableStateOf(0)
private set
var inspectorSignatureConfigured: Boolean? by mutableStateOf(null)
private set
var selectedActCompanySignaturePending by mutableStateOf(false)
private set
var selectedActSealPending by mutableStateOf(false)
private set
var visits: List<VisitSummary> by mutableStateOf(emptyList()) var visits: List<VisitSummary> by mutableStateOf(emptyList())
private set private set
@@ -75,6 +90,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private set private set
init { init {
OfflineMutationQueue.schedule(application)
viewModelScope.launch { refreshOfflineStateInternal() }
if (session != null) loadVisits() if (session != null) loadVisits()
} }
@@ -143,63 +160,133 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun startVisit() { fun startVisit() {
val id = visit?.id ?: return val id = visit?.id ?: return
val previousVisit = visit
launchBusy(mutation = true) { launchBusy(mutation = true) {
visit = repository.startVisit(id) offlineQueue.enqueueVisitStart(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id val localStartedAt = Instant.now().toString()
notice = "Inspección iniciada." visit = visit?.copy(status = "IN_PROGRESS", actualStartedAt = localStartedAt)
loadActsInternal(id, selectDraft = true) val result = offlineQueue.syncAll()
loadVisitsInternal() refreshOfflineStateInternal()
if (result.pending == 0) {
visit = repository.visit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
} else if (result.retry) {
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada en el dispositivo. Podés trabajar sin conexión; se sincronizará automáticamente."
} else {
visit = previousVisit
error = result.lastError ?: "No se pudo iniciar la Inspección."
}
} }
} }
fun reloadActs() { fun reloadActs() {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) } launchBusy {
loadActsInternal(visitId, selectDraft = selectedAct == null)
refreshInspectorSignatureStatusInternal()
refreshOfflineStateInternal()
}
}
fun syncPendingNow() {
launchBusy {
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
selectedAct?.id?.let { actId ->
runCatching { refreshSelectedActInternal(actId) }
}
if (result.pending == 0) {
notice = if (result.synced > 0) "Sincronización completa. ${result.synced} cambio${if (result.synced == 1) "" else "s"} enviado${if (result.synced == 1) "" else "s"}." else "No hay cambios pendientes de sincronización."
} else if (result.retry) {
notice = "Sin conexión. Los cambios siguen guardados en este dispositivo."
} else {
error = result.lastError ?: "Quedan cambios pendientes de sincronización."
}
}
}
fun refreshInspectorSignatureStatus() {
viewModelScope.launch {
refreshInspectorSignatureStatusInternal()
if (inspectorSignatureConfigured == true && pendingSyncCount > 0) {
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
val actId = selectedAct?.id
if (actId != null && !selectedActSealPending) {
runCatching { refreshSelectedActInternal(actId) }
}
when {
result.pending == 0 -> notice = "Firma verificada y cambios pendientes sincronizados."
result.retry -> notice = "Firma verificada. Los cambios siguen guardados hasta recuperar conexión."
else -> error = result.lastError ?: "Quedan cambios pendientes de sincronización."
}
}
}
} }
fun selectAct(actId: String) { fun selectAct(actId: String) {
launchBusy { launchBusy {
selectedAct = actsRepository.get(actId) selectedAct = actsRepository.get(actId)
actClosure = actsRepository.closure(actId) actClosure = actsRepository.closure(actId)
refreshInspectorSignatureStatusInternal()
refreshOfflineStateInternal()
clearFindingState() clearFindingState()
} }
} }
fun createActForSelectedInventory(urgency: String = "NON_URGENT") { 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 return
} }
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Elegí si el Acta es urgente o no urgente."
return
}
if (acts.any { it.status == "DRAFT" }) { if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en elaboración. 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(mutation = true) { launchBusy(mutation = true) {
val created = actsRepository.create( val actId = DhRepository.newOperationId()
currentVisit.id, val occurredAt = Instant.now().toString()
asset?.id, offlineQueue.enqueueActCreate(actId, currentVisit.id, currentVisit.code, occurredAt)
currentVisit.code, val localCode = "ACTA-PEND-${actId.take(6).uppercase()}"
urgency, val local = MobileActDetail(
id = actId,
visitId = currentVisit.id,
code = localCode,
status = "DRAFT",
occurredAt = occurredAt,
title = "Acta de inspección ${currentVisit.code}",
summary = "Se inició la inspección ${currentVisit.code}.",
) )
selectedAct = created selectedAct = local
actClosure = actsRepository.closure(created.id) acts = acts + MobileActSummary(
loadActsInternal(currentVisit.id, selectDraft = false) id = actId, visitId = currentVisit.id, code = localCode, status = "DRAFT", occurredAt = occurredAt,
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" title = local.title, summary = local.summary,
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta." )
if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) { actClosure = MobileActClosure(
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id) act = MobileActClosureHeader(
id = actId, code = localCode, status = "DRAFT", visitId = currentVisit.id, currentVersion = 0,
),
visit = MobileVisitClosureHeader(
id = currentVisit.id, code = currentVisit.code, status = currentVisit.status, actualClosedAt = currentVisit.actualClosedAt,
),
)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
loadActsInternal(currentVisit.id, selectDraft = false)
refreshSelectedActInternal(actId)
notice = "${selectedAct?.code ?: localCode} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
} else {
notice = "Acta creada en el dispositivo. Podés continuar trabajando; recibirá su código oficial cuando vuelva internet."
} }
} }
} }
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) { fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val generation = ++inventorySearchGeneration val generation = ++inventorySearchGeneration
@@ -225,16 +312,55 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
} }
private suspend fun ensureAssetInActOffline(
actId: String,
assetId: String,
code: String,
name: String,
typeName: String?,
): OfflineMutationQueue.SyncResult {
if (selectedAct?.assets?.none { it.id == assetId } != false) {
offlineQueue.enqueueActAsset(actId, assetId)
selectedAct = selectedAct?.copy(
assets = selectedAct!!.assets + AssetSummary(assetId, code, name, typeName),
assetCount = selectedAct!!.assetCount + 1,
)
acts = acts.map { if (it.id == actId) it.copy(assetCount = selectedAct?.assetCount ?: it.assetCount) else it }
}
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) selectedAct = actsRepository.get(actId)
return result
}
fun selectExisting(item: FieldInventoryItem) { fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy(mutation = true) { launchBusy(mutation = true) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) offlineQueue.enqueueFieldAssetSelect(visitId, item.id)
notice = "Inventario agregado a la Inspección." selectedFieldAsset = FieldAssetDetail(
reloadCurrentInventory(visitId) context = null,
asset = item.copy(selectedInInspection = true),
selectedInInspection = true,
capture = CaptureStatus(
captureRequired = item.captureRequired,
hasGeometry = item.hasGeometry,
creationGpsCaptured = item.hasGeometry,
fieldPhotoCount = item.fieldPhotoCount,
readyForFinding = item.readyForFinding,
),
)
val sync = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (sync.pending == 0) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
runCatching { reloadCurrentInventory(visitId) }
notice = "Inventario agregado a la Inspección."
} else {
notice = "Inventario seleccionado en el dispositivo. Se sincronizará automáticamente al volver internet."
}
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) ensureAssetInActOffline(draft.id, item.id, item.code, item.name, item.type?.name)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, item.id, draft.id) loadFindingOptionsInternal(visitId, item.id, draft.id)
} else if (selectedFieldAsset?.capture?.readyForFinding == true) { } else if (selectedFieldAsset?.capture?.readyForFinding == true) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos." notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
@@ -260,11 +386,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
if (type.familyRequired && familyId == null) { if (type.familyRequired && familyId == null) {
error = "Elegí una clasificación técnica o la opción Otro / no catalogado." error = "Elegí el Tipo de instalación o subinstalación correspondiente."
return return
} }
launchBusy(mutation = true) { launchBusy(mutation = true) {
val assetId = DhRepository.newOperationId()
val capturedAt = Instant.now().toString()
val request = CreateFieldInventoryRequest( val request = CreateFieldInventoryRequest(
clientGeneratedId = assetId,
typeId = type.id, typeId = type.id,
parentId = parentId, parentId = parentId,
familyId = familyId, familyId = familyId,
@@ -275,11 +404,44 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
deviceLatitude = FieldCoordinates.latitude(latitude), deviceLatitude = FieldCoordinates.latitude(latitude),
deviceLongitude = FieldCoordinates.longitude(longitude), deviceLongitude = FieldCoordinates.longitude(longitude),
deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy), deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
deviceCapturedAt = Instant.now().toString(), deviceCapturedAt = capturedAt,
) )
selectedFieldAsset = repository.createFieldAsset(visitId, request) offlineQueue.enqueueFieldAssetCreate(assetId, visitId, request)
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar." val localAsset = FieldInventoryItem(
reloadCurrentInventory(visitId) id = assetId,
code = "PEND-${assetId.take(6).uppercase()}",
name = request.name,
commonName = request.commonName,
informationStatus = "DRAFT",
dataOrigin = "FIELD_SURVEY",
type = AssetSummary(type.id, type.code, type.name, type.name),
selectedInInspection = true,
captureRequired = true,
hasGeometry = true,
fieldPhotoCount = 0,
readyForFinding = false,
)
selectedFieldAsset = FieldAssetDetail(
asset = localAsset,
selectedInInspection = true,
capture = CaptureStatus(
captureRequired = true,
hasGeometry = true,
creationGpsCaptured = true,
fieldPhotoCount = 0,
readyForFinding = false,
),
)
inventory = inventory + localAsset
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
selectedFieldAsset = repository.selectFieldAsset(visitId, assetId)
runCatching { reloadCurrentInventory(visitId) }
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
} else {
notice = "Inventario creado y guardado en el dispositivo. Sacá la foto obligatoria; se sincronizará automáticamente al volver internet."
}
} }
} }
@@ -306,8 +468,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
reloadCurrentInventory(visitId) 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) ensureAssetInActOffline(
loadActsInternal(visitId, selectDraft = false) draft.id, result.canonical.id, result.canonical.code, result.canonical.name, result.canonical.typeName,
)
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id) loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
} }
} }
@@ -322,27 +485,41 @@ 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(mutation = true) { launchBusy(mutation = true) {
val response = repository.uploadFieldPhoto( offlineQueue.enqueueFieldAssetPhoto(
visitId = visitId,
assetId = asset.id, assetId = asset.id,
file = file, visitId = visitId,
source = file,
latitude = latitude, latitude = latitude,
longitude = longitude, longitude = longitude,
accuracyM = accuracyM, accuracyM = accuracyM,
) )
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture) val localCapture = selectedFieldAsset?.capture?.copy(
notice = if (response.capture.readyForFinding) { hasGeometry = true,
"Captura completa: GPS y fotografía registrados." creationGpsCaptured = true,
fieldPhotoCount = (selectedFieldAsset?.capture?.fieldPhotoCount ?: 0) + 1,
readyForFinding = true,
) ?: CaptureStatus(
captureRequired = true, hasGeometry = true, creationGpsCaptured = true, fieldPhotoCount = 1, readyForFinding = true,
)
selectedFieldAsset = selectedFieldAsset?.copy(
asset = asset.copy(hasGeometry = true, fieldPhotoCount = localCapture.fieldPhotoCount, readyForFinding = true),
capture = localCapture,
)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
selectedFieldAsset = repository.selectFieldAsset(visitId, asset.id)
runCatching { reloadCurrentInventory(visitId) }
notice = "Inventario listo: ubicación y foto registradas."
} else { } else {
"Fotografía registrada." notice = "Foto del Inventario guardada en el dispositivo. Ya podés continuar; se sincronizará al volver internet."
} }
reloadCurrentInventory(visitId)
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (response.capture.readyForFinding && draft != null) { if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id) val current = selectedFieldAsset?.asset ?: asset
loadActsInternal(visitId, selectDraft = false) ensureAssetInActOffline(draft.id, current.id, current.code, current.name, current.type?.name)
loadFindingOptionsInternal(visitId, asset.id, draft.id) loadFindingOptionsInternal(visitId, current.id, draft.id)
} else if (response.capture.readyForFinding) { } else if (selectedFieldAsset?.capture?.readyForFinding == true) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos." notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
} }
} }
@@ -357,8 +534,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
launchBusy(mutation = true) { launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(draft.id, assetId) val asset = selectedFieldAsset?.asset ?: return@launchBusy
loadActsInternal(visitId, selectDraft = false) ensureAssetInActOffline(draft.id, assetId, asset.code, asset.name, asset.type?.name)
loadFindingOptionsInternal(visitId, assetId, draft.id) loadFindingOptionsInternal(visitId, assetId, draft.id)
} }
} }
@@ -369,7 +546,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
customLegalBasis: String?, customLegalBasis: String?,
description: String, description: String,
severity: Int?, severity: Int?,
correctionDueOn: String?,
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return
@@ -391,24 +567,60 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
launchBusy(mutation = true) { launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(actId, assetId) val clientFindingId = DhRepository.newOperationId()
val response = findingsRepository.create( val request = CreateFieldFindingRequest(
visitId, actId = actId,
assetId, clientGeneratedId = clientFindingId,
CreateFieldFindingRequest( catalogItemId = catalogItemId,
actId = actId, customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
catalogItemId = catalogItemId, customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() }, description = description.trim(),
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() }, severity = severity,
description = description.trim(),
severity = severity,
correctionDueOn = correctionDueOn?.trim()?.takeIf { it.isNotBlank() },
),
) )
lastCreatedFinding = response.finding if (selectedAct?.assets?.none { it.id == assetId } != false) {
notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica." offlineQueue.enqueueActAsset(actId, assetId)
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true) selectedFieldAsset?.asset?.let { asset ->
loadActsInternal(visitId, selectDraft = false) selectedAct = selectedAct?.copy(assets = selectedAct!!.assets + AssetSummary(
id = asset.id,
code = asset.code,
name = asset.name,
typeName = asset.type?.name,
))
}
}
offlineQueue.enqueueFindingCreate(clientFindingId, visitId, assetId, request)
val catalog = fieldFindingOptions?.catalog?.items?.firstOrNull { it.id == catalogItemId }
val localNumber = (fieldFindingOptions?.findings?.maxOfOrNull { it.findingNumber } ?: 0) + 1
val localFinding = FieldFindingItem(
id = clientFindingId,
actId = actId,
assetId = assetId,
catalogItemId = catalogItemId,
findingNumber = localNumber,
code = "PEND-H${localNumber.toString().padStart(3, '0')}",
status = "OPEN",
title = catalog?.title ?: customTitle?.trim().orEmpty().ifBlank { "Hallazgo pendiente" },
description = description.trim(),
severity = severity ?: catalog?.suggestedSeverity,
suggestedSeverity = catalog?.suggestedSeverity,
)
lastCreatedFinding = localFinding
fieldFindingOptions = fieldFindingOptions?.copy(
findings = fieldFindingOptions!!.findings + localFinding,
canAddAnother = true,
)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
lastCreatedFinding = fieldFindingOptions?.findings?.firstOrNull { it.id == clientFindingId } ?: localFinding
loadActsInternal(visitId, selectDraft = false)
notice = "Hallazgo ${lastCreatedFinding?.code ?: localFinding.code} registrado. Podés agregar evidencia fotográfica."
} else {
notice = "Hallazgo guardado en el dispositivo. Podés seguir sacando fotos; todo se sincronizará automáticamente al volver internet."
}
} }
} }
@@ -422,17 +634,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
description: String? = null, description: String? = null,
) { ) {
launchBusy(mutation = true) { launchBusy(mutation = true) {
findingsRepository.uploadObservationPhoto( offlineQueue.enqueueFindingPhoto(
findingId = findingId, findingId = findingId,
file = file, source = file,
latitude = latitude, latitude = latitude,
longitude = longitude, longitude = longitude,
accuracyM = accuracyM, accuracyM = accuracyM,
title = title, title = title,
description = description, description = description,
) )
loadEvidenceInternal(findingId) val result = offlineQueue.syncAll()
notice = "Evidencia fotográfica registrada con GPS." refreshOfflineStateInternal()
if (result.pending == 0) {
loadEvidenceInternal(findingId)
notice = "Evidencia fotográfica registrada con GPS."
} else {
notice = "Foto guardada en el dispositivo. Se sincronizará automáticamente al volver la conexión."
}
} }
} }
@@ -449,24 +667,41 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
phone: String?, phone: String?,
) { ) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank()) { val normalizedEmail = email?.trim().orEmpty()
error = "Completá nombre, documento y cargo del responsable de la empresa." val emailValid = Regex("""^[^\s@]+@[^\s@]+\.[^\s@]+$""").matches(normalizedEmail)
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank() || !emailValid) {
error = "Completá nombres y apellidos, DNI, cargo/función y un email válido del representante de la empresa."
return return
} }
launchBusy(mutation = true) { launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible( val request = MobileResponsibleRequest(
actId, attendanceStatus = "PRESENT",
MobileResponsibleRequest( fullName = fullName.trim(),
attendanceStatus = "PRESENT", documentType = documentType,
fullName = fullName.trim(), documentNumber = documentNumber.trim(),
documentType = documentType, position = position.trim(),
documentNumber = documentNumber.trim(), email = normalizedEmail.lowercase(),
position = position.trim(), phone = phone?.trim()?.takeIf { it.isNotBlank() },
email = email?.trim()?.takeIf { it.isNotBlank() },
phone = phone?.trim()?.takeIf { it.isNotBlank() },
),
) )
notice = "Responsable de empresa registrado para el Acta." offlineQueue.enqueueResponsible(actId, request)
actClosure = actClosure?.copy(responsible = MobileResponsible(
actId = actId,
attendanceStatus = request.attendanceStatus,
fullName = request.fullName,
documentType = request.documentType,
documentNumber = request.documentNumber,
position = request.position,
email = request.email,
phone = request.phone,
))
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
actClosure = actsRepository.closure(actId)
notice = "Representante de la empresa registrado para esta Acta."
} else {
notice = "Representante guardado en el dispositivo. Se sincronizará automáticamente cuando vuelva internet."
}
} }
} }
@@ -477,23 +712,53 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
launchBusy(mutation = true) { launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible( val request = MobileResponsibleRequest(
actId, attendanceStatus = "ABSENT",
MobileResponsibleRequest( absenceReason = reason.trim(),
attendanceStatus = "ABSENT",
absenceReason = reason.trim(),
),
) )
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta." offlineQueue.enqueueResponsible(actId, request)
actClosure = actClosure?.copy(responsible = MobileResponsible(
actId = actId,
attendanceStatus = "ABSENT",
absenceReason = reason.trim(),
))
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) actClosure = actsRepository.closure(actId)
notice = if (result.pending == 0) {
"Ausencia del representante registrada. La manifestación de la empresa deberá resolverse antes de firmar y cerrar el Acta."
} else {
"Ausencia guardada en el dispositivo. Se sincronizará automáticamente cuando vuelva internet."
}
} }
} }
fun prepareSelectedAct() { fun prepareSelectedAct(urgency: String, narrative: String) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Definí la urgencia antes de cerrar el Acta."
return
}
val summary = narrative.trim()
if (summary.length !in 10..20000) {
error = "Describí lo actuado antes de cerrar el Acta (entre 10 y 20000 caracteres)."
return
}
launchBusy(mutation = true) { launchBusy(mutation = true) {
actClosure = actsRepository.lock(actId) offlineQueue.enqueueActSummary(actId, summary)
refreshSelectedActInternal(actId) offlineQueue.enqueueActLock(actId, urgency)
notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación." selectedAct = selectedAct?.copy(status = "LOCKED", urgency = urgency, summary = summary)
acts = acts.map { if (it.id == actId) it.copy(status = "LOCKED", urgency = urgency, summary = summary) else it }
actClosure = actClosure?.copy(act = actClosure!!.act.copy(status = "LOCKED", urgency = urgency))
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
if (result.pending == 0) {
refreshSelectedActInternal(actId)
notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas."
} else {
notice = "Cierre de contenido guardado en el dispositivo como $urgencyLabel. Podés continuar con la firma; se sincronizará al recuperar internet."
}
} }
} }
@@ -524,14 +789,28 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) { ) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy(mutation = true) { launchBusy(mutation = true) {
actClosure = actsRepository.signCompany( offlineQueue.enqueueCompanySignature(
actId, png, latitude, longitude, accuracyM, manifestation, statement, actId = actId,
source = png,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
manifestation = manifestation,
statement = statement,
) )
notice = if (manifestation == "DISSENT") { val result = offlineQueue.syncAll()
"Firma de empresa registrada con disidencia." refreshOfflineStateInternal()
if (!selectedActCompanySignaturePending) {
actClosure = actsRepository.closure(actId)
notice = if (manifestation == "DISSENT") {
"Firma del representante registrada en disconformidad."
} else {
"Firma del representante de la empresa registrada."
}
} else { } else {
"Firma de empresa registrada." notice = "Firma guardada en el dispositivo. Quedó pendiente de sincronización y no se perderá si no hay internet."
} }
result.lastError?.takeIf { result.pending > 0 && !result.retry }?.let { error = it }
} }
} }
@@ -546,8 +825,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
launchBusy(mutation = true) { launchBusy(mutation = true) {
actClosure = actsRepository.companyOutcome(actId, status, reason) offlineQueue.enqueueCompanyOutcome(actId, status, reason.trim())
notice = "Negativa a firmar asentada." val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
actClosure = actsRepository.closure(actId)
notice = "Negativa a firmar asentada."
} else {
notice = "Negativa a firmar guardada en el dispositivo. Se sincronizará automáticamente al recuperar internet."
}
} }
} }
@@ -555,20 +841,50 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy(mutation = true) { launchBusy(mutation = true) {
actClosure = actsRepository.seal(actId) offlineQueue.enqueueSeal(actId)
refreshSelectedActInternal(actId) if (inspectorSignatureConfigured == false) {
loadActsInternal(currentVisit.id, selectDraft = false) refreshOfflineStateInternal()
clearFindingState() notice = "Cierre guardado en el dispositivo. Falta configurar la firma del inspector en Mi perfil del Dashboard; después se sincronizará automáticamente."
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección." return@launchBusy
}
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (!selectedActSealPending) {
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
} else if (result.retry) {
val localClosedAt = Instant.now().toString()
selectedAct = selectedAct?.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt)
acts = acts.map { if (it.id == actId) it.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt) else it }
clearFindingState()
notice = "Acta cerrada en el dispositivo. Podés continuar con otra Acta; se sincronizará automáticamente al recuperar internet."
} else {
error = result.lastError ?: "El cierre quedó pendiente de sincronización."
}
} }
} }
fun closeInspection() { fun closeInspection() {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
val previousVisit = visit
launchBusy(mutation = true) { launchBusy(mutation = true) {
visit = actsRepository.closeVisit(visitId) val closedAt = Instant.now().toString()
loadVisitsInternal() offlineQueue.enqueueVisitClose(visitId, closedAt)
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina." visit = visit?.copy(status = "CLOSED", actualClosedAt = closedAt)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
visit = repository.visit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
} else if (result.retry) {
notice = "Cierre de Inspección guardado en el dispositivo. Quedará sincronizado automáticamente cuando vuelva internet."
} else {
visit = previousVisit
error = result.lastError ?: "No se pudo cerrar la Inspección."
}
} }
} }
@@ -606,6 +922,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
selectedAct = null selectedAct = null
actClosure = null actClosure = null
} }
refreshOfflineStateInternal()
} }
private suspend fun refreshSelectedActInternal(actId: String) { private suspend fun refreshSelectedActInternal(actId: String) {
@@ -636,6 +953,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) )
} }
private suspend fun refreshInspectorSignatureStatusInternal() {
runCatching { actsRepository.selfSignatureStatus() }
.onSuccess { inspectorSignatureConfigured = it.configured }
}
private suspend fun refreshOfflineStateInternal() {
pendingSyncCount = offlineQueue.pendingCount()
val actId = selectedAct?.id
if (actId == null) {
selectedActCompanySignaturePending = false
selectedActSealPending = false
} else {
selectedActCompanySignaturePending = offlineQueue.hasPending("COMPANY_SIGNATURE", actId)
|| offlineQueue.hasPending("COMPANY_OUTCOME", actId)
selectedActSealPending = offlineQueue.hasPending("ACT_SEAL", actId)
}
}
private fun clearActState() { private fun clearActState() {
acts = emptyList() acts = emptyList()
selectedAct = null selectedAct = null
@@ -5,6 +5,8 @@ import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
import com.squareup.moshi.JsonAdapter
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 okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -25,6 +27,7 @@ import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import java.io.File import java.io.File
import java.io.IOException
import java.security.KeyStore import java.security.KeyStore
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
@@ -254,6 +257,7 @@ data class FieldAssetDetail(
) )
data class CreateFieldInventoryRequest( data class CreateFieldInventoryRequest(
val clientGeneratedId: String? = null,
val typeId: String, val typeId: String,
val parentId: String? = null, val parentId: String? = null,
val familyId: String? = null, val familyId: String? = null,
@@ -366,6 +370,7 @@ interface DhApi {
@Part("deviceAccuracyM") accuracy: okhttp3.RequestBody?, @Part("deviceAccuracyM") accuracy: okhttp3.RequestBody?,
@Part("deviceCapturedAt") capturedAt: okhttp3.RequestBody, @Part("deviceCapturedAt") capturedAt: okhttp3.RequestBody,
@Part("deviceLabel") deviceLabel: okhttp3.RequestBody, @Part("deviceLabel") deviceLabel: okhttp3.RequestBody,
@Part("operationId") operationId: okhttp3.RequestBody?,
@Part("exifLatitude") exifLatitude: okhttp3.RequestBody?, @Part("exifLatitude") exifLatitude: okhttp3.RequestBody?,
@Part("exifLongitude") exifLongitude: okhttp3.RequestBody?, @Part("exifLongitude") exifLongitude: okhttp3.RequestBody?,
@Part("exifCapturedAt") exifCapturedAt: okhttp3.RequestBody?, @Part("exifCapturedAt") exifCapturedAt: okhttp3.RequestBody?,
@@ -448,8 +453,14 @@ class SecureSessionStore(context: Context) {
} }
class DhRepository(context: Context) { class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val appContext = context.applicationContext
private val store = SecureSessionStore(appContext)
private val cache = OfflineJsonCache(appContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val visitListAdapter = moshi.adapter(VisitListResponse::class.java)
private val visitAdapter = moshi.adapter(VisitDetail::class.java)
private val inventoryAdapter = moshi.adapter(FieldInventoryListResponse::class.java)
private val fieldTypeAdapter = moshi.adapter(FieldTypeResponse::class.java)
private val api: DhApi = Retrofit.Builder() private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build()) .client(OkHttpClient.Builder().build())
@@ -468,25 +479,47 @@ class DhRepository(context: Context) {
store.clear() store.clear()
} }
suspend fun visits(): VisitListResponse = authorized { session -> suspend fun visits(): VisitListResponse {
api.visits("Bearer ${session.accessToken}", session.userId) val session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
return cached("visits:${session.userId}", visitListAdapter) {
authorized { active -> api.visits("Bearer ${active.accessToken}", active.userId) }
}
} }
suspend fun visit(id: String): VisitDetail = authorized { session -> suspend fun visit(id: String): VisitDetail = cached("visit:$id", visitAdapter) {
api.visit("Bearer ${session.accessToken}", id) authorized { session -> api.visit("Bearer ${session.accessToken}", id) }
} }
suspend fun startVisit(id: String): VisitDetail = authorized { session -> suspend fun startVisit(id: String): VisitDetail {
api.startVisit("Bearer ${session.accessToken}", id) val value = authorized { session -> api.startVisit("Bearer ${session.accessToken}", id) }
cache.put(userCacheKey("visit:$id"), visitAdapter.toJson(value))
return value
} }
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session -> suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null): FieldInventoryListResponse {
api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId) val normalizedSearch = search?.trim()?.takeIf { it.isNotBlank() }
val baseKey = "inventory:$visitId:${parentId ?: "root"}"
return try {
val value = authorized { session ->
api.fieldInventory("Bearer ${session.accessToken}", visitId, normalizedSearch, parentId)
}
if (normalizedSearch == null) cache.put(userCacheKey(baseKey), inventoryAdapter.toJson(value))
value
} catch (error: IOException) {
val cached = cache.get(userCacheKey(baseKey))?.let(inventoryAdapter::fromJson) ?: throw error
if (normalizedSearch == null) cached else cached.copy(
data = cached.data.filter { item ->
item.code.contains(normalizedSearch, ignoreCase = true)
|| item.name.contains(normalizedSearch, ignoreCase = true)
},
)
}
} }
suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session -> suspend fun fieldTypes(visitId: String, parentId: String?): FieldTypeResponse =
api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) cached("field-types:$visitId:${parentId ?: "root"}", fieldTypeAdapter) {
} authorized { session -> api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) }
}
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session -> suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId) api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
@@ -518,6 +551,7 @@ class DhRepository(context: Context) {
longitude: Double, longitude: Double,
accuracyM: Double?, accuracyM: Double?,
capturedAt: String = Instant.now().toString(), capturedAt: String = Instant.now().toString(),
operationId: String? = null,
): FieldPhotoResponse = authorized { session -> ): FieldPhotoResponse = authorized { session ->
val text = "text/plain".toMediaType() val text = "text/plain".toMediaType()
val body = file.asRequestBody("image/jpeg".toMediaType()) val body = file.asRequestBody("image/jpeg".toMediaType())
@@ -532,12 +566,27 @@ class DhRepository(context: Context) {
accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.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),
operationId = operationId?.toRequestBody(text),
exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text), exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text), exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text), exifCapturedAt = capturedAt.toRequestBody(text),
) )
} }
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
private suspend fun <T> cached(
key: String,
adapter: JsonAdapter<T>,
online: suspend () -> T,
): T {
return try {
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
} catch (error: IOException) {
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T { private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada") var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try { try {
@@ -561,8 +610,10 @@ class DhRepository(context: Context) {
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()
if (message == "Error interno") return "No se pudo completar la operación. Intentá nuevamente."
if (!message.isNullOrBlank()) return message if (!message.isNullOrBlank()) return message
return "Error HTTP ${error.code()}" if (error.code() >= 500) return "No se pudo completar la operación. Intentá nuevamente."
return "No se pudo procesar la solicitud (${error.code()})."
} }
return error.message ?: "Ocurrió un error inesperado" return error.message ?: "Ocurrió un error inesperado"
} }
@@ -0,0 +1,19 @@
package com.korexlabs.dhinspeccion.data
/** Builds the API payload using definition IDs while the form keeps stable field codes. */
internal fun fieldAttributePayload(
definitions: List<FieldAttributeDefinition>,
valuesByCode: Map<String, String>,
): Map<String, Any?> = definitions.mapNotNull { definition ->
val raw = valuesByCode[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.id to coerceFieldAttribute(definition, raw)
}.toMap()
private fun coerceFieldAttribute(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
}
@@ -2,6 +2,8 @@ package com.korexlabs.dhinspeccion.data
import android.content.Context import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
import com.squareup.moshi.JsonAdapter
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 okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -22,6 +24,7 @@ import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import java.io.File import java.io.File
import java.io.IOException
import java.time.Instant import java.time.Instant
data class FieldFindingAct( data class FieldFindingAct(
@@ -108,12 +111,12 @@ data class FieldFindingOptionsResponse(
data class CreateFieldFindingRequest( data class CreateFieldFindingRequest(
val actId: String, val actId: String,
val clientGeneratedId: String? = null,
val catalogItemId: String? = null, val catalogItemId: String? = null,
val customTitle: String? = null, val customTitle: String? = null,
val customLegalBasis: String? = null, val customLegalBasis: String? = null,
val description: String, val description: String,
val severity: Int? = null, val severity: Int? = null,
val correctionDueOn: String? = null,
) )
data class FieldFindingCreateResponse( data class FieldFindingCreateResponse(
@@ -162,6 +165,7 @@ private interface FieldFindingsApi {
@Part("longitude") longitude: RequestBody, @Part("longitude") longitude: RequestBody,
@Part("accuracyM") accuracyM: RequestBody?, @Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody, @Part("deviceLabel") deviceLabel: RequestBody,
@Part("operationId") operationId: RequestBody?,
): FieldFindingEvidence ): FieldFindingEvidence
@POST("auth/mobile/refresh") @POST("auth/mobile/refresh")
@@ -173,8 +177,12 @@ private interface FieldFindingsApi {
* F3.2 exige que la APK identifique explícitamente el Acta activa. * F3.2 exige que la APK identifique explícitamente el Acta activa.
*/ */
class FieldFindingsRepository(context: Context) { class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val appContext = context.applicationContext
private val store = SecureSessionStore(appContext)
private val cache = OfflineJsonCache(appContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val optionsAdapter = moshi.adapter(FieldFindingOptionsResponse::class.java)
private val evidenceAdapter = moshi.adapter(FieldFindingEvidenceListResponse::class.java)
private val api: FieldFindingsApi = Retrofit.Builder() private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build()) .client(OkHttpClient.Builder().build())
@@ -182,10 +190,28 @@ class FieldFindingsRepository(context: Context) {
.build() .build()
.create(FieldFindingsApi::class.java) .create(FieldFindingsApi::class.java)
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse = suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse {
authorized { session -> val exactKey = "finding-options:$visitId:$assetId:$actId"
api.options("Bearer ${session.accessToken}", visitId, assetId, actId) val templateKey = "finding-options-template:$visitId:$assetId"
return try {
val value = authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
}
val json = optionsAdapter.toJson(value)
cache.put(userCacheKey(exactKey), json)
cache.put(userCacheKey(templateKey), json)
value
} catch (error: IOException) {
cache.get(userCacheKey(exactKey))?.let(optionsAdapter::fromJson)
?: cache.get(userCacheKey(templateKey))?.let(optionsAdapter::fromJson)?.copy(
act = FieldFindingAct(id = actId, code = "Acta local pendiente", status = "DRAFT"),
findings = emptyList(),
assetIncludedInAct = true,
canAddAnother = true,
)
?: throw error
} }
}
suspend fun create( suspend fun create(
visitId: String, visitId: String,
@@ -195,9 +221,10 @@ class FieldFindingsRepository(context: Context) {
api.create("Bearer ${session.accessToken}", visitId, assetId, request) api.create("Bearer ${session.accessToken}", visitId, assetId, request)
} }
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session -> suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse =
api.evidence("Bearer ${session.accessToken}", findingId) cached("finding-evidence:$findingId", evidenceAdapter) {
} authorized { session -> api.evidence("Bearer ${session.accessToken}", findingId) }
}
suspend fun uploadObservationPhoto( suspend fun uploadObservationPhoto(
findingId: String, findingId: String,
@@ -208,6 +235,7 @@ class FieldFindingsRepository(context: Context) {
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
capturedAt: String = Instant.now().toString(), capturedAt: String = Instant.now().toString(),
operationId: String? = null,
): FieldFindingEvidence = authorized { session -> ): FieldFindingEvidence = authorized { session ->
val text = "text/plain".toMediaType() val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData( val part = MultipartBody.Part.createFormData(
@@ -228,9 +256,24 @@ class FieldFindingsRepository(context: Context) {
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text), longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text), accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text),
operationId = operationId?.toRequestBody(text),
) )
} }
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
private suspend fun <T> cached(
key: String,
adapter: JsonAdapter<T>,
online: suspend () -> T,
): T {
return try {
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
} catch (error: IOException) {
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T { private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada") var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try { try {
@@ -2,6 +2,8 @@ package com.korexlabs.dhinspeccion.data
import android.content.Context import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
import com.squareup.moshi.JsonAdapter
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 okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -24,6 +26,7 @@ import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import java.io.File import java.io.File
import java.io.IOException
import java.time.Instant import java.time.Instant
data class MobileActSummary( data class MobileActSummary(
@@ -35,7 +38,7 @@ 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 = "NON_URGENT", val urgency: String? = null,
val deadlineDays: Int? = null, val deadlineDays: Int? = null,
val deadlineDayType: String? = null, val deadlineDayType: String? = null,
val deadlineBasis: String? = null, val deadlineBasis: String? = null,
@@ -60,7 +63,7 @@ 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 = "NON_URGENT", val urgency: String? = null,
val deadlineDays: Int? = null, val deadlineDays: Int? = null,
val deadlineDayType: String? = null, val deadlineDayType: String? = null,
val deadlineBasis: String? = null, val deadlineBasis: String? = null,
@@ -90,16 +93,21 @@ data class MobileActListResponse(
) )
data class CreateMobileActRequest( data class CreateMobileActRequest(
val clientGeneratedId: String? = null,
val occurredAt: String, val occurredAt: String,
val urgency: String,
val title: String, val title: String,
val summary: String, val summary: String,
val observations: String? = null, val observations: String? = null,
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>? = null,
val summary: String? = null,
) )
data class MobileResponsibleRequest( data class MobileResponsibleRequest(
@@ -130,7 +138,7 @@ data class MobileActClosureHeader(
val code: String, val code: String,
val status: String, val status: String,
val visitId: String, val visitId: String,
val urgency: String = "NON_URGENT", val urgency: String? = null,
val deadlineDays: Int? = null, val deadlineDays: Int? = null,
val deadlineDayType: String? = null, val deadlineDayType: String? = null,
val deadlineBasis: String? = null, val deadlineBasis: String? = null,
@@ -185,11 +193,20 @@ data class MobileActClosure(
val act: MobileActClosureHeader, val act: MobileActClosureHeader,
val visit: MobileVisitClosureHeader, val visit: MobileVisitClosureHeader,
val responsible: MobileResponsible? = null, val responsible: MobileResponsible? = null,
val representativeSuggestion: MobileResponsible? = null,
val closure: MobileClosureRecord? = null, val closure: MobileClosureRecord? = null,
val signatures: List<MobileSignature> = emptyList(), val signatures: List<MobileSignature> = emptyList(),
val consents: MobileClosureConsents = MobileClosureConsents(), val consents: MobileClosureConsents = MobileClosureConsents(),
) )
data class MobileProfileSignatureStatus(
val configured: Boolean = false,
val mimeType: String? = null,
val sizeBytes: Int? = null,
val imageSha256: String? = null,
val updatedAt: String? = null,
)
data class MobileCompanyOutcomeRequest( data class MobileCompanyOutcomeRequest(
val status: String, val status: String,
val reason: String, val reason: String,
@@ -197,7 +214,7 @@ data class MobileCompanyOutcomeRequest(
data class MobileSealActRequest( data class MobileSealActRequest(
val clientClosedAt: String = Instant.now().toString(), val clientClosedAt: String = Instant.now().toString(),
val uploadMode: String = "ONLINE", val uploadMode: String = "IMMEDIATE",
) )
data class MobileCloseVisitRequest( data class MobileCloseVisitRequest(
@@ -232,6 +249,11 @@ private interface MobileActsApi {
@Body request: UpdateMobileActRequest, @Body request: UpdateMobileActRequest,
): MobileActDetail ): MobileActDetail
@GET("users/self/signature")
suspend fun selfSignature(
@Header("Authorization") authorization: String,
): MobileProfileSignatureStatus
@GET("inspection-acts/{actId}/closure") @GET("inspection-acts/{actId}/closure")
suspend fun closure( suspend fun closure(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -249,6 +271,7 @@ private interface MobileActsApi {
suspend fun lock( suspend fun lock(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("actId") actId: String, @Path("actId") actId: String,
@Body request: PrepareMobileActRequest,
): MobileActClosure ): MobileActClosure
@Multipart @Multipart
@@ -307,8 +330,14 @@ private interface MobileActsApi {
} }
class MobileActsRepository(context: Context) { class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val appContext = context.applicationContext
private val store = SecureSessionStore(appContext)
private val cache = OfflineJsonCache(appContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val actListAdapter = moshi.adapter(MobileActListResponse::class.java)
private val actAdapter = moshi.adapter(MobileActDetail::class.java)
private val closureAdapter = moshi.adapter(MobileActClosure::class.java)
private val signatureStatusAdapter = moshi.adapter(MobileProfileSignatureStatus::class.java)
private val api: MobileActsApi = Retrofit.Builder() private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build()) .client(OkHttpClient.Builder().build())
@@ -316,31 +345,40 @@ class MobileActsRepository(context: Context) {
.build() .build()
.create(MobileActsApi::class.java) .create(MobileActsApi::class.java)
suspend fun list(visitId: String): MobileActListResponse = authorized { session -> suspend fun list(visitId: String): MobileActListResponse =
api.listActs("Bearer ${session.accessToken}", visitId) cached("acts:$visitId", actListAdapter) {
} authorized { session -> api.listActs("Bearer ${session.accessToken}", visitId) }
}
suspend fun get(actId: String): MobileActDetail = authorized { session -> suspend fun get(actId: String): MobileActDetail = cached("act:$actId", actAdapter) {
api.act("Bearer ${session.accessToken}", actId) authorized { session -> api.act("Bearer ${session.accessToken}", actId) }
} }
suspend fun create( suspend fun create(
visitId: String, visitId: String,
assetId: String? = null,
visitCode: String, visitCode: String,
urgency: String = "NON_URGENT", clientGeneratedId: String? = null,
): MobileActDetail = authorized { session -> occurredAt: String = Instant.now().toString(),
api.createAct( ): MobileActDetail {
"Bearer ${session.accessToken}", val created = authorized { session ->
visitId, api.createAct(
CreateMobileActRequest( "Bearer ${session.accessToken}",
occurredAt = Instant.now().toString(), visitId,
urgency = urgency, CreateMobileActRequest(
title = "Acta de inspección $visitCode", clientGeneratedId = clientGeneratedId,
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.", occurredAt = occurredAt,
assetIds = listOfNotNull(assetId), title = "Acta de inspección $visitCode",
), summary = "Se inició la inspección $visitCode.",
) assetIds = emptyList(),
),
)
}
cache.put(userCacheKey("act:${created.id}"), actAdapter.toJson(created))
return created
}
suspend fun updateSummary(actId: String, summary: String): MobileActDetail = authorized { session ->
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(summary = summary))
} }
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail { suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
@@ -352,16 +390,25 @@ class MobileActsRepository(context: Context) {
} }
} }
suspend fun closure(actId: String): MobileActClosure = authorized { session -> suspend fun closure(actId: String): MobileActClosure = cached("act-closure:$actId", closureAdapter) {
api.closure("Bearer ${session.accessToken}", actId) authorized { session -> api.closure("Bearer ${session.accessToken}", actId) }
} }
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session -> suspend fun selfSignatureStatus(): MobileProfileSignatureStatus =
api.responsible("Bearer ${session.accessToken}", actId, request) cached("self-signature-status", signatureStatusAdapter) {
authorized { session -> api.selfSignature("Bearer ${session.accessToken}") }
}
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure {
val value = authorized { session -> api.responsible("Bearer ${session.accessToken}", actId, request) }
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
} }
suspend fun lock(actId: String): MobileActClosure = authorized { session -> suspend fun lock(actId: String, urgency: String): MobileActClosure {
api.lock("Bearer ${session.accessToken}", actId) val value = authorized { session -> api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency)) }
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
} }
suspend fun signInspector( suspend fun signInspector(
@@ -380,6 +427,7 @@ class MobileActsRepository(context: Context) {
accuracyM: Double?, accuracyM: Double?,
manifestation: String = "CONFORMITY", manifestation: String = "CONFORMITY",
statement: String? = null, statement: String? = null,
clientSignedAt: String = Instant.now().toString(),
): MobileActClosure = signature( ): MobileActClosure = signature(
actId = actId, actId = actId,
png = png, png = png,
@@ -389,22 +437,38 @@ class MobileActsRepository(context: Context) {
company = true, company = true,
manifestation = manifestation, manifestation = manifestation,
statement = statement, statement = statement,
clientSignedAt = clientSignedAt,
) )
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session -> suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure {
api.companyOutcome( val value = authorized { session ->
"Bearer ${session.accessToken}", api.companyOutcome(
actId, "Bearer ${session.accessToken}",
MobileCompanyOutcomeRequest(status, reason.trim()), actId,
) MobileCompanyOutcomeRequest(status, reason.trim()),
)
}
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
} }
suspend fun seal(actId: String): MobileActClosure = authorized { session -> suspend fun seal(
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest()) actId: String,
uploadMode: String = "IMMEDIATE",
clientClosedAt: String = Instant.now().toString(),
): MobileActClosure {
val value = authorized { session ->
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest(clientClosedAt, uploadMode))
}
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
} }
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session -> suspend fun closeVisit(
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest()) visitId: String,
clientClosedAt: String = Instant.now().toString(),
): VisitDetail = authorized { session ->
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest(clientClosedAt))
} }
private suspend fun signature( private suspend fun signature(
@@ -416,6 +480,7 @@ class MobileActsRepository(context: Context) {
company: Boolean, company: Boolean,
manifestation: String? = null, manifestation: String? = null,
statement: String? = null, statement: String? = null,
clientSignedAt: String = Instant.now().toString(),
): MobileActClosure = authorized { session -> ): MobileActClosure = authorized { session ->
val text = "text/plain".toMediaType() val text = "text/plain".toMediaType()
val file = MultipartBody.Part.createFormData( val file = MultipartBody.Part.createFormData(
@@ -424,7 +489,7 @@ class MobileActsRepository(context: Context) {
png.asRequestBody("image/png".toMediaType()), png.asRequestBody("image/png".toMediaType()),
) )
val consent = "true".toRequestBody(text) val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text) val signedAt = clientSignedAt.toRequestBody(text)
val device = "DH Android".toRequestBody(text) val device = "DH Android".toRequestBody(text)
val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text) val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text)
val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text) val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
@@ -444,6 +509,20 @@ class MobileActsRepository(context: Context) {
} }
} }
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
private suspend fun <T> cached(
key: String,
adapter: JsonAdapter<T>,
online: suspend () -> T,
): T {
return try {
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
} catch (error: IOException) {
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T { private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada") var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try { try {
@@ -25,22 +25,36 @@ data class MobilePlanningAssetResponse(
) )
data class OpenMobileInspectionRequest( data class OpenMobileInspectionRequest(
val departmentId: String,
val operationalAreaId: String, val operationalAreaId: String,
val scopeAssetId: String,
val operatorCompanyId: String, val operatorCompanyId: String,
) )
private interface MobileInspectionOpenApi { private interface MobileInspectionOpenApi {
@GET("inspection-visits/mobile/planning-context/areas") @GET("inspection-visits/mobile/planning-context/departments")
suspend fun areas( suspend fun departments(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
): MobilePlanningAssetResponse ): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators") @GET("inspection-visits/mobile/planning-context/departments/{departmentId}/areas")
suspend fun operators( suspend fun areas(
@Header("Authorization") authorization: String,
@Path("departmentId") departmentId: String,
): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/yacimientos")
suspend fun yacimientos(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("areaId") areaId: String, @Path("areaId") areaId: String,
): MobilePlanningAssetResponse ): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/yacimientos/{yacimientoId}/operators")
suspend fun operators(
@Header("Authorization") authorization: String,
@Path("yacimientoId") yacimientoId: String,
): MobilePlanningAssetResponse
@POST("inspection-visits/mobile/open") @POST("inspection-visits/mobile/open")
suspend fun open( suspend fun open(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -61,18 +75,31 @@ class MobileInspectionOpenRepository(context: Context) {
.build() .build()
.create(MobileInspectionOpenApi::class.java) .create(MobileInspectionOpenApi::class.java)
suspend fun areas(): MobilePlanningAssetResponse = authorized { session -> suspend fun departments(): MobilePlanningAssetResponse = authorized { session ->
api.areas("Bearer ${session.accessToken}") api.departments("Bearer ${session.accessToken}")
} }
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session -> suspend fun areas(departmentId: String): MobilePlanningAssetResponse = authorized { session ->
api.operators("Bearer ${session.accessToken}", areaId) api.areas("Bearer ${session.accessToken}", departmentId)
} }
suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session -> suspend fun yacimientos(areaId: String): MobilePlanningAssetResponse = authorized { session ->
api.yacimientos("Bearer ${session.accessToken}", areaId)
}
suspend fun operators(yacimientoId: String): MobilePlanningAssetResponse = authorized { session ->
api.operators("Bearer ${session.accessToken}", yacimientoId)
}
suspend fun open(
departmentId: String,
areaId: String,
yacimientoId: String,
companyId: String,
): VisitDetail = authorized { session ->
api.open( api.open(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
OpenMobileInspectionRequest(areaId, companyId), OpenMobileInspectionRequest(departmentId, areaId, yacimientoId, companyId),
) )
} }
@@ -89,5 +116,4 @@ class MobileInspectionOpenRepository(context: Context) {
private suspend fun refresh(previous: StoredSession): StoredSession = private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
} }
@@ -0,0 +1,632 @@
package com.korexlabs.dhinspeccion.data.offline
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.MobileActsRepository
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.SecureSessionStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONObject
import retrofit2.HttpException
import java.io.File
import java.io.IOException
import java.time.Instant
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
@Entity(tableName = "pending_mobile_operations")
data class PendingMobileOperation(
@PrimaryKey val id: String,
val ownerUserId: String,
val type: String,
val resourceId: String,
val payloadJson: String,
val filePath: String? = null,
val state: String = "PENDING",
val attempts: Int = 0,
val lastError: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
)
@Dao
interface PendingMobileOperationDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(value: PendingMobileOperation)
@Query("SELECT * FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND state IN ('PENDING','ERROR') ORDER BY createdAt ASC")
suspend fun pending(ownerUserId: String): List<PendingMobileOperation>
@Query("SELECT COUNT(*) FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND state IN ('PENDING','ERROR','SYNCING')")
suspend fun pendingCount(ownerUserId: String): Int
@Query("SELECT COUNT(*) FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND resourceId=:resourceId AND type=:type AND state IN ('PENDING','ERROR','SYNCING')")
suspend fun hasPending(ownerUserId: String, type: String, resourceId: String): Int
@Query("UPDATE pending_mobile_operations SET state=:state, attempts=:attempts, lastError=:lastError, updatedAt=:updatedAt WHERE id=:id")
suspend fun mark(id: String, state: String, attempts: Int, lastError: String?, updatedAt: Long = System.currentTimeMillis())
@Query("DELETE FROM pending_mobile_operations WHERE id=:id")
suspend fun delete(id: String)
}
@Entity(tableName = "mobile_offline_cache")
data class OfflineCacheEntry(
@PrimaryKey val cacheKey: String,
val json: String,
val updatedAt: Long = System.currentTimeMillis(),
)
@Dao
interface OfflineCacheDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun put(value: OfflineCacheEntry)
@Query("SELECT * FROM mobile_offline_cache WHERE cacheKey=:key LIMIT 1")
suspend fun get(key: String): OfflineCacheEntry?
@Query("DELETE FROM mobile_offline_cache WHERE cacheKey=:key")
suspend fun delete(key: String)
}
@Database(entities = [PendingMobileOperation::class, OfflineCacheEntry::class], version = 1, exportSchema = false)
abstract class OfflineQueueDatabase : RoomDatabase() {
abstract fun operations(): PendingMobileOperationDao
abstract fun cache(): OfflineCacheDao
companion object {
@Volatile private var instance: OfflineQueueDatabase? = null
fun get(context: Context): OfflineQueueDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
OfflineQueueDatabase::class.java,
"dh_mobile_offline_queue.db",
).build().also { instance = it }
}
}
}
class OfflineJsonCache(context: Context) {
private val dao = OfflineQueueDatabase.get(context).cache()
suspend fun put(key: String, json: String) {
dao.put(OfflineCacheEntry(key, json))
}
suspend fun get(key: String): String? = dao.get(key)?.json
suspend fun delete(key: String) = dao.delete(key)
}
class OfflineMutationQueue(private val context: Context) {
private val dao = OfflineQueueDatabase.get(context).operations()
private val sessions = SecureSessionStore(context.applicationContext)
private val mediaRoot = File(context.filesDir, "offline-media").apply { mkdirs() }
suspend fun pendingCount(): Int = currentOwnerOrNull()?.let { dao.pendingCount(it) } ?: 0
suspend fun hasPending(type: String, resourceId: String): Boolean =
currentOwnerOrNull()?.let { dao.hasPending(it, type, resourceId) > 0 } ?: false
suspend fun enqueueFindingCreate(
findingId: String,
visitId: String,
assetId: String,
request: CreateFieldFindingRequest,
): String = enqueueJson(
type = "FINDING_CREATE",
resourceId = findingId,
payload = JSONObject()
.put("visitId", visitId)
.put("assetId", assetId)
.put("actId", request.actId)
.put("clientGeneratedId", request.clientGeneratedId ?: findingId)
.put("catalogItemId", request.catalogItemId)
.put("customTitle", request.customTitle)
.put("customLegalBasis", request.customLegalBasis)
.put("description", request.description)
.put("severity", request.severity)
.toString(),
)
suspend fun enqueueFindingPhoto(
findingId: String,
source: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
title: String?,
description: String?,
capturedAt: String = Instant.now().toString(),
): String = persistWithFile("FINDING_PHOTO", findingId, source, "jpg") { id ->
JSONObject()
.put("operationId", id)
.put("latitude", latitude)
.put("longitude", longitude)
.put("accuracyM", accuracyM)
.put("title", title)
.put("description", description)
.put("capturedAt", capturedAt)
.toString()
}
suspend fun enqueueVisitStart(visitId: String): String = enqueueJson(
type = "VISIT_START",
resourceId = visitId,
payload = JSONObject().toString(),
)
suspend fun enqueueVisitClose(
visitId: String,
clientClosedAt: String = Instant.now().toString(),
): String = enqueueJson(
type = "VISIT_CLOSE",
resourceId = visitId,
payload = JSONObject().put("clientClosedAt", clientClosedAt).toString(),
)
suspend fun enqueueFieldAssetCreate(
assetId: String,
visitId: String,
request: CreateFieldInventoryRequest,
): String = enqueueJson(
type = "FIELD_ASSET_CREATE",
resourceId = assetId,
payload = JSONObject()
.put("visitId", visitId)
.put("typeId", request.typeId)
.put("parentId", request.parentId)
.put("familyId", request.familyId)
.put("code", request.code)
.put("name", request.name)
.put("commonName", request.commonName)
.put("description", request.description)
.put("discoveryNotes", request.discoveryNotes)
.put("attributes", JSONObject(request.attributes))
.put("deviceLatitude", request.deviceLatitude)
.put("deviceLongitude", request.deviceLongitude)
.put("deviceAccuracyM", request.deviceAccuracyM)
.put("deviceCapturedAt", request.deviceCapturedAt)
.put("deviceLabel", request.deviceLabel)
.toString(),
)
suspend fun enqueueFieldAssetPhoto(
assetId: String,
visitId: String,
source: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
capturedAt: String = Instant.now().toString(),
): String = persistWithFile("FIELD_ASSET_PHOTO", assetId, source, "jpg") { id ->
JSONObject()
.put("visitId", visitId)
.put("operationId", id)
.put("latitude", latitude)
.put("longitude", longitude)
.put("accuracyM", accuracyM)
.put("capturedAt", capturedAt)
.toString()
}
suspend fun enqueueFieldAssetSelect(visitId: String, assetId: String): String = enqueueJson(
type = "FIELD_ASSET_SELECT",
resourceId = assetId,
payload = JSONObject().put("visitId", visitId).toString(),
)
suspend fun enqueueActCreate(
actId: String,
visitId: String,
visitCode: String,
occurredAt: String,
): String = enqueueJson(
type = "ACT_CREATE",
resourceId = actId,
payload = JSONObject()
.put("visitId", visitId)
.put("visitCode", visitCode)
.put("occurredAt", occurredAt)
.toString(),
)
suspend fun enqueueActSummary(actId: String, summary: String): String = enqueueJson(
type = "ACT_SUMMARY",
resourceId = actId,
payload = JSONObject().put("summary", summary).toString(),
)
suspend fun enqueueActAsset(actId: String, assetId: String): String = enqueueJson(
type = "ACT_ASSET_ENSURE",
resourceId = actId,
payload = JSONObject().put("assetId", assetId).toString(),
)
suspend fun enqueueResponsible(actId: String, request: MobileResponsibleRequest): String = enqueueJson(
type = "ACT_RESPONSIBLE",
resourceId = actId,
payload = JSONObject()
.put("attendanceStatus", request.attendanceStatus)
.put("fullName", request.fullName)
.put("documentType", request.documentType)
.put("documentNumber", request.documentNumber)
.put("position", request.position)
.put("email", request.email)
.put("phone", request.phone)
.put("absenceReason", request.absenceReason)
.toString(),
)
suspend fun enqueueActLock(actId: String, urgency: String): String = enqueueJson(
type = "ACT_LOCK",
resourceId = actId,
payload = JSONObject().put("urgency", urgency).toString(),
)
suspend fun enqueueCompanyOutcome(actId: String, status: String, reason: String): String = enqueueJson(
type = "COMPANY_OUTCOME",
resourceId = actId,
payload = JSONObject().put("status", status).put("reason", reason).toString(),
)
suspend fun enqueueCompanySignature(
actId: String,
source: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
manifestation: String,
statement: String?,
clientSignedAt: String = Instant.now().toString(),
): String = persistWithFile("COMPANY_SIGNATURE", actId, source, "png") { _ ->
JSONObject()
.put("latitude", latitude)
.put("longitude", longitude)
.put("accuracyM", accuracyM)
.put("manifestation", manifestation)
.put("statement", statement)
.put("clientSignedAt", clientSignedAt)
.toString()
}
suspend fun enqueueSeal(
actId: String,
clientClosedAt: String = Instant.now().toString(),
): String {
val id = UUID.randomUUID().toString()
dao.insert(
PendingMobileOperation(
id = id,
ownerUserId = currentOwner(),
type = "ACT_SEAL",
resourceId = actId,
payloadJson = JSONObject().put("clientClosedAt", clientClosedAt).toString(),
createdAt = nextCreatedAt(),
),
)
schedule(context)
return id
}
private suspend fun enqueueJson(type: String, resourceId: String, payload: String): String {
val id = UUID.randomUUID().toString()
dao.insert(PendingMobileOperation(
id = id,
ownerUserId = currentOwner(),
type = type,
resourceId = resourceId,
payloadJson = payload,
createdAt = nextCreatedAt(),
))
schedule(context)
return id
}
private suspend fun persistWithFile(
type: String,
resourceId: String,
source: File,
extension: String,
payload: (String) -> String,
): String = withContext(Dispatchers.IO) {
val id = UUID.randomUUID().toString()
val target = File(mediaRoot, "$id.$extension")
source.copyTo(target, overwrite = false)
dao.insert(
PendingMobileOperation(
id = id,
ownerUserId = currentOwner(),
type = type,
resourceId = resourceId,
payloadJson = payload(id),
filePath = target.absolutePath,
createdAt = nextCreatedAt(),
),
)
source.delete()
schedule(context)
id
}
suspend fun syncAll(): SyncResult = syncMutex.withLock {
val ownerUserId = currentOwnerOrNull() ?: return@withLock SyncResult(0, 0, retry = false)
var synced = 0
var lastError: String? = null
for (operation in dao.pending(ownerUserId)) {
val attempt = operation.attempts + 1
dao.mark(operation.id, "SYNCING", attempt, null)
try {
syncOne(operation)
operation.filePath?.let { File(it).delete() }
dao.delete(operation.id)
synced += 1
} catch (error: IOException) {
dao.mark(operation.id, "PENDING", attempt, error.message)
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = true, lastError = error.message)
} catch (error: HttpException) {
if (isAlreadyApplied(operation, error)) {
operation.filePath?.let { File(it).delete() }
dao.delete(operation.id)
synced += 1
} else {
lastError = httpError(error)
dao.mark(operation.id, "ERROR", attempt, lastError)
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
}
} catch (error: Throwable) {
lastError = error.message ?: error::class.java.simpleName
dao.mark(operation.id, "ERROR", attempt, lastError)
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
}
}
SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
}
private suspend fun syncOne(operation: PendingMobileOperation) {
val payload = JSONObject(operation.payloadJson)
when (operation.type) {
"VISIT_START" -> DhRepository(context).startVisit(operation.resourceId)
"VISIT_CLOSE" -> MobileActsRepository(context).closeVisit(
operation.resourceId,
payload.getString("clientClosedAt"),
)
"FIELD_ASSET_CREATE" -> DhRepository(context).createFieldAsset(
payload.getString("visitId"),
CreateFieldInventoryRequest(
clientGeneratedId = operation.resourceId,
typeId = payload.getString("typeId"),
parentId = payload.optNullableString("parentId"),
familyId = payload.optNullableString("familyId"),
code = payload.optNullableString("code"),
name = payload.getString("name"),
commonName = payload.optNullableString("commonName"),
description = payload.optNullableString("description"),
discoveryNotes = payload.optNullableString("discoveryNotes"),
attributes = jsonObjectToMap(payload.getJSONObject("attributes")),
deviceLatitude = payload.getDouble("deviceLatitude"),
deviceLongitude = payload.getDouble("deviceLongitude"),
deviceAccuracyM = payload.optDoubleOrNull("deviceAccuracyM"),
deviceCapturedAt = payload.getString("deviceCapturedAt"),
deviceLabel = payload.optString("deviceLabel", "DH Android"),
),
)
"FIELD_ASSET_PHOTO" -> DhRepository(context).uploadFieldPhoto(
visitId = payload.getString("visitId"),
assetId = operation.resourceId,
file = requiredFile(operation),
latitude = payload.getDouble("latitude"),
longitude = payload.getDouble("longitude"),
accuracyM = payload.optDoubleOrNull("accuracyM"),
capturedAt = payload.getString("capturedAt"),
operationId = payload.getString("operationId"),
)
"FIELD_ASSET_SELECT" -> DhRepository(context).selectFieldAsset(
payload.getString("visitId"),
operation.resourceId,
)
"ACT_CREATE" -> MobileActsRepository(context).create(
visitId = payload.getString("visitId"),
visitCode = payload.getString("visitCode"),
clientGeneratedId = operation.resourceId,
occurredAt = payload.getString("occurredAt"),
)
"ACT_SUMMARY" -> MobileActsRepository(context).updateSummary(
operation.resourceId,
payload.getString("summary"),
)
"ACT_ASSET_ENSURE" -> MobileActsRepository(context).ensureAsset(
operation.resourceId,
payload.getString("assetId"),
)
"FINDING_CREATE" -> FieldFindingsRepository(context).create(
payload.getString("visitId"),
payload.getString("assetId"),
CreateFieldFindingRequest(
actId = payload.getString("actId"),
clientGeneratedId = payload.getString("clientGeneratedId"),
catalogItemId = payload.optNullableString("catalogItemId"),
customTitle = payload.optNullableString("customTitle"),
customLegalBasis = payload.optNullableString("customLegalBasis"),
description = payload.getString("description"),
severity = if (!payload.has("severity") || payload.isNull("severity")) null else payload.getInt("severity"),
),
)
"ACT_RESPONSIBLE" -> MobileActsRepository(context).setResponsible(
operation.resourceId,
MobileResponsibleRequest(
attendanceStatus = payload.getString("attendanceStatus"),
fullName = payload.optNullableString("fullName"),
documentType = payload.optNullableString("documentType"),
documentNumber = payload.optNullableString("documentNumber"),
position = payload.optNullableString("position"),
email = payload.optNullableString("email"),
phone = payload.optNullableString("phone"),
absenceReason = payload.optNullableString("absenceReason"),
),
)
"ACT_LOCK" -> MobileActsRepository(context).lock(
operation.resourceId,
payload.getString("urgency"),
)
"COMPANY_OUTCOME" -> MobileActsRepository(context).companyOutcome(
operation.resourceId,
payload.getString("status"),
payload.getString("reason"),
)
"FINDING_PHOTO" -> {
val file = requiredFile(operation)
FieldFindingsRepository(context).uploadObservationPhoto(
findingId = operation.resourceId,
file = file,
latitude = payload.getDouble("latitude"),
longitude = payload.getDouble("longitude"),
accuracyM = payload.optDoubleOrNull("accuracyM"),
title = payload.optNullableString("title"),
description = payload.optNullableString("description"),
capturedAt = payload.getString("capturedAt"),
operationId = payload.getString("operationId"),
)
}
"COMPANY_SIGNATURE" -> {
val file = requiredFile(operation)
MobileActsRepository(context).signCompany(
actId = operation.resourceId,
png = file,
latitude = payload.optDoubleOrNull("latitude"),
longitude = payload.optDoubleOrNull("longitude"),
accuracyM = payload.optDoubleOrNull("accuracyM"),
manifestation = payload.optString("manifestation", "CONFORMITY"),
statement = payload.optNullableString("statement"),
clientSignedAt = payload.getString("clientSignedAt"),
)
}
"ACT_SEAL" -> MobileActsRepository(context).seal(
actId = operation.resourceId,
uploadMode = "DEFERRED",
clientClosedAt = payload.getString("clientClosedAt"),
)
else -> error("Operación offline desconocida: ${operation.type}")
}
}
private suspend fun isAlreadyApplied(operation: PendingMobileOperation, error: HttpException): Boolean {
if (error.code() != 409) return false
return when (operation.type) {
"VISIT_START" -> runCatching {
DhRepository(context).visit(operation.resourceId).status in setOf("IN_PROGRESS", "CLOSED")
}.getOrDefault(false)
"VISIT_CLOSE" -> runCatching {
DhRepository(context).visit(operation.resourceId).status == "CLOSED"
}.getOrDefault(false)
"ACT_LOCK" -> runCatching {
MobileActsRepository(context).get(operation.resourceId).status != "DRAFT"
}.getOrDefault(false)
"COMPANY_OUTCOME", "COMPANY_SIGNATURE" -> runCatching {
MobileActsRepository(context).closure(operation.resourceId).signatures.any {
it.signerType == "COMPANY_RESPONSIBLE" && (it.status == "SIGNED" || it.status == "REFUSED")
}
}.getOrDefault(false)
"ACT_SEAL" -> runCatching {
MobileActsRepository(context).get(operation.resourceId).status == "SEALED"
}.getOrDefault(false)
else -> false
}
}
private fun requiredFile(operation: PendingMobileOperation): File {
val path = operation.filePath ?: error("La operación ${operation.id} no tiene archivo local")
return File(path).also { require(it.isFile) { "Archivo local pendiente inexistente" } }
}
private fun currentOwnerOrNull(): String? = sessions.load()?.userId
private fun currentOwner(): String = currentOwnerOrNull()
?: throw IllegalStateException("Sesión no iniciada")
data class SyncResult(val synced: Int, val pending: Int, val retry: Boolean, val lastError: String? = null)
companion object {
private const val UNIQUE_WORK = "dh-mobile-offline-sync"
private val syncMutex = Mutex()
private val queueClock = AtomicLong(System.currentTimeMillis())
private fun nextCreatedAt(): Long = queueClock.updateAndGet { previous ->
maxOf(System.currentTimeMillis(), previous + 1)
}
fun schedule(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<OfflineSyncWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork(UNIQUE_WORK, ExistingWorkPolicy.KEEP, request)
}
}
}
class OfflineSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val result = OfflineMutationQueue(applicationContext).syncAll()
return if (result.retry) Result.retry() else Result.success()
}
}
private fun httpError(error: HttpException): String {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull().orEmpty()
return runCatching { JSONObject(body).optString("message") }.getOrNull()
?.takeIf { it.isNotBlank() }
?: "No se pudo sincronizar (HTTP ${error.code()})"
}
private fun jsonObjectToMap(value: JSONObject): Map<String, Any?> = buildMap {
val keys = value.keys()
while (keys.hasNext()) {
val key = keys.next()
put(key, jsonValue(value.opt(key)))
}
}
private fun jsonValue(value: Any?): Any? = when (value) {
null, JSONObject.NULL -> null
is JSONObject -> jsonObjectToMap(value)
is org.json.JSONArray -> List(value.length()) { index -> jsonValue(value.opt(index)) }
else -> value
}
private fun JSONObject.optDoubleOrNull(name: String): Double? =
if (!has(name) || isNull(name)) null else getDouble(name)
private fun JSONObject.optNullableString(name: String): String? =
if (!has(name) || isNull(name)) null else optString(name).takeIf { it.isNotBlank() }
@@ -60,6 +60,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
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.launch import kotlinx.coroutines.launch
@@ -521,18 +522,7 @@ private fun InventoryCard(item: FieldInventoryItem, canModify: Boolean, onSelect
} }
private fun buildAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> = private fun buildAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition -> fieldAttributePayload(type.attributes, values)
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceAttribute(definition, raw)
}.toMap()
private fun coerceAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun hasPermission(context: Context, permission: String): Boolean = private fun hasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -56,6 +56,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -693,11 +694,11 @@ private fun DynamicFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit
} }
if (selectedType?.familyRequired == true) { if (selectedType?.familyRequired == true) {
Text("Clasificación técnica *", fontWeight = FontWeight.Bold) Text("Tipo de instalación *", fontWeight = FontWeight.Bold)
OutlinedTextField( OutlinedTextField(
value = familySearch, value = familySearch,
onValueChange = { familySearch = it }, onValueChange = { familySearch = it },
label = { Text("Buscar clasificación") }, label = { Text("Buscar tipo de instalación") },
supportingText = { Text("${filteredFamilies.size} opciones compatibles") }, supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
@@ -906,18 +907,7 @@ private fun dynamicStatusLabel(status: String): String = when (status) {
} }
private fun buildDynamicAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> = private fun buildDynamicAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition -> fieldAttributePayload(type.attributes, values)
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 = private fun dynamicHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -57,6 +57,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -720,18 +721,7 @@ private fun statusLabel(status: String): String = when (status) {
} }
private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> = private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition -> fieldAttributePayload(type.attributes, values)
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceF3Attribute(definition, raw)
}.toMap()
private fun coerceF3Attribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun f3HasPermission(context: Context, permission: String): Boolean = private fun f3HasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -1,27 +1,35 @@
package com.korexlabs.dhinspeccion.ui package com.korexlabs.dhinspeccion.ui
import android.Manifest import android.Manifest
import android.graphics.BitmapFactory
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
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.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -34,10 +42,17 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
@@ -60,26 +75,32 @@ private data class FindingGeoSnapshot(
val accuracyM: Double?, val accuracyM: Double?,
) )
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FieldFindingScreen(model: MainViewModel) { fun FieldFindingScreen(model: MainViewModel) {
val options = model.fieldFindingOptions ?: return val options = model.fieldFindingOptions ?: return
val asset = model.selectedFieldAsset?.asset ?: return val asset = model.selectedFieldAsset?.asset ?: return
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
var search by rememberSaveable(asset.id) { mutableStateOf("") } var search by rememberSaveable(asset.id) { mutableStateOf("") }
var catalogExpanded by rememberSaveable(asset.id) { mutableStateOf(false) }
var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) } var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) }
var other by rememberSaveable(asset.id) { mutableStateOf(false) } var other by rememberSaveable(asset.id) { mutableStateOf(false) }
var customTitle by rememberSaveable(asset.id) { mutableStateOf("") } var customTitle by rememberSaveable(asset.id) { mutableStateOf("") }
var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") } var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") }
var description by rememberSaveable(asset.id) { mutableStateOf("") } var description by rememberSaveable(asset.id) { mutableStateOf("") }
var severityText by rememberSaveable(asset.id) { mutableStateOf("") } var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
var localError by rememberSaveable { mutableStateOf<String?>(null) } 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) }
var pendingPhotoFindingId by remember { mutableStateOf<String?>(null) } var pendingPhotoFindingId by remember { mutableStateOf<String?>(null) }
var reviewPhotoFile by remember { mutableStateOf<File?>(null) }
var reviewPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
var reviewPhotoFindingId by remember { mutableStateOf<String?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile val file = pendingPhotoFile
@@ -87,14 +108,11 @@ fun FieldFindingScreen(model: MainViewModel) {
val findingId = pendingPhotoFindingId val findingId = pendingPhotoFindingId
if (success && file != null && geo != null && findingId != null) { if (success && file != null && geo != null && findingId != null) {
runCatching { writeFindingExif(file, geo) } runCatching { writeFindingExif(file, geo) }
model.uploadFindingPhoto( reviewPhotoFile = file
findingId = findingId, reviewPhotoGeo = geo
file = file, reviewPhotoFindingId = findingId
latitude = geo.latitude, } else {
longitude = geo.longitude, file?.delete()
accuracyM = geo.accuracyM,
title = "Evidencia fotográfica de campo",
)
} }
pendingPhotoFile = null pendingPhotoFile = null
pendingPhotoGeo = null pendingPhotoGeo = null
@@ -164,8 +182,81 @@ fun FieldFindingScreen(model: MainViewModel) {
customLegalBasis = "" customLegalBasis = ""
description = "" description = ""
severityText = "" severityText = ""
correctionDueOn = ""
search = "" search = ""
catalogExpanded = false
}
}
val reviewFile = reviewPhotoFile
val reviewGeo = reviewPhotoGeo
val reviewFindingId = reviewPhotoFindingId
if (reviewFile != null && reviewGeo != null && reviewFindingId != null) {
val bitmap = remember(reviewFile.absolutePath, reviewFile.lastModified()) {
runCatching { BitmapFactory.decodeFile(reviewFile.absolutePath)?.asImageBitmap() }.getOrNull()
}
Dialog(onDismissRequest = {}) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Revisar fotografía", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(
"Todavía no se subió. Verificá la imagen antes de incorporarla al Hallazgo.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = "Vista previa de la fotografía del Hallazgo",
modifier = Modifier.fillMaxWidth().heightIn(min = 220.dp, max = 460.dp),
contentScale = ContentScale.Fit,
)
} else {
Text("No se pudo generar la vista previa.", color = MaterialTheme.colorScheme.error)
}
Text(
"GPS ${"%.6f".format(reviewGeo.latitude)}, ${"%.6f".format(reviewGeo.longitude)}${reviewGeo.accuracyM?.let { " · ±${"%.1f".format(it)} m" }.orEmpty()}",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = {
model.uploadFindingPhoto(
findingId = reviewFindingId,
file = reviewFile,
latitude = reviewGeo.latitude,
longitude = reviewGeo.longitude,
accuracyM = reviewGeo.accuracyM,
title = "Evidencia fotográfica de campo",
)
reviewPhotoFile = null
reviewPhotoGeo = null
reviewPhotoFindingId = null
},
enabled = !model.busy && bitmap != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Usar esta foto") }
OutlinedButton(
onClick = {
reviewFile.delete()
reviewPhotoFile = null
reviewPhotoGeo = null
reviewPhotoFindingId = null
beginPhoto(reviewFindingId)
},
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Volver a tomar") }
OutlinedButton(
onClick = {
reviewFile.delete()
reviewPhotoFile = null
reviewPhotoGeo = null
reviewPhotoFindingId = null
},
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Eliminar foto") }
}
}
} }
} }
@@ -180,15 +271,15 @@ fun FieldFindingScreen(model: MainViewModel) {
OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) { OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) {
Text("Volver") Text("Volver")
} }
Text("Hallazgo de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
} }
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
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} · ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text( Text(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}", if (options.capture.readyForFinding) "Inventario listo para registrar Hallazgos" else "Completá GPS y foto antes de continuar",
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,
) )
@@ -225,87 +316,123 @@ fun FieldFindingScreen(model: MainViewModel) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
val photoCount = evidence.count { it.kind == "PHOTO" }
Button( Button(
onClick = { requestPhoto(finding.id) }, onClick = { requestPhoto(finding.id) },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text("Tomar foto con GPS") Text(if (photoCount == 0) "Agregar foto con GPS" else "Agregar otra foto con GPS")
} }
Text(
if (photoCount == 0) "Podés adjuntar una o varias fotos." else "$photoCount foto${if (photoCount == 1) "" else "s"} adjunta${if (photoCount == 1) "" else "s"}. Podés seguir agregando.",
style = MaterialTheme.typography.bodySmall,
)
} }
} }
} }
HorizontalDivider() HorizontalDivider()
} }
Text("1. Elegí el tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("1. Tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (!options.catalog.typeConfigured) { if (!options.catalog.typeConfigured) {
Text( Text(
options.catalog.configurationReason "No hay tipos sugeridos para este Inventario. Elegí Otro para cargarlo manualmente.",
?: "Este tipo de Inventario todavía no tiene un catálogo contextual configurado. Podés usar OTROS.", color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.bodySmall,
) )
} }
OutlinedTextField( ExposedDropdownMenuBox(
value = search, expanded = catalogExpanded && !model.busy,
onValueChange = { search = it }, onExpandedChange = {
label = { Text("Buscar en catálogo") }, if (!model.busy) {
modifier = Modifier.fillMaxWidth(), catalogExpanded = it
singleLine = true, if (it) search = ""
) }
},
filtered.forEach { item -> ) {
val chosen = !other && selectedCatalogId == item.id OutlinedTextField(
Card( value = if (catalogExpanded) search else when {
modifier = Modifier.fillMaxWidth().clickable { other -> "Otro / No está en la lista"
selectedCatalogId = item.id selected != null -> selected.title
other = false else -> ""
severityText = item.suggestedSeverity?.toString().orEmpty()
},
colors = if (chosen) {
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
CardDefaults.cardColors()
}, },
onValueChange = { search = it; catalogExpanded = true },
label = { Text("Tipo de Hallazgo *") },
placeholder = { Text("Buscar o seleccionar") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = catalogExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
enabled = !model.busy,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = {
catalogExpanded = false
focusManager.moveFocus(FocusDirection.Down)
}),
)
ExposedDropdownMenu(
expanded = catalogExpanded && !model.busy,
onDismissRequest = { catalogExpanded = false; search = "" },
modifier = Modifier.heightIn(max = 320.dp),
) { ) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { filtered.forEach { item ->
Text(if (chosen) "${item.title}" else item.title, fontWeight = FontWeight.SemiBold) DropdownMenuItem(
Text( text = {
listOfNotNull(item.categoryName, item.code, item.suggestedSeverity?.let { "Gravedad sugerida $it" }) Column {
.joinToString(" · "), Text(item.title, fontWeight = FontWeight.SemiBold)
style = MaterialTheme.typography.bodySmall, item.suggestedSeverity?.let {
Text("Gravedad sugerida: $it/10", style = MaterialTheme.typography.bodySmall)
}
}
},
onClick = {
selectedCatalogId = item.id
other = false
severityText = item.suggestedSeverity?.toString().orEmpty()
catalogExpanded = false
search = ""
keyboard?.hide()
focusManager.moveFocus(FocusDirection.Down)
},
) )
} }
DropdownMenuItem(
text = { Text("Otro / No está en la lista", fontWeight = FontWeight.SemiBold) },
onClick = {
other = true
selectedCatalogId = null
severityText = ""
catalogExpanded = false
search = ""
keyboard?.hide()
focusManager.moveFocus(FocusDirection.Down)
},
)
} }
} }
OutlinedButton(
onClick = {
other = true
selectedCatalogId = null
severityText = ""
},
modifier = Modifier.fillMaxWidth(),
) {
Text(if (other) "✓ OTROS · Hallazgo no catalogado" else "OTROS · No está en el catálogo")
}
if (other) { if (other) {
options.catalog.other.help?.let { Text(it, style = MaterialTheme.typography.bodySmall) } Text("Cargá un nombre claro para identificar el Hallazgo.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField( OutlinedTextField(
value = customTitle, value = customTitle,
onValueChange = { customTitle = it }, onValueChange = { customTitle = it },
label = { Text("Título del nuevo Hallazgo *") }, label = { Text("Nombre del Hallazgo *") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
OutlinedTextField( OutlinedTextField(
value = customLegalBasis, value = customLegalBasis,
onValueChange = { customLegalBasis = it }, onValueChange = { customLegalBasis = it },
label = { Text("Base legal / normativa (opcional)") }, label = { Text("Normativa (opcional)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
} }
Text("2. Describí lo observado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("2. Qué observaste", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
selected?.let { selected?.let {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
@@ -318,23 +445,21 @@ fun FieldFindingScreen(model: MainViewModel) {
OutlinedTextField( OutlinedTextField(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
label = { Text("Descripción del Hallazgo *") }, label = { Text("Qué observaste *") },
placeholder = { Text("Describí brevemente el problema") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
minLines = 3, minLines = 3,
maxLines = 6,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
OutlinedTextField( OutlinedTextField(
value = severityText, value = severityText,
onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) }, onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) },
label = { Text("Gravedad 1 a 10") }, label = { Text("Gravedad (1 a 10, opcional)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
OutlinedTextField(
value = correctionDueOn,
onValueChange = { correctionDueOn = it },
label = { Text("Fecha de corrección AAAA-MM-DD (opcional)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { keyboard?.hide(); focusManager.clearFocus() }),
singleLine = true, singleLine = true,
) )
@@ -348,7 +473,6 @@ fun FieldFindingScreen(model: MainViewModel) {
customLegalBasis = if (other) customLegalBasis else null, customLegalBasis = if (other) customLegalBasis else null,
description = description, description = description,
severity = severity, severity = severity,
correctionDueOn = correctionDueOn,
) )
}, },
enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy, enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy,
@@ -365,13 +489,19 @@ fun FieldFindingScreen(model: MainViewModel) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Hallazgo registrado", fontWeight = FontWeight.Bold) Text("Hallazgo registrado", fontWeight = FontWeight.Bold)
Text("${finding.code} · ${finding.title}") Text("${finding.code} · ${finding.title}")
val createdEvidence = model.fieldFindingEvidence[finding.id].orEmpty()
val createdPhotoCount = createdEvidence.count { it.kind == "PHOTO" }
Button( Button(
onClick = { requestPhoto(finding.id) }, onClick = { requestPhoto(finding.id) },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text("Tomar foto con GPS") Text(if (createdPhotoCount == 0) "Agregar foto con GPS" else "Agregar otra foto con GPS")
} }
Text(
if (createdPhotoCount == 0) "Podés adjuntar todas las fotos que necesites." else "Fotos adjuntas: $createdPhotoCount. Podés seguir agregando.",
style = MaterialTheme.typography.bodySmall,
)
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall) Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
} }
} }
@@ -57,30 +57,34 @@ fun MobileActsScreen(
val visit = model.visit ?: return val visit = model.visit ?: return
val selected = model.selectedAct val selected = model.selectedAct
val closure = model.actClosure val closure = model.actClosure
val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") } var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var actNarrative by rememberSaveable(selected?.id) {
mutableStateOf(selected?.summary.takeUnless { it == null || it.startsWith("Se inició la inspección ") || it.startsWith("Acta de inspección en curso.") }.orEmpty())
}
var attendance by rememberSaveable(selected?.id) { var attendance by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT")
} }
var fullName by rememberSaveable(selected?.id) { var fullName by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.fullName.orEmpty()) mutableStateOf(representativeSeed?.fullName.orEmpty())
} }
var documentType by rememberSaveable(selected?.id) { var documentType by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentType ?: "DNI") mutableStateOf("DNI")
} }
var documentNumber by rememberSaveable(selected?.id) { var documentNumber by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) mutableStateOf(representativeSeed?.documentNumber.orEmpty())
} }
var position by rememberSaveable(selected?.id) { var position by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.position.orEmpty()) mutableStateOf(representativeSeed?.position.orEmpty())
} }
var email by rememberSaveable(selected?.id) { var email by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.email.orEmpty()) mutableStateOf(representativeSeed?.email.orEmpty())
} }
var phone by rememberSaveable(selected?.id) { var phone by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.phone.orEmpty()) mutableStateOf(representativeSeed?.phone.orEmpty())
} }
var absenceReason by rememberSaveable(selected?.id) { var absenceReason by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) mutableStateOf(closure?.responsible?.absenceReason.orEmpty())
@@ -91,26 +95,17 @@ fun MobileActsScreen(
LaunchedEffect(visit.id) { model.reloadActs() } LaunchedEffect(visit.id) { model.reloadActs() }
fun signWithGeo(file: File, company: Boolean) { fun signCompanyWithGeo(file: File) {
scope.launch { scope.launch {
val geo = runCatching { currentActSignatureGeo(context) }.getOrNull() val geo = runCatching { currentActSignatureGeo(context) }.getOrNull()
if (company) { model.signSelectedActAsCompany(
model.signSelectedActAsCompany( png = file,
png = file, latitude = geo?.latitude,
latitude = geo?.latitude, longitude = geo?.longitude,
longitude = geo?.longitude, accuracyM = geo?.accuracyM,
accuracyM = geo?.accuracyM, manifestation = manifestation,
manifestation = manifestation, statement = dissentStatement.takeIf { manifestation == "DISSENT" },
statement = dissentStatement.takeIf { manifestation == "DISSENT" }, )
)
} else {
model.signSelectedActAsInspector(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
)
}
} }
} }
@@ -137,7 +132,11 @@ fun MobileActsScreen(
} }
model.acts.forEach { act -> model.acts.forEach { act ->
val active = selected?.id == act.id val active = selected?.id == act.id
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente" 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(),
@@ -165,23 +164,8 @@ fun MobileActsScreen(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
Text("Urgencia", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { newActUrgency = "URGENT" },
label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Text( Text(
if (newActUrgency == "URGENT") { "La urgencia se define al cerrar el Acta, después de completar los Hallazgos.",
"El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla."
} else {
"El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla."
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text( Text(
@@ -189,7 +173,7 @@ fun MobileActsScreen(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Button( Button(
onClick = { model.createAct(newActUrgency) }, onClick = { model.createAct() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") } ) { Text("Crear nueva Acta") }
@@ -198,22 +182,6 @@ fun MobileActsScreen(
} }
if (selected != null) { if (selected != null) {
HorizontalDivider()
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status))
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
}
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
}
}
when (selected.status) { when (selected.status) {
"DRAFT" -> { "DRAFT" -> {
Card( Card(
@@ -230,29 +198,26 @@ fun MobileActsScreen(
} }
HorizontalDivider() HorizontalDivider()
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Representante 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") })
} }
if (attendance == "PRESENT") { if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth())
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind -> OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth())
AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "$kind" else kind) }) OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth())
} if (closure?.responsible == null && closure?.representativeSuggestion != null) {
Text("Datos precargados del Acta anterior. Confirmalos para esta Acta.", style = MaterialTheme.typography.bodySmall)
} }
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth())
Button( Button(
onClick = { onClick = {
model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone)
}, },
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(), enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Guardar responsable") } ) { Text("Guardar representante") }
} else { } else {
OutlinedTextField( OutlinedTextField(
absenceReason, absenceReason,
@@ -270,74 +235,79 @@ fun MobileActsScreen(
HorizontalDivider() HorizontalDivider()
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.") Text("Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet firma el acompañante de la empresa.")
Button( OutlinedTextField(
onClick = { model.prepareSelectedAct() }, actNarrative,
enabled = !model.busy && closure?.responsible != null, { actNarrative = it },
label = { Text("Descripción de lo actuado *") },
minLines = 4,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Finalizar y BLOQUEAR Acta") } )
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { closingUrgency = "NON_URGENT" },
label = { Text(if (closingUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { closingUrgency = "URGENT" },
label = { Text(if (closingUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Button(
onClick = { model.prepareSelectedAct(closingUrgency, actNarrative) },
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank() && actNarrative.trim().length in 10..20000,
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar contenido y pasar a firma") }
} }
"LOCKED" -> { "LOCKED" -> {
val signatures = closure?.signatures.orEmpty() val signatures = closure?.signatures.orEmpty()
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" val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.") Text("El contenido ya es inmutable. La firma del inspector está guardada en el Dashboard y se aplicará al cierre final.")
closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
HorizontalDivider() HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold) Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
if (inspectorSigned) {
Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary)
} else {
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
SignaturePad(
label = "Firmá como inspector/a",
enabled = !model.busy,
onCaptured = { file -> signWithGeo(file, company = false) },
)
}
HorizontalDivider()
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) { if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) { val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad" "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
"REFUSED" -> "Negativa a firmar" "REFUSED" -> "Se negó a firmar"
"ABSENT" -> "Ausencia documentada"
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( val recordedAbsence = closure.responsible.absenceReason.orEmpty()
"El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.", Text("El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.")
style = MaterialTheme.typography.bodyMedium, Text(recordedAbsence, style = MaterialTheme.typography.bodySmall)
) OutlinedButton(
onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) },
enabled = !model.busy && recordedAbsence.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Confirmar ausencia documentada") }
} else { } else {
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)) {
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") }) AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "En disidencia") }) AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disconformidad" else "En disconformidad") })
} }
if (manifestation == "DISSENT") { if (manifestation == "DISSENT") {
OutlinedTextField( OutlinedTextField(
dissentStatement, dissentStatement,
{ dissentStatement = it }, { dissentStatement = it },
label = { Text("Manifestación de disidencia *") }, label = { Text("Motivo de disconformidad *") },
minLines = 2, minLines = 2,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
SignaturePad( SignaturePad(
label = "Firma del responsable de empresa", label = "Firma del representante de la empresa (acompañante)",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) }, onCaptured = { file -> signCompanyWithGeo(file) },
) )
Text("Si la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField( OutlinedTextField(
refusalReason, refusalReason,
{ refusalReason = it }, { refusalReason = it },
@@ -347,23 +317,20 @@ fun MobileActsScreen(
) )
OutlinedButton( OutlinedButton(
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) }, onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10, enabled = !model.busy && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") } ) { Text("Registrar negativa a firmar") }
} }
HorizontalDivider() HorizontalDivider()
if (inspectorSigned && companyResolved) { if (companyResolved) {
Button( Button(
onClick = { model.closeSelectedAct() }, onClick = { model.closeSelectedAct() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("SELLAR Acta definitivamente") } ) { Text("Aplicar mi firma y cerrar Acta") }
} else { } else {
Text( Text("Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.", style = MaterialTheme.typography.bodySmall)
"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,
)
} }
} }
@@ -9,7 +9,6 @@ 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -48,19 +47,41 @@ fun MobileHomeScreen(model: MainViewModel) {
val openRepository = remember(context) { MobileInspectionOpenRepository(context) } val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
var showOpen by rememberSaveable { mutableStateOf(false) } var showOpen by rememberSaveable { mutableStateOf(false) }
var departments by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) } var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var yacimientos by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) } var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var selectedDepartmentId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) } var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedYacimientoId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) } var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
var loadingContext by remember { mutableStateOf(false) } var loadingContext by remember { mutableStateOf(false) }
var opening by remember { mutableStateOf(false) } var opening by remember { mutableStateOf(false) }
var localError by remember { mutableStateOf<String?>(null) } var localError by remember { mutableStateOf<String?>(null) }
fun loadAreas() { fun loadDepartments() {
scope.launch { scope.launch {
loadingContext = true loadingContext = true
localError = null localError = null
runCatching { openRepository.areas().data } runCatching { openRepository.departments().data }
.onSuccess { departments = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseDepartment(departmentId: String) {
selectedDepartmentId = departmentId
selectedAreaId = null
selectedYacimientoId = null
selectedCompanyId = null
areas = emptyList()
yacimientos = emptyList()
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.areas(departmentId).data }
.onSuccess { areas = it } .onSuccess { areas = it }
.onFailure { localError = DhRepository.humanError(it) } .onFailure { localError = DhRepository.humanError(it) }
loadingContext = false loadingContext = false
@@ -69,38 +90,69 @@ fun MobileHomeScreen(model: MainViewModel) {
fun chooseArea(areaId: String) { fun chooseArea(areaId: String) {
selectedAreaId = areaId selectedAreaId = areaId
selectedYacimientoId = null
selectedCompanyId = null
yacimientos = emptyList()
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.yacimientos(areaId).data }
.onSuccess { yacimientos = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseYacimiento(yacimientoId: String) {
selectedYacimientoId = yacimientoId
selectedCompanyId = null selectedCompanyId = null
operators = emptyList() operators = emptyList()
scope.launch { scope.launch {
loadingContext = true loadingContext = true
localError = null localError = null
runCatching { openRepository.operators(areaId).data } runCatching { openRepository.operators(yacimientoId).data }
.onSuccess { operators = it } .onSuccess {
operators = it
if (it.size == 1) selectedCompanyId = it.first().id
}
.onFailure { localError = DhRepository.humanError(it) } .onFailure { localError = DhRepository.humanError(it) }
loadingContext = false loadingContext = false
} }
} }
LaunchedEffect(showOpen) { LaunchedEffect(showOpen) {
if (showOpen && areas.isEmpty()) loadAreas() if (showOpen && departments.isEmpty()) loadDepartments()
} }
Column(Modifier.fillMaxSize().padding(top = 28.dp)) { Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row( Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Column { Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall) Text(session.displayName, style = MaterialTheme.typography.bodySmall)
} }
Row { Row(
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) { Modifier.fillMaxWidth(),
Text("Actualizar") horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = { model.loadVisits() },
enabled = !model.busy && !opening,
modifier = Modifier.weight(1f),
) {
Text("Actualizar", maxLines = 1)
}
OutlinedButton(
onClick = { model.logout() },
enabled = !opening,
modifier = Modifier.weight(1f),
) {
Text("Salir", maxLines = 1)
} }
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
} }
} }
@@ -131,27 +183,57 @@ fun MobileHomeScreen(model: MainViewModel) {
) { ) {
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text( 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.", "Definí el contexto completo antes de abrir el Acta: Departamento → Área → Yacimiento → Operadora. La inspección queda autoasignada a vos y se inicia con la hora del servidor.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text("1. Área", fontWeight = FontWeight.SemiBold) Text("1. Departamento", fontWeight = FontWeight.SemiBold)
if (loadingContext && areas.isEmpty()) CircularProgressIndicator() if (loadingContext && departments.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(areas, key = { it.id }) { area -> items(departments, key = { it.id }) { department ->
AssistChip( AssistChip(
onClick = { chooseArea(area.id) }, onClick = { chooseDepartment(department.id) },
label = { Text(if (area.id == selectedAreaId) "${area.name}" else area.name) }, label = { Text(if (department.id == selectedDepartmentId) "${department.name}" else department.name) },
) )
} }
} }
if (selectedDepartmentId != null) {
Text("2. Á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) { if (selectedAreaId != null) {
Text("2. Operadora", fontWeight = FontWeight.SemiBold) Text("3. Yacimiento", fontWeight = FontWeight.SemiBold)
if (loadingContext && yacimientos.isEmpty()) {
CircularProgressIndicator()
} else if (yacimientos.isEmpty()) {
Text("No hay Yacimientos disponibles para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(yacimientos, key = { it.id }) { yacimiento ->
AssistChip(
onClick = { chooseYacimiento(yacimiento.id) },
label = { Text(if (yacimiento.id == selectedYacimientoId) "${yacimiento.name}" else yacimiento.name) },
)
}
}
}
if (selectedYacimientoId != null) {
Text("4. Operadora", fontWeight = FontWeight.SemiBold)
if (loadingContext && operators.isEmpty()) { if (loadingContext && operators.isEmpty()) {
CircularProgressIndicator() CircularProgressIndicator()
} else if (operators.isEmpty()) { } else if (operators.isEmpty()) {
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error) Text("El Yacimiento no tiene una Operadora válida configurada.", color = MaterialTheme.colorScheme.error)
} }
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(operators, key = { it.id }) { company -> items(operators, key = { it.id }) { company ->
@@ -165,12 +247,14 @@ fun MobileHomeScreen(model: MainViewModel) {
Button( Button(
onClick = { onClick = {
val departmentId = selectedDepartmentId ?: return@Button
val areaId = selectedAreaId ?: return@Button val areaId = selectedAreaId ?: return@Button
val yacimientoId = selectedYacimientoId ?: return@Button
val companyId = selectedCompanyId ?: return@Button val companyId = selectedCompanyId ?: return@Button
scope.launch { scope.launch {
opening = true opening = true
localError = null localError = null
runCatching { openRepository.open(areaId, companyId) } runCatching { openRepository.open(departmentId, areaId, yacimientoId, companyId) }
.onSuccess { opened -> .onSuccess { opened ->
showOpen = false showOpen = false
model.openVisit(opened.id) model.openVisit(opened.id)
@@ -180,9 +264,9 @@ fun MobileHomeScreen(model: MainViewModel) {
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext, enabled = selectedDepartmentId != null && selectedAreaId != null && selectedYacimientoId != null && selectedCompanyId != null && !opening && !loadingContext,
) { ) {
Text(if (opening) "Abriendo…" else "Abrir inspección ahora") Text(if (opening) "Abriendo…" else "Abrir inspección y continuar al Acta")
} }
} }
} }
@@ -217,6 +301,7 @@ private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Text(visit.code, fontWeight = FontWeight.Bold) Text(visit.code, fontWeight = FontWeight.Bold)
Text(visitStatusLabelEs(visit.status)) Text(visitStatusLabelEs(visit.status))
} }
Text(visit.scopeAsset?.name ?: "Yacimiento sin definir", fontWeight = FontWeight.SemiBold)
Text(visit.operatorCompany?.name ?: "Operadora sin definir") Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall) Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let { visit.plannedStartAt?.let {
@@ -3,7 +3,6 @@ package com.korexlabs.dhinspeccion.ui
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -72,44 +71,37 @@ fun ModernMobileActsScreen(
val visit = model.visit ?: return val visit = model.visit ?: return
val selected = model.selectedAct val selected = model.selectedAct
val closure = model.actClosure val closure = model.actClosure
val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") } var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") } var actNarrative by rememberSaveable(selected?.id) {
var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) } mutableStateOf(selected?.summary.takeUnless { it == null || it.startsWith("Se inició la inspección ") || it.startsWith("Acta de inspección en curso.") }.orEmpty())
var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") } }
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) } var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) }
var position by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.position.orEmpty()) } var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") }
var email by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.email.orEmpty()) } var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) }
var phone by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.phone.orEmpty()) } var position by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.position.orEmpty()) }
var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) } var email by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.email.orEmpty()) }
var phone by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.phone.orEmpty()) }
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") } var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") } var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") } var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
LaunchedEffect(visit.id) { model.reloadActs() } LaunchedEffect(visit.id) { model.reloadActs() }
fun signWithGeo(file: File, company: Boolean) { fun signCompanyWithGeo(file: File) {
scope.launch { scope.launch {
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull() val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
if (company) { model.signSelectedActAsCompany(
model.signSelectedActAsCompany( png = file,
png = file, latitude = geo?.latitude,
latitude = geo?.latitude, longitude = geo?.longitude,
longitude = geo?.longitude, accuracyM = geo?.accuracyM,
accuracyM = geo?.accuracyM, manifestation = manifestation,
manifestation = manifestation, statement = dissentStatement.takeIf { manifestation == "DISSENT" },
statement = dissentStatement.takeIf { manifestation == "DISSENT" }, )
)
} else {
model.signSelectedActAsInspector(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
)
}
} }
} }
@@ -147,6 +139,29 @@ fun ModernMobileActsScreen(
ModernMessage(model) ModernMessage(model)
if (model.pendingSyncCount > 0) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"${model.pendingSyncCount} cambio${if (model.pendingSyncCount == 1) "" else "s"} guardado${if (model.pendingSyncCount == 1) "" else "s"} en este dispositivo",
fontWeight = FontWeight.Bold,
)
Text(
"Podés seguir trabajando sin internet. Los cambios se sincronizan automáticamente cuando vuelve la conexión.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
OutlinedButton(onClick = { model.syncPendingNow() }, enabled = !model.busy) {
Text("Sincronizar ahora")
}
}
}
}
if (model.acts.isEmpty()) { if (model.acts.isEmpty()) {
ElevatedCard(Modifier.fillMaxWidth()) { ElevatedCard(Modifier.fillMaxWidth()) {
Column( Column(
@@ -171,12 +186,8 @@ fun ModernMobileActsScreen(
onClick = { model.selectAct(act.id) }, onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = if (active) 1.dp else 0.dp, tonalElevation = 0.dp,
border = BorderStroke(
if (active) 2.dp else 1.dp,
if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
),
) { ) {
Row( Row(
Modifier.padding(15.dp), Modifier.padding(15.dp),
@@ -195,7 +206,7 @@ fun ModernMobileActsScreen(
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(act.code, fontWeight = FontWeight.Bold) Text(act.code, fontWeight = FontWeight.Bold)
Text( Text(
"${modernActStatusLabel(act.status)} · ${if (act.urgency == "URGENT") "Urgente" else "No urgente"}", "${modernActStatusLabel(act.status)} · ${when (act.urgency) { "URGENT" -> "Urgente"; "NON_URGENT" -> "No urgente"; else -> "Urgencia pendiente" }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -225,36 +236,13 @@ fun ModernMobileActsScreen(
} }
} }
Text("Urgencia", fontWeight = FontWeight.SemiBold) Text(
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { "La urgencia se define recién al cerrar el Acta, cuando ya se conoce el resultado de la inspección.",
FilterChip( style = MaterialTheme.typography.bodySmall,
selected = newActUrgency == "NON_URGENT", color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick = { newActUrgency = "NON_URGENT" }, )
label = { Text("No urgente") },
)
FilterChip(
selected = newActUrgency == "URGENT",
onClick = { newActUrgency = "URGENT" },
label = { Text("Urgente") },
)
}
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido."
} else {
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
},
Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
Button( Button(
onClick = { model.createAct(newActUrgency) }, onClick = { model.createAct() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -267,33 +255,13 @@ fun ModernMobileActsScreen(
} }
if (selected != null) { 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(if (selected.urgency == "URGENT") "Urgente" else "No urgente", 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) { when (selected.status) {
"DRAFT" -> { "DRAFT" -> {
ElevatedCard(Modifier.fillMaxWidth()) { ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text( Text(
"Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.", "Elegí el Yacimiento, una Instalación o una Subinstalación. El Hallazgo siempre queda dentro del Yacimiento de esta Acta.",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
@@ -309,42 +277,25 @@ fun ModernMobileActsScreen(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary) Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
} }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text(
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") }) "Identificá a la persona que acompaña el recorrido. Al finalizar esta Acta podrá firmar conforme, firmar en disconformidad o negarse a firmar.",
FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") }) style = MaterialTheme.typography.bodySmall,
} color = MaterialTheme.colorScheme.onSurfaceVariant,
if (attendance == "PRESENT") { )
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind -> OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) }) OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
} if (closure?.responsible == null && closure?.representativeSuggestion != null) {
} Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
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") }
} }
Button(
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar representante") }
} }
} }
@@ -356,90 +307,156 @@ fun ModernMobileActsScreen(
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
} }
Text( Text(
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.", "Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet sólo firma el acompañante de la empresa. La urgencia pertenece al Acta completa. La urgencia no se asigna a cada Hallazgo.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
actNarrative,
{ actNarrative = it },
label = { Text("Descripción de lo actuado *") },
supportingText = { Text("Relatá la inspección y sus observaciones. Se incorpora al acta antes de sellarla.") },
minLines = 4,
modifier = Modifier.fillMaxWidth(),
)
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, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Button( Button(
onClick = { model.prepareSelectedAct() }, onClick = { model.prepareSelectedAct(closingUrgency, actNarrative) },
enabled = !model.busy && closure?.responsible != null, enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank() && actNarrative.trim().length in 10..20000,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta y dejar pendiente de firma") } ) { Text("Cerrar contenido y pasar a firma") }
} }
} }
} }
"LOCKED" -> { "LOCKED" -> {
val signatures = closure?.signatures.orEmpty() val signatures = closure?.signatures.orEmpty()
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" val companyResolvedOnServer = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
val companyResolved = companyResolvedOnServer || model.selectedActCompanySignaturePending
ElevatedCard(Modifier.fillMaxWidth()) { ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary) Icon(Icons.Filled.EditNote, null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
} }
Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.") Text(
closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) } "El contenido ya está cerrado e inmutable. Ahora corresponde la manifestación y firma del acompañante de la empresa.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
HorizontalDivider() )
Text("Firma del inspector", fontWeight = FontWeight.Bold) when (model.inspectorSignatureConfigured) {
if (inspectorSigned) { true -> Surface(
SuccessLine("Firma del inspector registrada") modifier = Modifier.fillMaxWidth(),
} else { shape = MaterialTheme.shapes.medium,
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall) color = MaterialTheme.colorScheme.primaryContainer,
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)) { Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error) Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Text( Text(
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.", "Firma del inspector configurada en Mi perfil. Se aplicará automáticamente al cierre definitivo de esta Acta.",
Modifier.weight(1f), Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
} }
false -> Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.errorContainer,
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
Text(
"Falta configurar la firma del inspector en Mi perfil del Dashboard. La firma de la empresa puede guardarse igual; el cierre quedará pendiente hasta completar este requisito.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedButton(onClick = { model.refreshInspectorSignatureStatus() }, enabled = !model.busy) {
Text("Volver a verificar")
}
}
}
null -> Text(
"La firma del inspector se validará con el Dashboard al sincronizar.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider()
Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolvedOnServer) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
"REFUSED" -> "Se negó a firmar"
"ABSENT" -> "Ausencia documentada"
else -> humanBackendLabelEs(companyOutcome.status)
}
SuccessLine(detail)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (model.selectedActCompanySignaturePending) {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
SuccessLine("Firma guardada en el dispositivo")
Text(
"Todavía no llegó al servidor. Podés continuar; se enviará automáticamente cuando haya conexión.",
style = MaterialTheme.typography.bodySmall,
)
}
}
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
Text(
"Esta Acta conserva un registro histórico de ausencia. El flujo actual requiere identificar al acompañante antes de pasar el contenido a firma.",
Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
} else { } else {
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)) {
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") }) FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") }) FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disconformidad") })
} }
if (manifestation == "DISSENT") { if (manifestation == "DISSENT") {
OutlinedTextField( OutlinedTextField(
dissentStatement, dissentStatement,
{ dissentStatement = it }, { dissentStatement = it },
label = { Text("Manifestación de disidencia *") }, label = { Text("Motivo de disconformidad *") },
minLines = 2, minLines = 2,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
SignaturePad( SignaturePad(
label = "Firma del responsable de empresa", label = "Firma del representante de la empresa (acompañante)",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) }, onCaptured = { file -> signCompanyWithGeo(file) },
) )
Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall) Text("Si se niega a firmar, asentá el motivo para dejar la constancia.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField( OutlinedTextField(
refusalReason, refusalReason,
{ refusalReason = it }, { refusalReason = it },
@@ -449,24 +466,34 @@ fun ModernMobileActsScreen(
) )
OutlinedButton( OutlinedButton(
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) }, onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10, enabled = !model.busy && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") } ) { Text("Registrar negativa a firmar") }
} }
if (inspectorSigned && companyResolved) { if (companyResolved) {
Button( if (model.selectedActSealPending) {
onClick = { model.closeSelectedAct() }, Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
enabled = !model.busy, Text(
modifier = Modifier.fillMaxWidth(), "Cierre guardado en este dispositivo · pendiente de sincronización",
) { Modifier.padding(12.dp),
Icon(Icons.Filled.CheckCircle, null) fontWeight = FontWeight.SemiBold,
Spacer(Modifier.width(6.dp)) )
Text("Firmar y cerrar Acta") }
} else {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text(if (model.inspectorSignatureConfigured == false) "Guardar cierre pendiente" else "Firmar y cerrar Acta")
}
} }
} else { } else {
Text( Text(
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.", "Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer
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.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -36,6 +37,11 @@ import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -57,6 +63,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable 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.focus.FocusDirection
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -76,6 +83,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -99,14 +107,15 @@ private data class ModernGeoSnapshot(
@Composable @Composable
fun ModernVisitRoot(model: MainViewModel) { fun ModernVisitRoot(model: MainViewModel) {
val visit = model.visit ?: return val visit = model.visit ?: return
var screenName by rememberSaveable(visit.id) { mutableStateOf(ModernVisitScreen.OVERVIEW.name) } var screenName by rememberSaveable(visit.id) {
mutableStateOf(if (visit.status == "IN_PROGRESS") ModernVisitScreen.ACTS.name else ModernVisitScreen.OVERVIEW.name)
}
val screen = runCatching { ModernVisitScreen.valueOf(screenName) }.getOrDefault(ModernVisitScreen.OVERVIEW) val screen = runCatching { ModernVisitScreen.valueOf(screenName) }.getOrDefault(ModernVisitScreen.OVERVIEW)
when (screen) { when (screen) {
ModernVisitScreen.OVERVIEW -> ModernVisitOverview( ModernVisitScreen.OVERVIEW -> ModernVisitOverview(
model = model, model = model,
onActs = { screenName = ModernVisitScreen.ACTS.name }, onActs = { screenName = ModernVisitScreen.ACTS.name },
onInventory = { screenName = ModernVisitScreen.INVENTORY.name },
) )
ModernVisitScreen.ACTS -> ModernMobileActsScreen( ModernVisitScreen.ACTS -> ModernMobileActsScreen(
model = model, model = model,
@@ -124,7 +133,6 @@ fun ModernVisitRoot(model: MainViewModel) {
private fun ModernVisitOverview( private fun ModernVisitOverview(
model: MainViewModel, model: MainViewModel,
onActs: () -> Unit, onActs: () -> Unit,
onInventory: () -> Unit,
) { ) {
val visit = model.visit ?: return val visit = model.visit ?: return
Column( Column(
@@ -183,7 +191,7 @@ private fun ModernVisitOverview(
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Listo para iniciar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Listo para iniciar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text( Text(
"Al iniciar se habilitan Actas, Hallazgos y el Inventario de campo.", "Al iniciar se habilitan Actas y Hallazgos.",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Button( Button(
@@ -217,25 +225,6 @@ private fun ModernVisitOverview(
} }
} }
} }
ElevatedCard(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Icon(Icons.Filled.Factory, null, Modifier.padding(11.dp), tint = MaterialTheme.colorScheme.secondary)
}
Column(Modifier.weight(1f)) {
Text("Inventario de campo", fontWeight = FontWeight.Bold)
Text(
"Buscar existente o cargar Instalación / Subinstalación",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else if (visit.status == "CLOSED") { } else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") } OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") }
} }
@@ -280,6 +269,7 @@ private fun ModernChecklistCard(visit: VisitDetail) {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) { private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
@@ -300,6 +290,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") } var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
var familyExpanded by remember { mutableStateOf(false) }
val attributeValues = remember { mutableStateMapOf<String, String>() } val attributeValues = remember { mutableStateMapOf<String, String>() }
var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) } var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) }
var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
@@ -316,6 +307,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
selectedTypeId = null selectedTypeId = null
selectedFamilyId = null selectedFamilyId = null
familySearch = "" familySearch = ""
familyExpanded = false
attributeValues.clear() attributeValues.clear()
localError = null localError = null
} }
@@ -400,6 +392,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId } val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val editableAttributes = selectedType?.attributes.orEmpty().filterNot {
selectedType?.familyRequired == true && it.code == "tipo_instalacion"
}
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId } val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
val filteredFamilies = remember(selectedType, familySearch) { val filteredFamilies = remember(selectedType, familySearch) {
val needle = modernNormalize(familySearch) val needle = modernNormalize(familySearch)
@@ -533,7 +528,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
ModernInventoryMode.PICK_PARENT -> "Elegir Instalación" ModernInventoryMode.PICK_PARENT -> "Elegir Instalación"
ModernInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación" ModernInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación"
ModernInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación" ModernInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación"
ModernInventoryMode.BROWSE -> "Inventario de campo" ModernInventoryMode.BROWSE -> "Ubicación del Hallazgo"
}, },
subtitle = visit.scopeAsset?.name ?: visit.code, subtitle = visit.scopeAsset?.name ?: visit.code,
onBack = { if (mode == ModernInventoryMode.BROWSE) onBack() else backToBrowse() }, onBack = { if (mode == ModernInventoryMode.BROWSE) onBack() else backToBrowse() },
@@ -767,37 +762,56 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
if (selectedType?.familyRequired == true) { if (selectedType?.familyRequired == true) {
Text("Clasificación técnica", fontWeight = FontWeight.Bold) ExposedDropdownMenuBox(
OutlinedTextField( expanded = familyExpanded && !model.busy,
value = familySearch, onExpandedChange = {
onValueChange = { familySearch = it }, if (!model.busy) {
label = { Text("Buscar clasificación") }, familyExpanded = it
leadingIcon = { Icon(Icons.Filled.Search, null) }, familySearch = ""
supportingText = { Text("${filteredFamilies.size} opciones compatibles") }, }
modifier = Modifier.fillMaxWidth(), },
singleLine = true, ) {
) OutlinedTextField(
filteredFamilies.take(12).forEach { family -> value = if (familyExpanded) familySearch else selectedFamily?.name.orEmpty(),
Surface( onValueChange = { familySearch = it; familyExpanded = true },
onClick = { selectedFamilyId = family.id }, label = { Text(if (isSubinstallation) "Tipo de subinstalación" else "Tipo de instalación") },
modifier = Modifier.fillMaxWidth(), placeholder = { Text("Buscar por nombre o código") },
shape = MaterialTheme.shapes.medium, leadingIcon = { Icon(Icons.Filled.Search, null) },
color = if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = familyExpanded) },
border = BorderStroke(1.dp, if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant), enabled = !model.busy,
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = {
familyExpanded = false
focusManager.moveFocus(FocusDirection.Down)
}),
)
ExposedDropdownMenu(
expanded = familyExpanded && !model.busy,
onDismissRequest = { familyExpanded = false; familySearch = "" },
modifier = Modifier.heightIn(max = 280.dp),
) { ) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { if (filteredFamilies.isEmpty()) {
Text( DropdownMenuItem(text = { Text("Sin coincidencias") }, onClick = {}, enabled = false)
(if (family.isOther) "Otro · " else "") + family.name, }
Modifier.weight(1f), filteredFamilies.forEach { family ->
fontWeight = if (selectedFamilyId == family.id) FontWeight.Bold else FontWeight.Normal, DropdownMenuItem(
text = { Text((if (family.isOther) "Otro · " else "") + family.name) },
trailingIcon = {
if (selectedFamilyId == family.id) Icon(Icons.Filled.CheckCircle, "Seleccionado")
},
onClick = {
selectedFamilyId = family.id
familyExpanded = false
familySearch = ""
keyboard?.hide()
focusManager.clearFocus()
},
) )
if (selectedFamilyId == family.id) Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
} }
} }
} }
if (filteredFamilies.size > 12) {
Text("Seguí escribiendo para reducir la lista.", style = MaterialTheme.typography.bodySmall)
}
selectedFamily?.let { family -> selectedFamily?.let { family ->
if (family.isOther) { if (family.isOther) {
Text("Quedará marcado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary) Text("Quedará marcado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary)
@@ -812,9 +826,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Nombre técnico *") }, label = { Text("Nombre técnico *") },
supportingText = { Text("Usá la denominación técnica del elemento. El código DH se genera automáticamente.") }, placeholder = { Text("Denominación técnica") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
OutlinedTextField( OutlinedTextField(
value = commonName, value = commonName,
@@ -822,17 +838,24 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
label = { Text("Nombre habitual (opcional)") }, label = { Text("Nombre habitual (opcional)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
OutlinedTextField( OutlinedTextField(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
label = { Text("Descripción (opcional)") }, label = { Text("Descripción (opcional)") },
supportingText = { Text("Dato equivalente al alta desde el panel web.") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
minLines = 2, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = if (editableAttributes.isEmpty()) ImeAction.Done else ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) },
onDone = { keyboard?.hide(); focusManager.clearFocus() },
),
) )
selectedType?.attributes?.forEach { definition -> editableAttributes.forEachIndexed { index, definition ->
val isLastAttribute = index == editableAttributes.lastIndex
OutlinedTextField( OutlinedTextField(
value = attributeValues[definition.code].orEmpty(), value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it }, onValueChange = { attributeValues[definition.code] = it },
@@ -843,8 +866,14 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}, },
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text, keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text,
imeAction = if (isLastAttribute) ImeAction.Done else ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) },
onDone = { keyboard?.hide(); focusManager.clearFocus() },
), ),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true,
) )
} }
@@ -861,10 +890,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
} }
val attributesReady = selectedType?.attributes val attributesReady = editableAttributes
?.filter { it.isRequired } .filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() } .all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button( Button(
onClick = { requestCreate() }, onClick = { requestCreate() },
@@ -912,7 +940,7 @@ private fun ModernInventoryBrowse(
OutlinedTextField( OutlinedTextField(
value = search, value = search,
onValueChange = onSearchChange, onValueChange = onSearchChange,
label = { Text("Buscar Inventario") }, label = { Text("Buscar Yacimiento, Instalación o Subinstalación") },
placeholder = { Text("Nombre, código o dato técnico") }, placeholder = { Text("Nombre, código o dato técnico") },
leadingIcon = { Icon(Icons.Filled.Search, null) }, leadingIcon = { Icon(Icons.Filled.Search, null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
@@ -1095,18 +1123,7 @@ private fun modernNormalize(value: String): String = value.trim().lowercase()
private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status) private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status)
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> = private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition -> fieldAttributePayload(type.attributes, values)
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceModernAttribute(definition, raw)
}.toMap()
private fun coerceModernAttribute(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 modernHasPermission(context: Context, permission: String): Boolean = private fun modernHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -13,7 +13,7 @@ internal fun visitStatusLabelEs(status: String?): String = when (status?.upperca
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) { internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"DRAFT" -> "En elaboración" "DRAFT" -> "En elaboración"
"LOCKED" -> "Pendiente de firma" "LOCKED" -> "Para firmar"
"SEALED" -> "Firmada y cerrada" "SEALED" -> "Firmada y cerrada"
"CLOSED" -> "Cerrada" "CLOSED" -> "Cerrada"
"RECTIFIED" -> "Rectificada" "RECTIFIED" -> "Rectificada"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -10,7 +10,7 @@ class DynamicFieldFlowContractTest {
@Test @Test
fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() { fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() {
assertTrue(source.contains("Elegí la Instalación padre")) assertTrue(source.contains("Elegí la Instalación padre"))
assertTrue(source.contains("Clasificación técnica *")) assertTrue(source.contains("Tipo de instalación *"))
assertTrue(source.contains("Guardar y tomar foto")) assertTrue(source.contains("Guardar y tomar foto"))
assertTrue(source.contains("model.loadFieldTypes(item.id)")) assertTrue(source.contains("model.loadFieldTypes(item.id)"))
assertTrue(source.contains("parentId = item.id")) assertTrue(source.contains("parentId = item.id"))
@@ -0,0 +1,29 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FieldAttributePayloadTest {
@Test
fun payloadUsesDefinitionIdsExpectedByApiAndKeepsTypedValues() {
val definitions = listOf(
FieldAttributeDefinition("id-marca", "campo_marca", "Marca", "TEXT"),
FieldAttributeDefinition("id-capacidad", "campo_capacidad", "Capacidad", "NUMBER"),
FieldAttributeDefinition("id-serie", "campo_numero_serie", "Número de serie", "TEXT"),
)
val payload = fieldAttributePayload(
definitions,
mapOf("campo_marca" to " algo ", "campo_capacidad" to "37,73", "campo_numero_serie" to " "),
)
assertEquals("algo", payload["id-marca"])
assertEquals(37.73, payload["id-capacidad"])
assertFalse(payload.containsKey("id-serie"))
assertTrue(payload.keys.none { it.startsWith("campo_") })
}
}
@@ -20,8 +20,15 @@ class FinalFieldFlowContractTest {
assertTrue(visit.contains("Instalación")) assertTrue(visit.contains("Instalación"))
assertTrue(visit.contains("Subinstalación")) assertTrue(visit.contains("Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto")) assertTrue(visit.contains("Guardar y tomar foto"))
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma")) assertTrue(acts.contains("Cerrar contenido y pasar a firma"))
assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\"")) assertTrue(acts.contains("Nombres y apellidos *"))
assertTrue(acts.contains("Cargo / función *"))
assertTrue(acts.contains("Email *"))
assertTrue(acts.contains("En disconformidad"))
assertTrue(acts.contains("Motivo de disconformidad *"))
assertTrue(acts.contains("urgencia pertenece al Acta completa"))
assertTrue(acts.contains("urgencia no se asigna a cada Hallazgo"))
assertTrue(labels.contains("\"LOCKED\" -> \"Para firmar\""))
} }
@Test @Test
@@ -45,7 +52,9 @@ class FinalFieldFlowContractTest {
fun currentFieldUiDoesNotExposeBackendEnglishStates() { fun currentFieldUiDoesNotExposeBackendEnglishStates() {
assertTrue(home.contains("visitStatusLabelEs(visit.status)")) assertTrue(home.contains("visitStatusLabelEs(visit.status)"))
assertTrue(findings.contains("findingStatusLabelEs(finding.status)")) assertTrue(findings.contains("findingStatusLabelEs(finding.status)"))
assertTrue(acts.contains("documentTypeLabelEs(kind)")) assertTrue(acts.contains("DNI *"))
assertFalse(acts.contains("PASSPORT"))
assertFalse(acts.contains("OTHER"))
assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\"")) assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\""))
assertTrue(labels.contains("\"OTHER\" -> \"Otro\"")) assertTrue(labels.contains("\"OTHER\" -> \"Otro\""))
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta")) assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
@@ -54,4 +63,14 @@ class FinalFieldFlowContractTest {
assertFalse(acts.contains("Acta SELLADA")) assertFalse(acts.contains("Acta SELLADA"))
assertFalse(acts.contains("Text(\"Email\")")) assertFalse(acts.contains("Text(\"Email\")"))
} }
@Test
fun findingCreationIsCompactSearchableAndHasNoCorrectionDate() {
assertTrue(findings.contains("ExposedDropdownMenuBox"))
assertTrue(findings.contains("Tipo de Hallazgo *"))
assertTrue(findings.contains("Buscar o seleccionar"))
assertTrue(findings.contains("Otro / No está en la lista"))
assertFalse(findings.contains("Fecha de corrección"))
assertFalse(findings.contains("Hallazgos del modelo autoritativo"))
}
} }
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test @Test
fun debugBuildKeepsSeparateApplicationIdentity() { fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(29, BuildConfig.VERSION_CODE) assertEquals(41, BuildConfig.VERSION_CODE)
assertEquals("0.19.1-debug", BuildConfig.VERSION_NAME) assertEquals("0.19.13-debug", BuildConfig.VERSION_NAME)
} }
@Test @Test
+1
View File
@@ -2,4 +2,5 @@ plugins {
id("com.android.application") version "8.13.2" apply false id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
id("org.jetbrains.kotlin.kapt") version "2.2.20" apply false
} }
+2
View File
@@ -4,6 +4,7 @@ COPY package*.json ./
RUN npm ci RUN npm ci
COPY nest-cli.json tsconfig.json ./ COPY nest-cli.json tsconfig.json ./
COPY src ./src COPY src ./src
COPY assets ./assets
RUN npm run build RUN npm run build
FROM node:24-alpine AS runner FROM node:24-alpine AS runner
@@ -13,6 +14,7 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/assets ./assets
RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage
USER node USER node
EXPOSE 3000 EXPOSE 3000
Binary file not shown.
Binary file not shown.
+78
View File
@@ -0,0 +1,78 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: DejaVu fonts
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
Source: https://dejavu-fonts.github.io/
Files: *
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
Bitstream Vera is a trademark of Bitstream, Inc.
DejaVu changes are in public domain.
License: bitstream-vera
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
.
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
.
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Files: debian/*
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
License: GPL-2+
This program is free software; you can redistribute it
and/or modify it under the terms of the GNU General Public
License as published by the Free Software Foundation; either
version 2 of the License, or (at your option) any later
version.
.
This program is distributed in the hope that it will be
useful, but WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
PURPOSE. See the GNU General Public License for more
details.
.
You should have received a copy of the GNU General Public
License along with this package; if not, write to the Free
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
Boston, MA 02110-1301 USA
.
On Debian systems, the full text of the GNU General Public
License version 2 can be found in the file
/usr/share/common-licenses/GPL-2'.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+742 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.29.0-17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.29.0-17",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
@@ -21,9 +21,11 @@
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"pdfkit": "^0.20.2",
"pg": "^8.0.0", "pg": "^8.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"sharp": "^0.35.4",
"typeorm": "^0.3.0" "typeorm": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
@@ -32,6 +34,7 @@
"@types/cookie-parser": "^1.4.9", "@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pdfkit": "^0.17.6",
"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"
@@ -216,6 +219,16 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@epic-web/invariant": { "node_modules/@epic-web/invariant": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
@@ -664,6 +677,506 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@inquirer/ansi": { "node_modules/@inquirer/ansi": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
@@ -1459,6 +1972,30 @@
"typeorm": "^0.3.0 || ^1.0.0-dev" "typeorm": "^0.3.0 || ^1.0.0-dev"
} }
}, },
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@phc/format": { "node_modules/@phc/format": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -1484,6 +2021,15 @@
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tokenizer/inflate": { "node_modules/@tokenizer/inflate": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
@@ -1659,6 +2205,16 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.15.1", "version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -2185,6 +2741,15 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -2805,6 +3370,21 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff": { "node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
@@ -3156,7 +3736,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
@@ -3189,6 +3768,12 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-type": { "node_modules/file-type": {
"version": "21.3.4", "version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
@@ -3228,6 +3813,32 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fontkit/node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -3906,6 +4517,25 @@
"integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==", "integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -4388,6 +5018,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4475,6 +5111,20 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/pdfkit": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz",
"integrity": "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.3.0",
"@noble/hashes": "^1.8.0",
"fflate": "^0.8.3",
"fontkit": "^2.0.4",
"linebreak": "^1.1.0",
"png-js": "^2.0.0"
}
},
"node_modules/pg": { "node_modules/pg": {
"version": "8.23.0", "version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
@@ -4594,6 +5244,14 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/png-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -4793,6 +5451,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/router": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -4997,6 +5661,55 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5437,6 +6150,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/to-buffer": { "node_modules/to-buffer": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
@@ -5887,6 +6606,26 @@
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/universalify": { "node_modules/universalify": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+4 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.29.0-17",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -28,9 +28,11 @@
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"pdfkit": "^0.20.2",
"pg": "^8.0.0", "pg": "^8.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"sharp": "^0.35.4",
"typeorm": "^0.3.0" "typeorm": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
@@ -39,6 +41,7 @@
"@types/cookie-parser": "^1.4.9", "@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pdfkit": "^0.17.6",
"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"
@@ -1,14 +1,9 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import { Controller, Get, Param, ParseUUIDPipe, Query, Res } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express'; import type { Response } from 'express';
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 { ActAdministrationService } from './act-administration.service';
import { ActAdministrationService, MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from './act-administration.service';
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto'; import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
@Controller('act-administration') @Controller('act-administration')
export class ActAdministrationQueueController { export class ActAdministrationQueueController {
@@ -21,11 +16,8 @@ export class ActAdministrationQueueController {
export class ActAdministrationController { export class ActAdministrationController {
constructor(private readonly administration: ActAdministrationService) {} constructor(private readonly administration: ActAdministrationService) {}
@Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); } @Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); }
@Patch('deadline') @RequirePermissions('inspection_findings.follow_up') // Historic deadlines and responses remain readable here.
setDeadline(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: SetActResponseDeadlineDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.setDeadline(actId, dto, principal, request); } // New administrative entries are recorded on the related Informe.
@Post('responses') @RequirePermissions('inspection_findings.follow_up')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 } }))
addResponse(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CreateActCompanyResponseDto, @UploadedFile() file: UploadedActResponseFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.addResponse(actId, dto, file, principal, request); }
} }
@Controller('act-company-responses') @Controller('act-company-responses')
@@ -18,5 +18,6 @@ import { FieldBriefingService } from './field-briefing.service';
FieldBriefingController, FieldBriefingController,
], ],
providers: [ActAdministrationService, FieldBriefingService], providers: [ActAdministrationService, FieldBriefingService],
exports: [ActAdministrationService],
}) })
export class ActAdministrationModule {} export class ActAdministrationModule {}
@@ -114,17 +114,46 @@ export class ActAdministrationService {
return act; return act;
} }
async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) { async setDeadline(
actId: string,
dto: SetActResponseDeadlineDto,
principal: AuthPrincipal,
request: RequestWithContext,
reportId: string | null = null,
) {
const act = await this.ensureClosedAct(actId); const act = await this.ensureClosedAct(actId);
const rows = await this.dataSource.query( return this.dataSource.transaction(async (manager) => {
`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id, response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`, const rows = await manager.query(
[randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId], `INSERT INTO inspection_act_deadline_events (id, act_id, report_id, response_due_on, reason, created_by)
); VALUES ($1,$2,$3,$4,$5,$6)
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, responseDueOn: dto.responseDueOn, reason: dto.reason } }); RETURNING id, report_id AS "reportId", response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
return rows[0]; [randomUUID(), actId, reportId, dto.responseDueOn, dto.reason, principal.userId],
);
const projected = await manager.query(
`UPDATE inspection_findings
SET correction_due_on=$2, updated_by=$3, updated_at=CURRENT_TIMESTAMP
WHERE act_id=$1 AND status<>'VOIDED'
RETURNING id`,
[actId, dto.responseDueOn, principal.userId],
) as Array<{ id: string }>;
await this.audit.record({
actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET',
entityType: 'inspection_act', entityId: actId, requestId: request.requestId,
afterData: { actCode: act.code, reportId, responseDueOn: dto.responseDueOn, reason: dto.reason },
metadata: { reportId, projectedFindingCount: projected.length, sharedDeadline: true },
}, manager);
return { ...rows[0], projectedFindingCount: projected.length };
});
} }
async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) { async addResponse(
actId: string,
dto: CreateActCompanyResponseDto,
file: UploadedActResponseFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
reportId: string | null = null,
) {
const act = await this.ensureClosedAct(actId); const act = await this.ensureClosedAct(actId);
let storedName: string | null = null; let storedName: string | null = null;
let sha256: string | null = null; let sha256: string | null = null;
@@ -138,12 +167,12 @@ export class ActAdministrationService {
} }
try { try {
const rows = await this.dataSource.query( const rows = await this.dataSource.query(
`INSERT INTO inspection_act_company_responses (id, act_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by) `INSERT INTO inspection_act_company_responses (id, act_id, report_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`, RETURNING id, report_id AS "reportId", received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
[randomUUID(), actId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId], [randomUUID(), actId, reportId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
); );
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 } }); await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, reportId, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 }, metadata: { reportId } });
return rows[0]; return rows[0];
} catch (error) { } catch (error) {
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined); if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
@@ -151,8 +180,13 @@ export class ActAdministrationService {
} }
} }
async responseContent(responseId: string) { async responseContent(responseId: string, reportId?: string) {
const rows = await this.dataSource.query(`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes" FROM inspection_act_company_responses WHERE id = $1 AND stored_name IS NOT NULL`, [responseId]); const rows = await this.dataSource.query(
`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes"
FROM inspection_act_company_responses
WHERE id=$1 AND stored_name IS NOT NULL AND ($2::uuid IS NULL OR report_id=$2::uuid)`,
[responseId, reportId ?? null],
);
const row = rows[0]; const row = rows[0];
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' }); if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
const filePath = join(STORAGE, row.storedName); const filePath = join(STORAGE, row.storedName);
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { PhaseADataModule } from '../core-data/phase-a-data.module'; import { PhaseADataModule } from '../core-data/phase-a-data.module';
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
import { RolesController } from './roles/roles.controller'; import { RolesController } from './roles/roles.controller';
import { RolesService } from './roles/roles.service'; import { RolesService } from './roles/roles.service';
import { UsersController } from './users/users.controller'; import { UsersController } from './users/users.controller';
import { UsersService } from './users/users.service'; import { UsersService } from './users/users.service';
@Module({ @Module({
imports: [PhaseADataModule, AuditModule, AuthModule], imports: [PhaseADataModule, AuditModule, AuthModule, InspectionReportsModule],
controllers: [UsersController, RolesController], controllers: [UsersController, RolesController],
providers: [UsersService, RolesService], providers: [UsersService, RolesService],
}) })
@@ -28,10 +28,9 @@ export class CreateUserDto {
@Transform(({ value }) => @Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null, typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
) )
@IsOptional()
@IsEmail() @IsEmail()
@MaxLength(320) @MaxLength(320)
email?: string | null; email!: string;
@Transform(({ value }) => @Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null, typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
@@ -0,0 +1,30 @@
import { Transform } from 'class-transformer';
import {
IsEmail,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class UpdateSelfProfileDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsEmail()
@MaxLength(320)
email!: string;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(160)
jobTitle?: string | null;
}
@@ -0,0 +1,65 @@
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
import { SmtpSecurityMode } from '../../../database/entities';
export enum UserSmtpMode {
SYSTEM = 'SYSTEM',
CUSTOM = 'CUSTOM',
}
const trimmed = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export class UpdateUserSmtpSettingsDto {
@IsEnum(UserSmtpMode)
mode!: UserSmtpMode;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@Transform(trimmed)
@IsString()
@MinLength(1)
@MaxLength(255)
host?: string;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@IsInt()
@Min(1)
@Max(65535)
port?: number;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@IsEnum(SmtpSecurityMode)
securityMode?: SmtpSecurityMode;
@IsOptional()
@Transform(trimmed)
@IsString()
@MaxLength(255)
username?: string | null;
@IsOptional()
@IsString()
@MaxLength(512)
password?: string | null;
@IsOptional()
@Transform(trimmed)
@IsString()
@MaxLength(200)
fromName?: string | null;
@IsOptional()
@IsBoolean()
enabled = true;
}
@@ -1,6 +1,7 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
@@ -9,24 +10,43 @@ import {
Put, Put,
Query, Query,
Req, Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { AuditService } from '../../audit/audit.service';
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator'; import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
import type { import type {
AuthPrincipal, AuthPrincipal,
RequestWithContext, RequestWithContext,
} from '../../common/http/request-context'; } from '../../common/http/request-context';
import { AuditAction } from '../../database/entities';
import { SmtpDeliveryService } from '../../inspection-reports/smtp-delivery.service';
import {
MAX_INSPECTION_SIGNATURE_BYTES,
type UploadedInspectionSignatureFile,
} from '../../inspection-closing/inspection-signature-file';
import { administrationAuditContext } from '../common/administration-audit';
import { ChangeUserStatusDto } from './dto/change-user-status.dto'; import { ChangeUserStatusDto } from './dto/change-user-status.dto';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto'; import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto'; import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import { ResetUserPasswordDto } from './dto/reset-user-password.dto'; import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
import { UpdateUserSmtpSettingsDto } from './dto/update-user-smtp-settings.dto';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
@Controller('users') @Controller('users')
export class UsersController { export class UsersController {
constructor(private readonly users: UsersService) {} constructor(
private readonly users: UsersService,
private readonly smtp: SmtpDeliveryService,
private readonly audit: AuditService,
) {}
@Get() @Get()
@RequirePermissions('users.read') @RequirePermissions('users.read')
@@ -34,6 +54,112 @@ export class UsersController {
return this.users.list(query); return this.users.list(query);
} }
@Get('self/profile')
selfProfile(@CurrentAuth() principal: AuthPrincipal) {
return this.users.getSelfProfile(principal.userId);
}
@Patch('self/profile')
updateSelfProfile(
@Body() dto: UpdateSelfProfileDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.updateSelfProfile(dto, principal, request);
}
@Get('self/signature')
selfSignature(@CurrentAuth() principal: AuthPrincipal) {
return this.users.getSelfSignature(principal.userId);
}
@Get('self/signature/content')
async selfSignatureContent(
@CurrentAuth() principal: AuthPrincipal,
@Res() response: Response,
): Promise<void> {
const signature = await this.users.getSelfSignatureContent(principal.userId);
response.setHeader('Content-Type', 'image/png');
response.setHeader('Content-Length', String(signature.buffer.length));
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('ETag', `"${signature.sha256}"`);
response.setHeader('X-Content-Type-Options', 'nosniff');
response.send(signature.buffer);
}
@Put('self/signature')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 },
}))
updateSelfSignature(
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.updateSelfSignature(file, principal, request);
}
@Delete('self/signature')
deleteSelfSignature(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.deleteSelfSignature(principal, request);
}
@Get('self/smtp')
selfSmtp(@CurrentAuth() principal: AuthPrincipal) {
return this.smtp.publicUserSettings(principal.userId);
}
@Put('self/smtp')
async updateSelfSmtp(
@Body() dto: UpdateUserSmtpSettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
const before = await this.smtp.publicUserSettings(principal.userId);
const after = await this.smtp.saveUserSettings(principal.userId, dto);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_SMTP_SETTINGS_UPDATED,
entityType: 'user_smtp_settings',
entityId: principal.userId,
beforeData: before as Record<string, unknown>,
afterData: after as Record<string, unknown>,
metadata: { passwordNeverReturned: true, scope: 'SELF' },
});
return after;
}
@Post('self/smtp/test')
async testSelfSmtp(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
const profile = await this.users.getSelfProfile(principal.userId);
if (!profile.email) throw new Error('El usuario no tiene email configurado');
const sent = await this.smtp.send({
to: profile.email,
subject: 'DH Inspección · Prueba de correo personal',
text: 'Este correo confirma que tu configuración de correo en DH Inspección funciona correctamente.',
attachment: {
filename: 'dh-inspeccion-prueba-correo.txt',
mimeType: 'text/plain',
content: Buffer.from('DH Inspección · Correo personal OK\n', 'utf8'),
},
}, principal.userId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_SMTP_TEST_SENT,
entityType: 'user_smtp_settings',
entityId: principal.userId,
afterData: { recipient: profile.email, messageId: sent.messageId },
metadata: { scope: 'SELF' },
});
return { ok: true, recipient: profile.email, messageId: sent.messageId };
}
@Post() @Post()
@RequirePermissions('users.create') @RequirePermissions('users.create')
create( create(
+131 -16
View File
@@ -1,6 +1,8 @@
import { createHash } from 'node:crypto';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -29,7 +31,12 @@ import type { CreateUserDto } from './dto/create-user.dto';
import type { ListUsersQueryDto } from './dto/list-users-query.dto'; import type { ListUsersQueryDto } from './dto/list-users-query.dto';
import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto'; import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto'; import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import type { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
import type { UpdateUserDto } from './dto/update-user.dto'; import type { UpdateUserDto } from './dto/update-user.dto';
import {
inspectInspectionSignatureFile,
type UploadedInspectionSignatureFile,
} from '../../inspection-closing/inspection-signature-file';
export interface UserRoleView { export interface UserRoleView {
id: string; id: string;
@@ -37,6 +44,14 @@ export interface UserRoleView {
name: string; name: string;
} }
export interface UserReusableSignatureView {
configured: boolean;
mimeType: 'image/png' | null;
sizeBytes: number | null;
imageSha256: string | null;
updatedAt: Date | null;
}
export interface AdministrativeUserView { export interface AdministrativeUserView {
id: string; id: string;
username: string; username: string;
@@ -56,6 +71,7 @@ export interface AdministrativeUserView {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
roles: UserRoleView[]; roles: UserRoleView[];
smtpMode: 'SYSTEM' | 'CUSTOM';
} }
interface UserViewRow extends AdministrativeUserView { interface UserViewRow extends AdministrativeUserView {
@@ -76,10 +92,10 @@ function roleSelectionInvalid(): BadRequestException {
}); });
} }
function inspectorEmailRequired(): BadRequestException { function userEmailRequired(): BadRequestException {
return new BadRequestException({ return new BadRequestException({
code: 'INSPECTOR_EMAIL_REQUIRED', code: 'USER_EMAIL_REQUIRED',
message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones', message: 'Cada usuario de Hidrocarburos debe tener un email válido',
}); });
} }
@@ -144,6 +160,7 @@ export class UsersService {
user_account.password_changed_at AS "passwordChangedAt", user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt", user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt", user_account.updated_at AS "updatedAt",
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
COALESCE( COALESCE(
JSONB_AGG( JSONB_AGG(
JSONB_BUILD_OBJECT( JSONB_BUILD_OBJECT(
@@ -185,6 +202,105 @@ export class UsersService {
); );
} }
async getSelfProfile(userId: string): Promise<AdministrativeUserView> {
return this.getById(userId);
}
async getSelfSignature(userId: string): Promise<UserReusableSignatureView> {
const [row] = (await this.dataSource.query(`
SELECT mime_type AS "mimeType", size_bytes AS "sizeBytes",
image_sha256 AS "imageSha256", updated_at AS "updatedAt"
FROM user_signature_profiles WHERE user_id=$1
`, [userId])) as Array<{
mimeType: 'image/png'; sizeBytes: number; imageSha256: string; updatedAt: Date;
}>;
return row ? { configured: true, ...row } : {
configured: false, mimeType: null, sizeBytes: null, imageSha256: null, updatedAt: null,
};
}
async getSelfSignatureContent(userId: string): Promise<{ buffer: Buffer; sha256: string }> {
const [row] = (await this.dataSource.query(`
SELECT image_data AS buffer, image_sha256 AS sha256
FROM user_signature_profiles WHERE user_id=$1
`, [userId])) as Array<{ buffer: Buffer; sha256: string }>;
if (!row) throw new NotFoundException({
code: 'USER_SIGNATURE_NOT_CONFIGURED',
message: 'Todavía no cargaste tu firma de inspector',
});
return row;
}
async updateSelfSignature(
file: UploadedInspectionSignatureFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<UserReusableSignatureView> {
if (!principal.roles.includes('inspector')) throw new ForbiddenException({
code: 'INSPECTOR_SIGNATURE_ROLE_REQUIRED',
message: 'La firma reutilizable está disponible para usuarios con rol Inspector',
});
const inspected = inspectInspectionSignatureFile(file);
const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex');
const before = await this.getSelfSignature(principal.userId);
await this.dataSource.transaction(async (manager) => {
await manager.query(`
INSERT INTO user_signature_profiles (
user_id,original_name,mime_type,size_bytes,image_sha256,image_data,updated_by
) VALUES ($1,$2,$3,$4,$5,$6,$1)
ON CONFLICT (user_id) DO UPDATE SET
original_name=EXCLUDED.original_name, mime_type=EXCLUDED.mime_type,
size_bytes=EXCLUDED.size_bytes, image_sha256=EXCLUDED.image_sha256,
image_data=EXCLUDED.image_data, updated_by=EXCLUDED.updated_by,
updated_at=CURRENT_TIMESTAMP
`, [principal.userId, inspected.originalName, inspected.mimeType, file!.buffer.length, imageSha256, file!.buffer]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_UPDATED,
entityType: 'user_signature_profile',
entityId: principal.userId,
beforeData: before as unknown as Record<string, unknown>,
afterData: { configured: true, imageSha256, sizeBytes: file!.buffer.length },
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
}, manager);
});
return this.getSelfSignature(principal.userId);
}
async deleteSelfSignature(
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<UserReusableSignatureView> {
const before = await this.getSelfSignature(principal.userId);
await this.dataSource.transaction(async (manager) => {
await manager.query('DELETE FROM user_signature_profiles WHERE user_id=$1', [principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_UPDATED,
entityType: 'user_signature_profile',
entityId: principal.userId,
beforeData: before as unknown as Record<string, unknown>,
afterData: { configured: false },
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
}, manager);
});
return this.getSelfSignature(principal.userId);
}
async updateSelfProfile(
dto: UpdateSelfProfileDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
if (!dto.email?.trim()) throw userEmailRequired();
return this.update(
principal.userId,
{ email: dto.email, phone: dto.phone, jobTitle: dto.jobTitle },
principal,
request,
);
}
async create( async create(
dto: CreateUserDto, dto: CreateUserDto,
principal: AuthPrincipal, principal: AuthPrincipal,
@@ -195,10 +311,10 @@ export class UsersService {
try { try {
return await this.dataSource.transaction(async (manager) => { return await this.dataSource.transaction(async (manager) => {
const roles = await this.resolveRoles(manager, dto.roleIds); const roles = await this.resolveRoles(manager, dto.roleIds);
this.assertInspectorHasEmail(roles, dto.email ?? null); if (!dto.email?.trim()) throw userEmailRequired();
const user = manager.getRepository(User).create({ const user = manager.getRepository(User).create({
username: dto.username.trim().toLowerCase(), username: dto.username.trim().toLowerCase(),
email: dto.email?.trim().toLowerCase() || null, email: dto.email.trim().toLowerCase(),
dni: dto.dni ?? null, dni: dto.dni ?? null,
phone: dto.phone ?? null, phone: dto.phone ?? null,
jobTitle: dto.jobTitle ?? null, jobTitle: dto.jobTitle ?? null,
@@ -268,15 +384,13 @@ export class UsersService {
return await this.dataSource.transaction(async (manager) => { return await this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id); const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id); const before = await this.loadUserView(manager, id);
if (dto.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) { if (dto.email !== undefined && !dto.email) throw userEmailRequired();
throw inspectorEmailRequired();
}
if (dto.username !== undefined) { if (dto.username !== undefined) {
user.username = dto.username.trim().toLowerCase(); user.username = dto.username.trim().toLowerCase();
} }
if (dto.email !== undefined) { if (dto.email !== undefined) {
user.email = dto.email?.trim().toLowerCase() || null; user.email = dto.email!.trim().toLowerCase();
} }
if (dto.dni !== undefined) user.dni = dto.dni ?? null; if (dto.dni !== undefined) user.dni = dto.dni ?? null;
if (dto.phone !== undefined) user.phone = dto.phone ?? null; if (dto.phone !== undefined) user.phone = dto.phone ?? null;
@@ -286,6 +400,13 @@ export class UsersService {
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim(); if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
user.updatedBy = principal.userId; user.updatedBy = principal.userId;
await manager.getRepository(User).save(user); await manager.getRepository(User).save(user);
if (dto.email !== undefined && user.email) {
await manager.query(`
UPDATE user_smtp_settings
SET from_email=$2,reply_to=$2,updated_by=$1,updated_at=CURRENT_TIMESTAMP
WHERE user_id=$1 AND mode='CUSTOM'
`, [id, user.email]);
}
const updated = await this.loadUserView(manager, id); const updated = await this.loadUserView(manager, id);
await this.audit.record( await this.audit.record(
@@ -414,7 +535,6 @@ export class UsersService {
await this.lockUser(manager, id); await this.lockUser(manager, id);
const roles = await this.resolveRoles(manager, dto.roleIds); const roles = await this.resolveRoles(manager, dto.roleIds);
const before = await this.loadUserView(manager, id); const before = await this.loadUserView(manager, id);
this.assertInspectorHasEmail(roles, before.email);
const beforeIds = before.roles.map((role) => role.id).sort(); const beforeIds = before.roles.map((role) => role.id).sort();
const afterIds = roles.map((role) => role.id).sort(); const afterIds = roles.map((role) => role.id).sort();
if (beforeIds.join(',') === afterIds.join(',')) return before; if (beforeIds.join(',') === afterIds.join(',')) return before;
@@ -453,12 +573,6 @@ export class UsersService {
return roles; return roles;
} }
private assertInspectorHasEmail(roles: Role[], email: string | null | undefined): void {
if (roles.some((role) => role.code === 'inspector') && !email?.trim()) {
throw inspectorEmailRequired();
}
}
private async insertUserRoles( private async insertUserRoles(
manager: EntityManager, manager: EntityManager,
userId: string, userId: string,
@@ -511,6 +625,7 @@ export class UsersService {
user_account.password_changed_at AS "passwordChangedAt", user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt", user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt", user_account.updated_at AS "updatedAt",
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
COALESCE( COALESCE(
JSONB_AGG( JSONB_AGG(
JSONB_BUILD_OBJECT( JSONB_BUILD_OBJECT(
@@ -63,3 +63,25 @@ export class MapAssetsController {
return this.geometries.map(query); return this.geometries.map(query);
} }
} }
@Controller('map/context')
export class MapOperationalContextController {
constructor(private readonly geometries: AssetGeometriesService) {}
@Get()
@RequirePermissions('assets.read')
map() {
return this.geometries.mapOperationalContext();
}
}
@Controller('map/documents')
export class MapDocumentsController {
constructor(private readonly geometries: AssetGeometriesService) {}
@Get()
@RequirePermissions('assets.read', 'inspection_acts.read', 'inspection_findings.read')
map() {
return this.geometries.mapDocuments();
}
}
@@ -230,6 +230,8 @@ export class AssetGeometriesService {
geometry: row.geometry, geometry: row.geometry,
properties: { properties: {
id: row.id, id: row.id,
entityId: row.id,
entityKind: 'ASSET',
code: row.code, code: row.code,
name: row.name, name: row.name,
typeId: row.typeId, typeId: row.typeId,
@@ -242,12 +244,230 @@ export class AssetGeometriesService {
accuracyM: row.accuracyM == null ? null : Number(row.accuracyM), accuracyM: row.accuracyM == null ? null : Number(row.accuracyM),
capturedAt: row.capturedAt, capturedAt: row.capturedAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
href: `/inventarios/${row.id}`,
contextLine: row.parentName ? `Depende de ${row.parentName}` : null,
}, },
})), })),
meta: { count: visible.length, truncated }, meta: { count: visible.length, truncated },
}; };
} }
async mapOperationalContext() {
const rows = (await this.dataSource.query(`
WITH RECURSIVE y_tree AS (
SELECT y.id AS yacimiento_id, y.id AS asset_id
FROM assets y
INNER JOIN asset_types y_type ON y_type.id=y.asset_type_id
WHERE lower(y_type.code)='yacimiento'
AND y_type.is_active=true
AND y.information_status<>'INACTIVE'
UNION ALL
SELECT tree.yacimiento_id, child.id
FROM y_tree tree
INNER JOIN assets child ON child.parent_id=tree.asset_id
WHERE child.information_status<>'INACTIVE'
), y_loc AS (
SELECT tree.yacimiento_id,
ST_Centroid(ST_Collect(geometry.geometry)) AS geometry,
MAX(geometry.updated_at) AS updated_at,
COUNT(*)::integer AS source_geometries
FROM y_tree tree
INNER JOIN asset_geometries geometry ON geometry.asset_id=tree.asset_id
GROUP BY tree.yacimiento_id
), base AS (
SELECT
y.id AS yacimiento_id,y.code AS yacimiento_code,y.name AS yacimiento_name,
y.information_status AS yacimiento_status,
area.id AS area_id,area.code AS area_code,area.name AS area_name,
department.id AS department_id,department.code AS department_code,department.name AS department_name,
company.id AS company_id,company.code AS company_code,COALESCE(profile.legal_name,company.name) AS company_name,
loc.geometry,loc.updated_at,loc.source_geometries
FROM y_loc loc
INNER JOIN assets y ON y.id=loc.yacimiento_id
LEFT JOIN assets area ON area.id=y.parent_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN assets company ON company.id=y.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
)
SELECT
'YACIMIENTO:'||base.yacimiento_id::text AS "featureId",
base.yacimiento_id AS "entityId",'YACIMIENTO'::text AS "entityKind",
ST_AsGeoJSON(base.geometry)::jsonb AS geometry,
base.yacimiento_code AS code,base.yacimiento_name AS name,
'Yacimiento'::text AS "typeName",base.yacimiento_status::text AS "informationStatus",
'POINT'::text AS "geometryType",base.updated_at AS "updatedAt",
'/inventarios/'||base.yacimiento_id::text AS href,
concat_ws(' · ',base.department_name,base.area_name,base.company_name) AS "contextLine",
base.department_name AS "departmentName",base.area_name AS "areaName",
base.yacimiento_name AS "yacimientoName",base.company_name AS "companyName",
base.source_geometries AS "sourceGeometries"
FROM base
UNION ALL
SELECT
'COMPANY:'||base.company_id::text||':'||base.yacimiento_id::text AS "featureId",
base.company_id AS "entityId",'COMPANY'::text AS "entityKind",
ST_AsGeoJSON(base.geometry)::jsonb AS geometry,
base.company_code AS code,base.company_name AS name,
'Empresa operadora'::text AS "typeName",'ACTIVE'::text AS "informationStatus",
'POINT'::text AS "geometryType",base.updated_at AS "updatedAt",
'/inventarios/'||base.company_id::text AS href,
concat_ws(' · ','Yacimiento '||base.yacimiento_name,'Área '||base.area_name,base.department_name) AS "contextLine",
base.department_name AS "departmentName",base.area_name AS "areaName",
base.yacimiento_name AS "yacimientoName",base.company_name AS "companyName",
base.source_geometries AS "sourceGeometries"
FROM base
WHERE base.company_id IS NOT NULL
ORDER BY "entityKind","name","code"
`)) as Array<Record<string, unknown>>;
return {
type: 'FeatureCollection' as const,
features: rows.map((row) => ({
type: 'Feature' as const,
id: row.featureId,
geometry: row.geometry,
properties: {
id: row.featureId,
entityId: row.entityId,
entityKind: row.entityKind,
code: row.code,
name: row.name,
typeName: row.typeName,
informationStatus: row.informationStatus,
geometryType: row.geometryType,
updatedAt: row.updatedAt,
href: row.href,
contextLine: row.contextLine,
departmentName: row.departmentName,
areaName: row.areaName,
yacimientoName: row.yacimientoName,
companyName: row.companyName,
sourceGeometries: row.sourceGeometries,
},
})),
meta: { count: rows.length, truncated: false },
};
}
async mapDocuments() {
const rows = (await this.dataSource.query(`
WITH RECURSIVE y_tree AS (
SELECT y.id AS yacimiento_id, y.id AS asset_id
FROM assets y
INNER JOIN asset_types y_type ON y_type.id=y.asset_type_id
WHERE lower(y_type.code)='yacimiento'
AND y_type.is_active=true
AND y.information_status<>'INACTIVE'
UNION ALL
SELECT tree.yacimiento_id, child.id
FROM y_tree tree
INNER JOIN assets child ON child.parent_id=tree.asset_id
WHERE child.information_status<>'INACTIVE'
), y_loc AS (
SELECT tree.yacimiento_id,
ST_Centroid(ST_Collect(geometry.geometry)) AS geometry,
MAX(geometry.updated_at) AS updated_at
FROM y_tree tree
INNER JOIN asset_geometries geometry ON geometry.asset_id=tree.asset_id
GROUP BY tree.yacimiento_id
), finding_rows AS (
SELECT
finding.id,finding.code,finding.title,finding.status,finding.asset_id,
act.id AS act_id,act.code AS act_code,act.occurred_at,
visit.id AS visit_id,visit.code AS visit_code,
target.code AS asset_code,target.name AS asset_name,
target_geometry.geometry AS target_geometry,
tree.yacimiento_id,
y.name AS yacimiento_name,area.name AS area_name,department.name AS department_name,
COALESCE(profile.legal_name,company.name) AS company_name,
loc.geometry AS fallback_geometry,COALESCE(target_geometry.updated_at,loc.updated_at) AS updated_at
FROM inspection_findings finding
INNER JOIN inspection_acts act ON act.id=finding.act_id
INNER JOIN inspection_visits visit ON visit.id=act.visit_id
INNER JOIN assets target ON target.id=finding.asset_id
LEFT JOIN asset_geometries target_geometry ON target_geometry.asset_id=target.id
LEFT JOIN y_tree tree ON tree.asset_id=target.id
LEFT JOIN assets y ON y.id=tree.yacimiento_id
LEFT JOIN assets area ON area.id=y.parent_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN assets company ON company.id=y.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
LEFT JOIN y_loc loc ON loc.yacimiento_id=tree.yacimiento_id
WHERE finding.status<>'VOIDED'
), act_yacimiento AS (
SELECT act.id AS act_id,
COALESCE(
CASE WHEN lower(COALESCE(scope_type.code,''))='yacimiento' THEN scope.id END,
(SELECT fr.yacimiento_id FROM finding_rows fr WHERE fr.act_id=act.id AND fr.yacimiento_id IS NOT NULL ORDER BY fr.id LIMIT 1)
) AS yacimiento_id
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id=act.visit_id
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id
WHERE act.status<>'CANCELLED'
)
SELECT
'FINDING:'||finding.id::text AS "featureId",finding.id AS "entityId",'FINDING'::text AS "entityKind",
ST_AsGeoJSON(ST_Centroid(COALESCE(finding.target_geometry,finding.fallback_geometry)))::jsonb AS geometry,
finding.code,finding.title AS name,'Hallazgo'::text AS "typeName",finding.status::text AS "informationStatus",
'POINT'::text AS "geometryType",finding.updated_at AS "updatedAt",
'/hallazgos/'||finding.id::text AS href,
concat_ws(' · ',finding.act_code,finding.asset_name,finding.yacimiento_name,finding.company_name) AS "contextLine",
finding.department_name AS "departmentName",finding.area_name AS "areaName",
finding.yacimiento_name AS "yacimientoName",finding.company_name AS "companyName",
finding.act_code AS "actCode",finding.asset_name AS "assetName"
FROM finding_rows finding
WHERE COALESCE(finding.target_geometry,finding.fallback_geometry) IS NOT NULL
UNION ALL
SELECT
'ACT:'||act.id::text AS "featureId",act.id AS "entityId",'ACT'::text AS "entityKind",
ST_AsGeoJSON(loc.geometry)::jsonb AS geometry,
act.code,act.title AS name,'Acta'::text AS "typeName",act.status::text AS "informationStatus",
'POINT'::text AS "geometryType",COALESCE(loc.updated_at,act.updated_at) AS "updatedAt",
'/inspecciones/actas/'||act.id::text AS href,
concat_ws(' · ',y.name,area.name,COALESCE(profile.legal_name,company.name)) AS "contextLine",
department.name AS "departmentName",area.name AS "areaName",y.name AS "yacimientoName",
COALESCE(profile.legal_name,company.name) AS "companyName",act.code AS "actCode",NULL::text AS "assetName"
FROM inspection_acts act
INNER JOIN act_yacimiento ay ON ay.act_id=act.id
INNER JOIN y_loc loc ON loc.yacimiento_id=ay.yacimiento_id
INNER JOIN assets y ON y.id=ay.yacimiento_id
LEFT JOIN assets area ON area.id=y.parent_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN assets company ON company.id=y.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE act.status<>'CANCELLED'
ORDER BY "entityKind","code"
`)) as Array<Record<string, unknown>>;
return {
type: 'FeatureCollection' as const,
features: rows.map((row) => ({
type: 'Feature' as const,
id: row.featureId,
geometry: row.geometry,
properties: {
id: row.featureId,
entityId: row.entityId,
entityKind: row.entityKind,
code: row.code,
name: row.name,
typeName: row.typeName,
informationStatus: row.informationStatus,
geometryType: row.geometryType,
updatedAt: row.updatedAt,
href: row.href,
contextLine: row.contextLine,
departmentName: row.departmentName,
areaName: row.areaName,
yacimientoName: row.yacimientoName,
companyName: row.companyName,
actCode: row.actCode,
assetName: row.assetName,
},
})),
meta: { count: rows.length, truncated: false },
};
}
private async requireAsset( private async requireAsset(
manager: EntityManager, manager: EntityManager,
id: string, id: string,
@@ -260,7 +260,7 @@ export const MASTER_BOOTSTRAP_TYPES: MasterBootstrapTypePreset[] = [
{ {
code: 'equipo', code: 'equipo',
name: 'Equipo', name: 'Equipo',
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en una familia técnica específica.', description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en un tipo de instalación/subinstalación específico.',
canBeRoot: false, canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC, operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'], allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'],
@@ -7,6 +7,8 @@ import { AssetsService } from './assets.service';
import { import {
AssetGeometriesController, AssetGeometriesController,
MapAssetsController, MapAssetsController,
MapDocumentsController,
MapOperationalContextController,
} from './asset-geometries.controller'; } from './asset-geometries.controller';
import { AssetGeometriesService } from './asset-geometries.service'; import { AssetGeometriesService } from './asset-geometries.service';
import { AssetHistoryController } from './asset-history.controller'; import { AssetHistoryController } from './asset-history.controller';
@@ -50,6 +52,8 @@ import { InventoryBrowserService } from './inventory-browser.service';
FieldInventoryMergeController, FieldInventoryMergeController,
AssetGeometriesController, AssetGeometriesController,
MapAssetsController, MapAssetsController,
MapOperationalContextController,
MapDocumentsController,
AssetHistoryController, AssetHistoryController,
AssetMediaController, AssetMediaController,
AssetProvenanceController, AssetProvenanceController,
@@ -756,10 +756,12 @@ export class AssetsService {
const definitions = await this.loadDefinitions(manager, type.id); const definitions = await this.loadDefinitions(manager, type.id);
const values = validateAssetAttributeValues(definitions, dto.attributes); const values = validateAssetAttributeValues(definitions, dto.attributes);
const asset = manager.getRepository(Asset).create({ const asset = manager.getRepository(Asset).create({
id: dto.clientGeneratedId,
assetTypeId: type.id, assetTypeId: type.id,
parentId: dto.parentId, parentId: dto.parentId,
operationalAreaId: dto.operationalAreaId, operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId, operatorCompanyId: dto.operatorCompanyId,
inventoryFamilyId: dto.inventoryFamilyId ?? null,
code: dto.code, code: dto.code,
name: dto.name, name: dto.name,
commonName: dto.commonName ?? null, commonName: dto.commonName ?? null,
@@ -2,6 +2,10 @@ import { Transform } from 'class-transformer';
import { IsObject, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator'; import { IsObject, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
export class CreateFieldDiscoveryDto { export class CreateFieldDiscoveryDto {
@IsOptional()
@IsUUID('4')
clientGeneratedId?: string;
@IsUUID('4') @IsUUID('4')
visitId!: string; visitId!: string;
@@ -36,6 +40,10 @@ export class CreateFieldDiscoveryDto {
@IsUUID('4') @IsUUID('4')
operatorCompanyId!: string; operatorCompanyId!: string;
@IsOptional()
@IsUUID('4')
inventoryFamilyId?: 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()
@@ -98,7 +98,7 @@ export class InventoryFamilyCatalogService {
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true) ) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true)
RETURNING id RETURNING id
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>; `,[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'); if (!inserted) throw new Error('No se pudo crear el tipo de Inventario');
await this.replaceParents(manager, inserted.id, parentIds); await this.replaceParents(manager, inserted.id, parentIds);
const created = await this.family(inserted.id,false,manager); const created = await this.family(inserted.id,false,manager);
await this.audit.record({ await this.audit.record({
@@ -186,7 +186,7 @@ export class InventoryFamilyCatalogService {
} catch (error) { } catch (error) {
if (this.isUniqueViolation(error)) throw new ConflictException({ if (this.isUniqueViolation(error)) throw new ConflictException({
code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS', code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS',
message:'Ya existe un campo técnico con ese código en la clasificación', message:'Ya existe un campo técnico con ese código en el tipo',
}); });
throw error; throw error;
} }
@@ -328,7 +328,7 @@ export class InventoryFamilyCatalogService {
${lockClause} ${lockClause}
`),[familyId])) as FamilyRow[]; `),[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({ if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe', code:'INVENTORY_FAMILY_NOT_FOUND',message:'El tipo de Inventario no existe',
}); });
return rows[0]; return rows[0];
} }
@@ -343,7 +343,7 @@ export class InventoryFamilyCatalogService {
if (level==='INSTALLATION') { if (level==='INSTALLATION') {
if (ids.length) throw new BadRequestException({ if (ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED', code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Una clasificación de Instalación no lleva compatibilidades padre', message:'Un tipo de Instalación no lleva compatibilidades padre',
}); });
return []; return [];
} }
@@ -352,7 +352,7 @@ export class InventoryFamilyCatalogService {
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación', message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
}); });
if (ownId && ids.includes(ownId)) throw new BadRequestException({ if (ownId && ids.includes(ownId)) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma', code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Un tipo no puede ser compatible consigo mismo',
}); });
const [count]=(await manager.query(` const [count]=(await manager.query(`
SELECT COUNT(*)::integer AS total FROM inventory_families SELECT COUNT(*)::integer AS total FROM inventory_families
@@ -434,7 +434,7 @@ export class InventoryFamilyCatalogService {
if (!unique.has(identity)) unique.set(identity,clean); if (!unique.has(identity)) unique.set(identity,clean);
} }
if (unique.size>100) throw new ConflictException({ if (unique.size>100) throw new ConflictException({
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información', code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'El tipo admite hasta 100 campos de información',
}); });
return [...unique.values()]; return [...unique.values()];
} }
@@ -253,7 +253,7 @@ export class InventoryStructureService {
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario', dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`, dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family family
? `Clasificación técnica: ${family.code} · ${family.name}` ? `Tipo: ${family.code} · ${family.name}`
: yacimientoContext : yacimientoContext
? `Tipo de concesión: ${yacimientoContext.concessionName}` ? `Tipo de concesión: ${yacimientoContext.concessionName}`
: null, : null,
@@ -458,13 +458,13 @@ 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: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica', message: 'Empresa, Departamento, Área y Yacimiento no llevan Tipo de instalación/subinstalación',
}); });
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 clasificación técnica de la ${kind.toLowerCase()}`, message: `Elegí el tipo 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,
@@ -479,15 +479,15 @@ export class InventoryStructureService {
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 clasificación técnica no existe' }); if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El Tipo de instalación/subinstalación 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 clasificación técnica no corresponde al nivel seleccionado', message: 'El Tipo de instalación/subinstalación no corresponde al nivel seleccionado',
}); });
if (kind === 'SUBINSTALACION') { if (kind === 'SUBINSTALACION') {
if (!parent?.inventoryFamilyId) throw new BadRequestException({ if (!parent?.inventoryFamilyId) throw new BadRequestException({
code:'INVENTORY_PARENT_FAMILY_REQUIRED', code:'INVENTORY_PARENT_FAMILY_REQUIRED',
message:'La Instalación padre debe tener una clasificación técnica válida', message:'La Instalación padre debe tener un Tipo de instalación válido',
}); });
const [compatible]=(await manager.query(` const [compatible]=(await manager.query(`
SELECT 1 AS ok FROM inventory_family_parent_rules SELECT 1 AS ok FROM inventory_family_parent_rules
@@ -496,7 +496,7 @@ export class InventoryStructureService {
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>; `,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
if (!compatible) throw new BadRequestException({ if (!compatible) throw new BadRequestException({
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada', message: 'Ese tipo de Subinstalación no es compatible con el tipo de la Instalación seleccionada',
}); });
} }
return family; return family;
@@ -29,7 +29,7 @@ export class InventoryTechnicalValuesService {
for (const [definitionId,raw] of Object.entries(dto.values)) { for (const [definitionId,raw] of Object.entries(dto.values)) {
const definition=definitionById.get(definitionId); const definition=definitionById.get(definitionId);
if (!definition || !definition.isActive) throw new BadRequestException({ 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', code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen al tipo actual',
}); });
const value=this.normalize(definition,raw); const value=this.normalize(definition,raw);
if (value!==undefined) normalized[definitionId]=value; if (value!==undefined) normalized[definitionId]=value;
@@ -67,7 +67,7 @@ export class InventoryTechnicalValuesService {
const asset=rows[0]; const asset=rows[0];
if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'}); 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({ 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', code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene Tipo de instalación/subinstalación y no admite campos técnicos por rubro',
}); });
const definitions=(await manager.query(` const definitions=(await manager.query(`
SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive", SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive",
+4 -5
View File
@@ -118,10 +118,9 @@ async function main(): Promise<void> {
); );
} }
email = (await prompt.question('Email (optional): ')).trim().toLowerCase(); email = (await prompt.question('Email: ')).trim().toLowerCase();
if ( if (
email && !email || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
(email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
) { ) {
throw new Error('Email is invalid'); throw new Error('Email is invalid');
} }
@@ -189,7 +188,7 @@ async function main(): Promise<void> {
OR ($2::text IS NOT NULL AND LOWER(email) = $2) OR ($2::text IS NOT NULL AND LOWER(email) = $2)
LIMIT 1 LIMIT 1
`, `,
[username, email || null], [username, email],
)) as unknown[]; )) as unknown[];
if (duplicate.length > 0) { if (duplicate.length > 0) {
throw new Error('Username or email already exists'); throw new Error('Username or email already exists');
@@ -202,7 +201,7 @@ async function main(): Promise<void> {
const user = manager.getRepository(User).create({ const user = manager.getRepository(User).create({
username, username,
email: email || null, email,
passwordHash, passwordHash,
firstName, firstName,
lastName, lastName,
@@ -103,6 +103,8 @@ export enum AuditAction {
INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED', INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED',
SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED', SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED',
SMTP_TEST_SENT = 'SMTP_TEST_SENT', SMTP_TEST_SENT = 'SMTP_TEST_SENT',
USER_SMTP_SETTINGS_UPDATED = 'USER_SMTP_SETTINGS_UPDATED',
USER_SMTP_TEST_SENT = 'USER_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',
@@ -71,8 +71,8 @@ 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, default: InspectionActUrgency.NON_URGENT }) @Column({ name: 'urgency', type: 'varchar', length: 24, nullable: true })
urgency!: InspectionActUrgency; urgency!: InspectionActUrgency | null;
@Column({ name: 'deadline_days', type: 'integer', nullable: true }) @Column({ name: 'deadline_days', type: 'integer', nullable: true })
deadlineDays!: number | null; deadlineDays!: number | null;
+2 -2
View File
@@ -16,8 +16,8 @@ export class User extends TimestampedEntity {
@Column({ type: 'varchar', length: 80 }) @Column({ type: 'varchar', length: 80 })
username!: string; username!: string;
@Column({ type: 'varchar', length: 320, nullable: true }) @Column({ type: 'varchar', length: 320 })
email!: string | null; email!: string;
@Column({ type: 'varchar', length: 32, nullable: true }) @Column({ type: 'varchar', length: 32, nullable: true })
dni!: string | null; dni!: string | null;
@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F61ActUrgencyAtLock1790113800000 implements MigrationInterface {
name = 'F61ActUrgencyAtLock1790113800000';
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency DROP DEFAULT`);
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency DROP NOT NULL`);
await q.query(`UPDATE inspection_acts SET urgency=NULL WHERE status='DRAFT'`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`UPDATE inspection_acts SET urgency='NON_URGENT' WHERE urgency IS NULL`);
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency SET NOT NULL`);
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency SET DEFAULT 'NON_URGENT'`);
}
}
@@ -0,0 +1,105 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class F62UserSmtpAndActRepresentative1790117400000 implements MigrationInterface {
name = 'F62UserSmtpAndActRepresentative1790117400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM users WHERE email IS NULL OR btrim(email)='') THEN
RAISE EXCEPTION 'USER_EMAIL_REQUIRED_BEFORE_F62';
END IF;
END $$
`);
await queryRunner.query(`ALTER TABLE users ALTER COLUMN email SET NOT NULL`);
await queryRunner.query(`
CREATE TABLE user_smtp_settings (
user_id uuid PRIMARY KEY,
mode varchar(16) NOT NULL DEFAULT 'SYSTEM',
host varchar(255),
port integer,
security_mode varchar(24),
username varchar(255),
password_enc text,
from_name varchar(200),
from_email varchar(320),
reply_to varchar(320),
enabled boolean NOT NULL DEFAULT true,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_user_smtp_mode CHECK (mode IN ('SYSTEM','CUSTOM')),
CONSTRAINT chk_user_smtp_security CHECK (security_mode IS NULL OR security_mode IN ('NONE','STARTTLS','TLS')),
CONSTRAINT chk_user_smtp_port CHECK (port IS NULL OR (port > 0 AND port <= 65535)),
CONSTRAINT chk_user_smtp_custom_complete CHECK (
mode='SYSTEM' OR (
LENGTH(TRIM(COALESCE(host,''))) > 0
AND port IS NOT NULL
AND security_mode IS NOT NULL
AND LENGTH(TRIM(COALESCE(from_email,''))) > 0
)
),
CONSTRAINT fk_user_smtp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_smtp_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
ALTER TABLE inspection_act_responsibles
DROP CONSTRAINT chk_inspection_act_responsibles_details
`);
await queryRunner.query(`
ALTER TABLE inspection_act_responsibles
ADD CONSTRAINT chk_inspection_act_responsibles_details CHECK (
(
attendance_status = 'PRESENT'
AND LENGTH(TRIM(COALESCE(full_name, ''))) > 0
AND document_type IS NOT NULL
AND LENGTH(TRIM(COALESCE(document_number, ''))) > 0
AND LENGTH(TRIM(COALESCE(position, ''))) > 0
AND LENGTH(TRIM(COALESCE(email, ''))) > 0
AND absence_reason IS NULL
) OR (
attendance_status = 'ABSENT'
AND LENGTH(TRIM(COALESCE(absence_reason, ''))) >= 10
)
)
`);
const appRole = process.env.DB_APP_USER;
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
const applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE user_smtp_settings TO ${applicationRole}`);
await queryRunner.query(`REVOKE DELETE ON TABLE user_smtp_settings FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_act_responsibles
DROP CONSTRAINT chk_inspection_act_responsibles_details
`);
await queryRunner.query(`
ALTER TABLE inspection_act_responsibles
ADD CONSTRAINT chk_inspection_act_responsibles_details CHECK (
(
attendance_status = 'PRESENT'
AND LENGTH(TRIM(COALESCE(full_name, ''))) > 0
AND document_type IS NOT NULL
AND LENGTH(TRIM(COALESCE(document_number, ''))) > 0
AND LENGTH(TRIM(COALESCE(position, ''))) > 0
AND absence_reason IS NULL
) OR (
attendance_status = 'ABSENT'
AND LENGTH(TRIM(COALESCE(absence_reason, ''))) >= 10
)
)
`);
await queryRunner.query(`DROP TABLE IF EXISTS user_smtp_settings`);
await queryRunner.query(`ALTER TABLE users ALTER COLUMN email DROP NOT NULL`);
}
}
@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* F4 cambió el código visible del Acta a ACT-NNNNN-DD-MM-YY, pero el CHECK
* histórico de Hallazgos seguía aceptando sólo ACTA-YYYY-NNNNNN-HNNN.
* El servicio genera el Hallazgo a partir del código real del Acta, por lo que
* PostgreSQL rechazaba altas válidas de campo con un 500.
*/
export class F65FieldFindingCodeContract1790121000000 implements MigrationInterface {
name = 'F65FieldFindingCodeContract1790121000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_findings
DROP CONSTRAINT IF EXISTS chk_inspection_findings_code
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
ADD CONSTRAINT chk_inspection_findings_code CHECK (
code ~ '^(ACTA-[0-9]{4}-[0-9]{6}|ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2})-H[0-9]{3}$'
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE inspection_findings finding
SET code = 'ACTA-' || act.act_year::text || '-' || LPAD(act.act_number::text, 6, '0')
|| '-H' || LPAD(finding.finding_number::text, 3, '0')
FROM inspection_acts act
WHERE finding.act_id = act.id
AND finding.code ~ '^ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}-H[0-9]{3}$'
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
DROP CONSTRAINT IF EXISTS chk_inspection_findings_code
`);
await queryRunner.query(`
ALTER TABLE inspection_findings
ADD CONSTRAINT chk_inspection_findings_code CHECK (
code ~ '^ACTA-[0-9]{4}-[0-9]{6}-H[0-9]{3}$'
)
`);
}
}
@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class F67UserReusableSignature1790124600000 implements MigrationInterface {
name = 'F67UserReusableSignature1790124600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE user_signature_profiles (
user_id uuid PRIMARY KEY,
original_name varchar(255) NOT NULL,
mime_type varchar(100) NOT NULL,
size_bytes integer NOT NULL,
image_sha256 char(64) NOT NULL,
image_data bytea NOT NULL,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_user_signature_profiles_png CHECK (mime_type='image/png'),
CONSTRAINT chk_user_signature_profiles_size CHECK (
size_bytes BETWEEN 1 AND 1048576
AND OCTET_LENGTH(image_data)=size_bytes
),
CONSTRAINT chk_user_signature_profiles_sha CHECK (
image_sha256 ~ '^[0-9a-f]{64}$'
),
CONSTRAINT fk_user_signature_profiles_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_signature_profiles_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
const appRole = process.env.DB_APP_USER;
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
const applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE user_signature_profiles
TO ${applicationRole}
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE user_signature_profiles');
}
}
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F68OfflineEvidenceIdempotency1790128200000 implements MigrationInterface {
name = 'F68OfflineEvidenceIdempotency1790128200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_finding_evidence
ADD COLUMN client_operation_id uuid
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_inspection_finding_evidence_client_operation
ON inspection_finding_evidence (client_operation_id)
WHERE client_operation_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE asset_field_capture_events
ADD COLUMN client_operation_id uuid
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_asset_field_capture_events_client_operation
ON asset_field_capture_events (client_operation_id)
WHERE client_operation_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX uq_asset_field_capture_events_client_operation');
await queryRunner.query('ALTER TABLE asset_field_capture_events DROP COLUMN client_operation_id');
await queryRunner.query('DROP INDEX uq_inspection_finding_evidence_client_operation');
await queryRunner.query('ALTER TABLE inspection_finding_evidence DROP COLUMN client_operation_id');
}
}
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F69ConsolidatedActDocument1790131800000 implements MigrationInterface {
name = 'F69ConsolidatedActDocument1790131800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inspection_act_consolidated_pdf_artifacts (
act_id uuid PRIMARY KEY REFERENCES inspection_acts(id) ON DELETE CASCADE,
stored_name varchar(255) NOT NULL,
original_name varchar(255) NOT NULL,
size_bytes integer NOT NULL CHECK (size_bytes > 0),
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await queryRunner.query(`
CREATE TABLE inspection_report_consolidated_word_artifacts (
report_id uuid PRIMARY KEY REFERENCES inspection_reports(id) ON DELETE CASCADE,
stored_name varchar(255) NOT NULL,
original_name varchar(255) NOT NULL,
size_bytes integer NOT NULL CHECK (size_bytes > 0),
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE inspection_report_consolidated_word_artifacts');
await queryRunner.query('DROP TABLE inspection_act_consolidated_pdf_artifacts');
}
}
@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F69ConsolidatedDocumentRevisions1790135400000 implements MigrationInterface {
name = 'F69ConsolidatedDocumentRevisions1790135400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inspection_act_consolidated_pdf_revisions (
act_id uuid NOT NULL REFERENCES inspection_acts(id) ON DELETE CASCADE,
template_version smallint NOT NULL CHECK (template_version >= 1),
stored_name varchar(255) NOT NULL,
original_name varchar(255) NOT NULL,
size_bytes integer NOT NULL CHECK (size_bytes > 0),
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (act_id,template_version)
)
`);
await queryRunner.query(`
INSERT INTO inspection_act_consolidated_pdf_revisions(
act_id,template_version,stored_name,original_name,size_bytes,sha256,generated_at)
SELECT act_id,1,stored_name,original_name,size_bytes,sha256,generated_at
FROM inspection_act_consolidated_pdf_artifacts
`);
await queryRunner.query(`
CREATE TABLE inspection_report_consolidated_word_revisions (
report_id uuid NOT NULL REFERENCES inspection_reports(id) ON DELETE CASCADE,
template_version smallint NOT NULL CHECK (template_version >= 1),
stored_name varchar(255) NOT NULL,
original_name varchar(255) NOT NULL,
size_bytes integer NOT NULL CHECK (size_bytes > 0),
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (report_id,template_version)
)
`);
await queryRunner.query(`
INSERT INTO inspection_report_consolidated_word_revisions(
report_id,template_version,stored_name,original_name,size_bytes,sha256,generated_at)
SELECT report_id,1,stored_name,original_name,size_bytes,sha256,generated_at
FROM inspection_report_consolidated_word_artifacts
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE inspection_report_consolidated_word_revisions');
await queryRunner.query('DROP TABLE inspection_act_consolidated_pdf_revisions');
}
}
@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F611ReportResponseWorkflow1790139000000 implements MigrationInterface {
name = 'F611ReportResponseWorkflow1790139000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid`);
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD CONSTRAINT fk_act_deadline_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD CONSTRAINT fk_act_response_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
await queryRunner.query(`CREATE INDEX idx_act_deadline_events_report_created ON inspection_act_deadline_events(report_id, created_at DESC)`);
await queryRunner.query(`CREATE INDEX idx_act_company_responses_report_received ON inspection_act_company_responses(report_id, received_on DESC, created_at DESC)`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION validate_report_act_relation()
RETURNS trigger AS $$
BEGIN
IF NEW.report_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM inspection_reports report
WHERE report.id=NEW.report_id AND report.act_id=NEW.act_id
) THEN
RAISE EXCEPTION 'El Informe no corresponde al Acta indicada';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`);
await queryRunner.query(`CREATE TRIGGER trg_act_deadline_report_relation BEFORE INSERT ON inspection_act_deadline_events FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
await queryRunner.query(`CREATE TRIGGER trg_act_response_report_relation BEFORE INSERT ON inspection_act_company_responses FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_response_report_relation ON inspection_act_company_responses`);
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_deadline_report_relation ON inspection_act_deadline_events`);
await queryRunner.query(`DROP FUNCTION IF EXISTS validate_report_act_relation()`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_company_responses_report_received`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_deadline_events_report_created`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP CONSTRAINT IF EXISTS fk_act_response_report`);
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP CONSTRAINT IF EXISTS fk_act_deadline_report`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP COLUMN IF EXISTS report_id`);
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP COLUMN IF EXISTS report_id`);
}
}
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F612AdminReportPermission1790142600000 implements MigrationInterface {
name = 'F612AdminReportPermission1790142600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO role_permissions(role_id, permission_id)
SELECT role.id, permission.id
FROM roles role
JOIN permissions permission ON permission.code='inspection_reports.generate'
WHERE role.code='admin'
ON CONFLICT(role_id, permission_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions role_permission
USING roles role, permissions permission
WHERE role_permission.role_id=role.id
AND role_permission.permission_id=permission.id
AND role.code='admin'
AND permission.code='inspection_reports.generate'
`);
}
}
@@ -3,7 +3,6 @@ import {
ArrayMaxSize, ArrayMaxSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEnum,
IsISO8601, IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
@@ -11,15 +10,15 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class CreateInspectionActDto { export class CreateInspectionActDto {
@IsOptional()
@IsUUID('4')
clientGeneratedId?: string;
@IsISO8601({ strict: true }) @IsISO8601({ strict: true })
occurredAt!: string; occurredAt!: string;
@IsEnum(InspectionActUrgency)
urgency!: InspectionActUrgency;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@@ -4,7 +4,6 @@ import {
ArrayMinSize, ArrayMinSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEnum,
IsISO8601, IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
@@ -12,17 +11,12 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class UpdateInspectionActDto { export class UpdateInspectionActDto {
@IsOptional() @IsOptional()
@IsISO8601({ strict: true }) @IsISO8601({ strict: true })
occurredAt?: string; occurredAt?: string;
@IsOptional()
@IsEnum(InspectionActUrgency)
urgency?: InspectionActUrgency;
@IsOptional() @IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsString()
@@ -53,6 +53,12 @@ export class InspectionActsController {
return this.acts.listGlobal(query); return this.acts.listGlobal(query);
} }
@Get(':id/field-media')
@RequirePermissions('inspection_acts.read', 'assets.read_media')
fieldMedia(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.acts.listFieldMedia(id);
}
@Get(':id') @Get(':id')
@RequirePermissions('inspection_acts.read') @RequirePermissions('inspection_acts.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
@@ -59,9 +59,22 @@ interface ActReportSummary {
code: string; code: string;
status: string; status: string;
pdfStatus: string; pdfStatus: string;
wordStatus: string;
gedoIfIdentifier: string | null;
gedoOfficializedAt: Date | null;
generatedAt: Date; generatedAt: Date;
} }
interface ActTerritorialContext {
department: ActContextAsset | null;
area: ActContextAsset | null;
yacimiento: ActContextAsset | null;
company: ActContextAsset | null;
installations: ActContextAsset[];
subinstallations: ActContextAsset[];
legacyAreaScope: boolean;
}
export interface InspectionActListItem { export interface InspectionActListItem {
id: string; id: string;
visitId: string; visitId: string;
@@ -74,7 +87,7 @@ export interface InspectionActListItem {
title: string; title: string;
summary: string; summary: string;
observations: string | null; observations: string | null;
urgency: InspectionActUrgency; urgency: InspectionActUrgency | null;
deadlineDays: number | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null; deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null; deadlineBasis: InspectionDeadlineBasis | null;
@@ -94,6 +107,7 @@ export interface InspectionActListItem {
findingCount: number; findingCount: number;
companies: ActContextAsset[]; companies: ActContextAsset[];
areas: ActContextAsset[]; areas: ActContextAsset[];
context: ActTerritorialContext;
report: ActReportSummary | null; report: ActReportSummary | null;
createdBy: ActPerson | null; createdBy: ActPerson | null;
updatedBy: ActPerson | null; updatedBy: ActPerson | null;
@@ -278,6 +292,48 @@ export class InspectionActsService {
return this.dataSource.transaction((manager) => this.loadView(manager, id)); return this.dataSource.transaction((manager) => this.loadView(manager, id));
} }
async listFieldMedia(id: string) {
const [act] = await this.dataSource.query(
'SELECT id FROM inspection_acts WHERE id=$1::uuid',
[id],
) as Array<{ id: string }>;
if (!act) throw actNotFound();
const data = await this.dataSource.query(`
SELECT
media.id,
media.asset_id AS "assetId",
asset.code AS "assetCode",
asset.name AS "assetName",
media.kind,
media.original_name AS "originalName",
media.mime_type AS "mimeType",
media.size_bytes::double precision AS "sizeBytes",
media.sha256,
media.title,
media.description,
media.captured_at AS "capturedAt",
media.latitude::double precision AS latitude,
media.longitude::double precision AS longitude,
media.accuracy_m::double precision AS "accuracyM",
media.source,
capture.device_captured_at AS "fieldCapturedAt",
capture.created_at AS "createdAt"
FROM inspection_acts act
INNER JOIN inspection_act_assets link
ON link.act_id=act.id AND link.included=true
INNER JOIN asset_field_capture_events capture
ON capture.visit_id=act.visit_id
AND capture.asset_id=link.asset_id
AND capture.event_type='PHOTO'
INNER JOIN asset_media media
ON media.id=capture.media_id AND media.deleted_at IS NULL
INNER JOIN assets asset ON asset.id=media.asset_id
WHERE act.id=$1::uuid
ORDER BY capture.device_captured_at DESC,capture.created_at DESC,media.id
`, [id]);
return { data };
}
async create( async create(
visitId: string, visitId: string,
dto: CreateInspectionActDto, dto: CreateInspectionActDto,
@@ -289,6 +345,22 @@ export class InspectionActsService {
const visit = await this.lockVisit(manager, visitId); const visit = await this.lockVisit(manager, visitId);
this.assertVisitOpen(visit); this.assertVisitOpen(visit);
await this.assertActorAssigned(manager, visitId, principal); await this.assertActorAssigned(manager, visitId, principal);
if (dto.clientGeneratedId) {
const [existing] = await manager.query(`
SELECT id, visit_id AS "visitId"
FROM inspection_acts
WHERE id=$1
`, [dto.clientGeneratedId]) as Array<{ id: string; visitId: string }>;
if (existing) {
if (existing.visitId !== visitId) {
throw new ConflictException({
code: 'INSPECTION_ACT_CLIENT_ID_REUSED',
message: 'El identificador offline del Acta ya fue utilizado en otra inspección',
});
}
return this.loadView(manager, existing.id);
}
}
await this.assertVisitHasNoDraftAct(manager, visitId); await this.assertVisitHasNoDraftAct(manager, visitId);
await this.assertVisitAssets(manager, visitId, dto.assetIds); await this.assertVisitAssets(manager, visitId, dto.assetIds);
const occurredAt = new Date(dto.occurredAt); const occurredAt = new Date(dto.occurredAt);
@@ -305,6 +377,7 @@ export class InspectionActsService {
`, [occurredAt])) as Array<{ date_part: string }>; `, [occurredAt])) as Array<{ date_part: string }>;
const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`; const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`;
const act = manager.getRepository(InspectionAct).create({ const act = manager.getRepository(InspectionAct).create({
id: dto.clientGeneratedId,
visitId, visitId,
actYear, actYear,
actNumber, actNumber,
@@ -314,7 +387,7 @@ export class InspectionActsService {
title: dto.title, title: dto.title,
summary: dto.summary, summary: dto.summary,
observations: dto.observations ?? null, observations: dto.observations ?? null,
urgency: dto.urgency, urgency: null,
deadlineDays: null, deadlineDays: null,
deadlineDayType: null, deadlineDayType: null,
deadlineBasis: null, deadlineBasis: null,
@@ -379,7 +452,6 @@ export class InspectionActsService {
await this.assertVisitAssets(manager, visit.id, nextAssetIds); await this.assertVisitAssets(manager, visit.id, nextAssetIds);
const before = await this.loadView(manager, id); const before = await this.loadView(manager, id);
if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt; if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt;
if (dto.urgency !== undefined) act.urgency = dto.urgency;
if (dto.title !== undefined) act.title = dto.title; if (dto.title !== undefined) act.title = dto.title;
if (dto.summary !== undefined) act.summary = dto.summary; if (dto.summary !== undefined) act.summary = dto.summary;
if (dto.observations !== undefined) act.observations = dto.observations; if (dto.observations !== undefined) act.observations = dto.observations;
@@ -489,11 +561,23 @@ export class InspectionActsService {
COALESCE(finding_count.total, 0)::integer AS "findingCount", COALESCE(finding_count.total, 0)::integer AS "findingCount",
COALESCE(context.companies, '[]'::jsonb) AS companies, COALESCE(context.companies, '[]'::jsonb) AS companies,
COALESCE(context.areas, '[]'::jsonb) AS areas, COALESCE(context.areas, '[]'::jsonb) AS areas,
JSONB_BUILD_OBJECT(
'department', context.department,
'area', context.area,
'yacimiento', context.yacimiento,
'company', context.company,
'installations', COALESCE(context.installations, '[]'::jsonb),
'subinstallations', COALESCE(context.subinstallations, '[]'::jsonb),
'legacyAreaScope', context.legacy_area_scope
) AS context,
CASE WHEN report.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN report.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', report.id, 'id', report.id,
'code', report.code, 'code', report.code,
'status', report.status, 'status', report.status,
'pdfStatus', report.pdf_status, 'pdfStatus', report.pdf_status,
'wordStatus', report.word_status,
'gedoIfIdentifier', report.gedo_if_identifier,
'gedoOfficializedAt', report.gedo_officialized_at,
'generatedAt', report.generated_at 'generatedAt', report.generated_at
) END AS report, ) END AS report,
CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
@@ -522,10 +606,64 @@ export class InspectionActsService {
) END AS companies, ) END AS companies,
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
) END AS areas ) END AS areas,
CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',department.id,'code',department.code,'name',department.name
) END AS department,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',area.id,'code',area.code,'name',area.name
) END AS area,
CASE WHEN yacimiento.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',yacimiento.id,'code',yacimiento.code,'name',yacimiento.name
) END AS yacimiento,
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',company.id,'code',company.code,'name',company.name
) END AS company,
COALESCE(affected.installations, '[]'::jsonb) AS installations,
COALESCE(affected.subinstallations, '[]'::jsonb) AS subinstallations,
(lower(COALESCE(scope_type.code,''))='area') AS legacy_area_scope
FROM inspection_visits context_visit FROM inspection_visits context_visit
LEFT JOIN assets company ON company.id=context_visit.operator_company_id LEFT JOIN assets company ON company.id=context_visit.operator_company_id
LEFT JOIN assets area ON area.id=context_visit.operational_area_id LEFT JOIN assets area ON area.id=context_visit.operational_area_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN assets scope_asset ON scope_asset.id=context_visit.scope_asset_id
LEFT JOIN asset_types scope_type ON scope_type.id=scope_asset.asset_type_id
LEFT JOIN assets yacimiento ON yacimiento.id=CASE
WHEN lower(COALESCE(scope_type.code,''))='yacimiento' THEN scope_asset.id
ELSE NULL
END
LEFT JOIN LATERAL (
SELECT
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code)
FROM (
SELECT DISTINCT installation.id, installation.code, installation.name
FROM inspection_findings finding_context
INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id
INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id
LEFT JOIN assets installation ON installation.id=CASE
WHEN lower(finding_type.code)='instalacion' THEN finding_asset.id
WHEN lower(finding_type.code)='subinstalacion' THEN finding_asset.parent_id
ELSE NULL
END
WHERE finding_context.act_id=act.id
AND finding_context.status<>'VOIDED'
AND installation.id IS NOT NULL
) x
), '[]'::jsonb) AS installations,
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code)
FROM (
SELECT DISTINCT finding_asset.id, finding_asset.code, finding_asset.name
FROM inspection_findings finding_context
INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id
INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id
WHERE finding_context.act_id=act.id
AND finding_context.status<>'VOIDED'
AND lower(finding_type.code)='subinstalacion'
) x
), '[]'::jsonb) AS subinstallations
) affected ON true
WHERE context_visit.id=act.visit_id WHERE context_visit.id=act.visit_id
) context ON true ) context ON true
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -729,10 +867,13 @@ export class InspectionActsService {
principal: AuthPrincipal, principal: AuthPrincipal,
): Promise<number> { ): Promise<number> {
const [row] = (await manager.query(` const [row] = (await manager.query(`
UPDATE inspection_acts WITH updated AS (
SET current_version = current_version + 1 UPDATE inspection_acts
WHERE id = $1 SET current_version = current_version + 1
RETURNING current_version AS "versionNumber" WHERE id = $1
RETURNING current_version
)
SELECT current_version AS "versionNumber" FROM updated
`, [act.id])) as Array<{ versionNumber: number }>; `, [act.id])) as Array<{ versionNumber: number }>;
const versionNumber = Number(row.versionNumber); const versionNumber = Number(row.versionNumber);
act.currentVersion = versionNumber; act.currentVersion = versionNumber;
@@ -213,14 +213,14 @@ export class CompanySignatureInviteService {
if (!publicUrl) { if (!publicUrl) {
deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada'; deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada';
} else if (!(await this.smtp.configured())) { } else if (!(await this.smtp.configured(principal.userId))) {
deliveryError = 'SMTP no configurado'; deliveryError = 'SMTP no configurado';
} else { } else {
const body = [ const body = [
`Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`, `Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`,
`Inspección: ${created.inspectionCode}.`, `Inspección: ${created.inspectionCode}.`,
'', '',
'El enlace permite firmar en conformidad, firmar en disidencia o registrar una negativa a firmar.', 'El enlace permite firmar en conformidad, firmar en disconformidad o registrar una negativa a firmar.',
'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.', 'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.',
'', '',
`Enlace seguro: ${publicUrl}`, `Enlace seguro: ${publicUrl}`,
@@ -236,7 +236,7 @@ export class CompanySignatureInviteService {
mimeType: 'text/plain', mimeType: 'text/plain',
content: Buffer.from(body, 'utf8'), content: Buffer.from(body, 'utf8'),
}, },
}); }, principal.userId);
emailSent = true; emailSent = true;
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_act_company_signature_invites UPDATE inspection_act_company_signature_invites
@@ -264,7 +264,6 @@ export class CompanySignatureInviteService {
const invite = await this.resolveToken(this.dataSource.manager, token, false); const invite = await this.resolveToken(this.dataSource.manager, token, false);
const locked = invite.lockedSnapshot ?? {}; const locked = invite.lockedSnapshot ?? {};
const act = this.record(locked.act); const act = this.record(locked.act);
const inventories = this.records(locked.inventories);
const findings = this.records(locked.findings).map((finding) => ({ const findings = this.records(locked.findings).map((finding) => ({
id: finding.id, id: finding.id,
code: finding.code, code: finding.code,
@@ -296,12 +295,6 @@ export class CompanySignatureInviteService {
documentNumber: invite.recipientDocumentNumber, documentNumber: invite.recipientDocumentNumber,
position: invite.recipientPosition, position: invite.recipientPosition,
}, },
inventories: inventories.map((inventory) => ({
id: inventory.id,
code: inventory.code,
name: inventory.name,
typeName: inventory.typeName ?? inventory.typeCode ?? null,
})),
findings, findings,
consent: REMOTE_COMPANY_CONSENT, consent: REMOTE_COMPANY_CONSENT,
allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'], allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'],
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class PrepareInspectionActDto {
@IsEnum(InspectionActUrgency)
urgency!: InspectionActUrgency;
}
@@ -54,7 +54,9 @@ export class UpsertInspectionResponsibleDto {
@MaxLength(200) @MaxLength(200)
position?: string; position?: string;
@IsOptional() @ValidateIf((dto: UpsertInspectionResponsibleDto) => (
dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT
))
@Transform(trimOrUndefined) @Transform(trimOrUndefined)
@IsEmail() @IsEmail()
@MaxLength(320) @MaxLength(320)
@@ -17,6 +17,7 @@ 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 type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CloseInspectionActDto } from './dto/close-inspection-act.dto'; import { CloseInspectionActDto } from './dto/close-inspection-act.dto';
import { PrepareInspectionActDto } from './dto/prepare-inspection-act.dto';
import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
import { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; import { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
@@ -52,10 +53,11 @@ export class InspectionClosingController {
@RequirePermissions('inspection_closure.prepare') @RequirePermissions('inspection_closure.prepare')
lock( lock(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: PrepareInspectionActDto,
@CurrentAuth() principal: AuthPrincipal, @CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext, @Req() request: RequestWithContext,
) { ) {
return this.closing.prepare(actId, principal, request); return this.closing.prepare(actId, dto, principal, request);
} }
@Post('signatures/inspector') @Post('signatures/inspector')
@@ -35,6 +35,7 @@ import { assertMobileInspector } from '../inspection-operations/mobile-inspector
import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import { InspectionReportsService } from '../inspection-reports/inspection-reports.service';
import { sha256CanonicalJson } from './canonical-json'; import { sha256CanonicalJson } from './canonical-json';
import type { CloseInspectionActDto } from './dto/close-inspection-act.dto'; import type { CloseInspectionActDto } from './dto/close-inspection-act.dto';
import type { PrepareInspectionActDto } from './dto/prepare-inspection-act.dto';
import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
@@ -44,7 +45,7 @@ import {
type UploadedInspectionSignatureFile, type UploadedInspectionSignatureFile,
} from './inspection-signature-file'; } from './inspection-signature-file';
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V4'; const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V5';
const CONSENT_VERSION = 'F4-1'; const CONSENT_VERSION = 'F4-1';
const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.'; const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.';
const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.'; const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.';
@@ -102,6 +103,14 @@ interface StoredSignature extends SignatureView {
storedName: string | null; storedName: string | null;
} }
interface ReusableInspectorSignature {
originalName: string;
mimeType: 'image/png';
sizeBytes: number;
imageSha256: string;
imageData: Buffer;
}
interface ClosureRecord { interface ClosureRecord {
actId: string; actId: string;
schemaVersion: string; schemaVersion: string;
@@ -130,7 +139,7 @@ export interface InspectionClosureView {
code: string; code: string;
status: InspectionActStatus; status: InspectionActStatus;
visitId: string; visitId: string;
urgency: InspectionActUrgency; urgency: InspectionActUrgency | null;
deadlineDays: number | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null; deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null; deadlineBasis: InspectionDeadlineBasis | null;
@@ -151,6 +160,7 @@ export interface InspectionClosureView {
actualClosedAt: Date | null; actualClosedAt: Date | null;
}; };
responsible: ResponsibleView | null; responsible: ResponsibleView | null;
representativeSuggestion: ResponsibleView | null;
closure: null | { closure: null | {
schemaVersion: string; schemaVersion: string;
preparedSha256: string; preparedSha256: string;
@@ -268,6 +278,7 @@ export class InspectionClosingService {
/** Finaliza el contenido: desde este punto el Acta queda inmutable. */ /** Finaliza el contenido: desde este punto el Acta queda inmutable. */
async prepare( async prepare(
actId: string, actId: string,
dto: PrepareInspectionActDto,
principal: AuthPrincipal, principal: AuthPrincipal,
request: RequestWithContext, request: RequestWithContext,
): Promise<InspectionClosureView> { ): Promise<InspectionClosureView> {
@@ -285,16 +296,16 @@ export class InspectionClosingService {
} }
const policy = await this.deadlinePolicy(manager); const policy = await this.deadlinePolicy(manager);
const deadlineDays = act.urgency === InspectionActUrgency.URGENT const deadlineDays = dto.urgency === InspectionActUrgency.URGENT
? policy.urgentDays ? policy.urgentDays
: policy.nonUrgentDays; : policy.nonUrgentDays;
const deadlineDayType = act.urgency === InspectionActUrgency.URGENT const deadlineDayType = dto.urgency === InspectionActUrgency.URGENT
? policy.urgentDayType ? policy.urgentDayType
: policy.nonUrgentDayType; : policy.nonUrgentDayType;
const deadlineBasis = act.urgency === InspectionActUrgency.URGENT const deadlineBasis = dto.urgency === InspectionActUrgency.URGENT
? InspectionDeadlineBasis.ACT_DATE ? InspectionDeadlineBasis.ACT_DATE
: InspectionDeadlineBasis.GEDO_DATE; : InspectionDeadlineBasis.GEDO_DATE;
const deadlineBaseAt = act.urgency === InspectionActUrgency.URGENT const deadlineBaseAt = dto.urgency === InspectionActUrgency.URGENT
? act.occurredAt ? act.occurredAt
: null; : null;
const deadlineAt = deadlineBaseAt const deadlineAt = deadlineBaseAt
@@ -303,22 +314,27 @@ export class InspectionClosingService {
const lockedAt = new Date(); const lockedAt = new Date();
const [updated] = (await manager.query(` const [updated] = (await manager.query(`
UPDATE inspection_acts WITH updated AS (
SET status='LOCKED', UPDATE inspection_acts
deadline_days=$2, SET status='LOCKED',
deadline_day_type=$3, urgency=$2,
deadline_basis=$4, deadline_days=$3,
deadline_base_at=$5, deadline_day_type=$4,
deadline_at=$6, deadline_basis=$5,
locked_at=$7, deadline_base_at=$6,
locked_by=$8, deadline_at=$7,
current_version=current_version+1, locked_at=$8,
updated_by=$8, locked_by=$9,
updated_at=$7 current_version=current_version+1,
WHERE id=$1 updated_by=$9,
RETURNING current_version AS "versionNumber" updated_at=$8
WHERE id=$1
RETURNING current_version
)
SELECT current_version AS "versionNumber" FROM updated
`, [ `, [
actId, actId,
dto.urgency,
deadlineDays, deadlineDays,
deadlineDayType, deadlineDayType,
deadlineBasis, deadlineBasis,
@@ -372,7 +388,7 @@ export class InspectionClosingService {
afterData: { afterData: {
status: InspectionActStatus.LOCKED, status: InspectionActStatus.LOCKED,
lockedSha256: preparedSha256, lockedSha256: preparedSha256,
urgency: act.urgency, urgency: dto.urgency,
deadlineDays, deadlineDays,
deadlineDayType, deadlineDayType,
deadlineBasis, deadlineBasis,
@@ -456,6 +472,13 @@ export class InspectionClosingService {
message: 'La negativa a firmar requiere identificar al responsable que se negó', message: 'La negativa a firmar requiere identificar al responsable que se negó',
}); });
} }
if (dto.status === InspectionActSignatureStatus.ABSENT
&& responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.ABSENT) {
throw new BadRequestException({
code: 'INSPECTION_COMPANY_ABSENCE_REQUIRES_ABSENT_RESPONSIBLE',
message: 'La ausencia sólo puede confirmarse cuando el representante fue registrado como ausente',
});
}
if (await this.hasCompanyOutcome(manager, actId)) { if (await this.hasCompanyOutcome(manager, actId)) {
throw new ConflictException({ throw new ConflictException({
code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED',
@@ -527,22 +550,15 @@ export class InspectionClosingService {
request: RequestWithContext, request: RequestWithContext,
): Promise<InspectionClosureView> { ): Promise<InspectionClosureView> {
assertMobileInspector(principal); assertMobileInspector(principal);
const sealed = await this.dataSource.transaction(async (manager) => { let generatedInspectorSignaturePath: string | null = null;
let sealed: InspectionClosureView;
try {
sealed = await this.dataSource.transaction(async (manager) => {
const { act, visit } = await this.lockContext(manager, actId); const { act, visit } = await this.lockContext(manager, actId);
this.assertLockedForManifestation(act); this.assertLockedForManifestation(act);
await this.assertActorAssigned(manager, visit.id, principal, true); await this.assertActorAssigned(manager, visit.id, principal, true);
const closure = await this.requireClosure(manager, actId); const closure = await this.requireClosure(manager, actId);
const signatures = await this.loadSignatures(manager, actId); let signatures = await this.loadSignatures(manager, actId);
const inspectorSigned = signatures.some((item) =>
item.signerType === InspectionActSignerType.INSPECTOR
&& item.status === InspectionActSignatureStatus.SIGNED,
);
if (!inspectorSigned) {
throw new ConflictException({
code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED',
message: 'Se requiere la firma del inspector para sellar el acta',
});
}
const companyOutcomes = signatures.filter((item) => const companyOutcomes = signatures.filter((item) =>
item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE, item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE,
); );
@@ -552,6 +568,17 @@ export class InspectionClosingService {
message: 'Debe existir exactamente una firma, disidencia, negativa o ausencia documentada del responsable de la empresa', message: 'Debe existir exactamente una firma, disidencia, negativa o ausencia documentada del responsable de la empresa',
}); });
} }
const inspectorSigned = signatures.some((item) =>
item.signerType === InspectionActSignerType.INSPECTOR
&& item.status === InspectionActSignatureStatus.SIGNED,
);
if (!inspectorSigned) {
const created = await this.createInspectorSignatureFromProfile(
manager, actId, closure.preparedSha256, principal, request,
);
generatedInspectorSignaturePath = created.filePath;
signatures = await this.loadSignatures(manager, actId);
}
const serverSealedAt = new Date(); const serverSealedAt = new Date();
const deviceSealedAt = new Date(dto.clientClosedAt); const deviceSealedAt = new Date(dto.clientClosedAt);
@@ -614,18 +641,21 @@ export class InspectionClosingService {
WHERE act_id=$1 WHERE act_id=$1
`, [actId, finalSnapshot, finalSha256, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]); `, [actId, finalSnapshot, finalSha256, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]);
const [updated] = (await manager.query(` const [updated] = (await manager.query(`
UPDATE inspection_acts WITH updated AS (
SET status='SEALED', UPDATE inspection_acts
sealed_at=$2, SET status='SEALED',
sealed_by=$3, sealed_at=$2,
closed_at=$2, sealed_by=$3,
closed_by=$3, closed_at=$2,
closure_sha256=$4, closed_by=$3,
current_version=current_version+1, closure_sha256=$4,
updated_by=$3, current_version=current_version+1,
updated_at=$2 updated_by=$3,
WHERE id=$1 updated_at=$2
RETURNING current_version AS "versionNumber" WHERE id=$1
RETURNING current_version
)
SELECT current_version AS "versionNumber" FROM updated
`, [actId, serverSealedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; `, [actId, serverSealedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>;
await manager.query(` await manager.query(`
INSERT INTO inspection_act_versions ( INSERT INTO inspection_act_versions (
@@ -659,7 +689,11 @@ export class InspectionClosingService {
}, manager); }, manager);
await this.reports.ensureFrozenReport(manager, actId, principal, request); await this.reports.ensureFrozenReport(manager, actId, principal, request);
return this.loadView(manager, actId); return this.loadView(manager, actId);
}); });
} catch (error) {
if (generatedInspectorSignaturePath) await unlink(generatedInspectorSignaturePath).catch(() => undefined);
throw error;
}
await this.reports.ensureWordForAct(actId); await this.reports.ensureWordForAct(actId);
return sealed; return sealed;
} }
@@ -862,6 +896,8 @@ export class InspectionClosingService {
'id',act.id, 'id',act.id,
'code',act.code, 'code',act.code,
'status',act.status, 'status',act.status,
'actYear',act.act_year,
'actNumber',act.act_number,
'occurredAt',act.occurred_at, 'occurredAt',act.occurred_at,
'title',act.title, 'title',act.title,
'summary',act.summary, 'summary',act.summary,
@@ -876,6 +912,7 @@ export class InspectionClosingService {
'id',visit.id, 'id',visit.id,
'code',visit.code, 'code',visit.code,
'status',visit.status, 'status',visit.status,
'scopeAssetId',visit.scope_asset_id,
'operationalAreaId',visit.operational_area_id, 'operationalAreaId',visit.operational_area_id,
'operatorCompanyId',visit.operator_company_id, 'operatorCompanyId',visit.operator_company_id,
'leadInspectorUserId',visit.lead_inspector_user_id, 'leadInspectorUserId',visit.lead_inspector_user_id,
@@ -888,43 +925,74 @@ export class InspectionClosingService {
`, [actId]) as Array<{ act: Record<string, unknown> }>; `, [actId]) as Array<{ act: Record<string, unknown> }>;
if (!act) throw actNotFound(); if (!act) throw actNotFound();
const responsible = await this.requireResponsible(manager, actId); const responsible = await this.requireResponsible(manager, actId);
const [contextRow] = await manager.query(`
SELECT
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS company,
CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',department.id,'code',department.code,'name',department.name) END AS department,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS area,
CASE WHEN scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',scope.id,'code',scope.code,'name',scope.name,'typeCode',scope_type.code,'typeName',scope_type.name) END AS scope,
CASE WHEN lead.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',lead.id,'username',lead.username,'firstName',lead.first_name,'lastName',lead.last_name,'email',lead.email) END AS "leadInspector"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
LEFT JOIN assets company ON company.id=visit.operator_company_id
LEFT JOIN assets area ON area.id=visit.operational_area_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id
LEFT JOIN users lead ON lead.id=visit.lead_inspector_user_id
WHERE act.id=$1
`, [actId]) as Array<Record<string, unknown>>;
const inspectors = await manager.query(`
SELECT member.id,member.username,member.first_name AS "firstName",member.last_name AS "lastName",member.email
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
JOIN inspection_visit_members link ON link.visit_id=act.visit_id AND link.included=true
JOIN users member ON member.id=link.user_id
WHERE act.id=$1
ORDER BY CASE WHEN member.id=visit.lead_inspector_user_id THEN 0 ELSE 1 END,
member.last_name,member.first_name,member.username
`, [actId]) as Array<Record<string, unknown>>;
const context = { ...(contextRow ?? {}), inspectors };
const inventories = await manager.query(` const inventories = await manager.query(`
SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion", WITH RECURSIVE selected AS (
type.code AS "typeCode",type.name AS "typeName" SELECT asset.id,asset.code,asset.name,asset.common_name,asset.parent_id,asset.current_version,type.code AS type_code,type.name AS type_name,
FROM inspection_act_assets link family.code AS family_code,family.name AS family_name
JOIN assets asset ON asset.id=link.asset_id FROM inspection_act_assets link
JOIN asset_types type ON type.id=asset.asset_type_id JOIN assets asset ON asset.id=link.asset_id
WHERE link.act_id=$1 AND link.included=true JOIN asset_types type ON type.id=asset.asset_type_id
ORDER BY asset.code,asset.id LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE link.act_id=$1 AND link.included=true
AND EXISTS (SELECT 1 FROM inspection_findings finding WHERE finding.act_id=link.act_id AND finding.asset_id=asset.id AND finding.status<>'VOIDED')
), lineage AS (
SELECT selected.id AS root_id,selected.id,selected.code,selected.name,selected.type_code,selected.type_name,selected.parent_id,0 AS depth FROM selected
UNION ALL
SELECT lineage.root_id,parent.id,parent.code,parent.name,parent_type.code,parent_type.name,parent.parent_id,lineage.depth+1
FROM lineage JOIN assets parent ON parent.id=lineage.parent_id JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
WHERE lineage.depth<8
)
SELECT selected.id,selected.code,selected.name,selected.common_name AS "commonName",selected.current_version AS "currentVersion",
selected.type_code AS "typeCode",selected.type_name AS "typeName",
selected.family_code AS "installationTypeCode",selected.family_name AS "installationTypeName",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',lineage.id,'code',lineage.code,'name',lineage.name,'typeCode',lineage.type_code,'typeName',lineage.type_name) ORDER BY lineage.depth DESC) FROM lineage WHERE lineage.root_id=selected.id),'[]'::jsonb) AS path
FROM selected ORDER BY selected.code,selected.id
`, [actId]) as Array<Record<string, unknown>>; `, [actId]) as Array<Record<string, unknown>>;
const findings = await manager.query(` const findings = await manager.query(`
SELECT SELECT finding.id,finding.finding_number AS "findingNumber",finding.code,finding.status,
finding.id, finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId",finding.title,finding.description,
finding.finding_number AS "findingNumber", finding.legal_basis AS "legalBasis",finding.glossary,finding.catalog_revision AS "catalogRevision",
finding.code, finding.suggested_severity AS "suggestedSeverity",finding.severity,
finding.status, finding.is_recurrence AS "isRecurrence",finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
finding.asset_id AS "assetId", finding.correction_due_on AS "correctionDueOn",finding.current_version AS "currentVersion",
finding.catalog_item_id AS "catalogItemId", CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',catalog.id,'code',catalog.code,'sourceNumber',catalog.source_number,'title',catalog.title,'categoryName',category.name,'revision',finding.catalog_revision) END AS catalog,
finding.title, CASE WHEN antecedent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',antecedent.id,'code',antecedent.code,'title',antecedent.title) END AS recurrence
finding.description,
finding.legal_basis AS "legalBasis",
finding.severity,
finding.is_recurrence AS "isRecurrence",
finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
finding.correction_due_on AS "correctionDueOn",
finding.current_version AS "currentVersion"
FROM inspection_findings finding FROM inspection_findings finding
LEFT JOIN finding_catalog_items catalog ON catalog.id=finding.catalog_item_id
LEFT JOIN finding_categories category ON category.id=catalog.category_id
LEFT JOIN inspection_findings antecedent ON antecedent.id=finding.recurrence_of_finding_id
WHERE finding.act_id=$1 AND finding.status<>'VOIDED' WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
ORDER BY finding.finding_number,finding.id ORDER BY finding.finding_number,finding.id
`, [actId]) as Array<Record<string, unknown>>; `, [actId]) as Array<Record<string, unknown>>;
return { return { schemaVersion: CLOSURE_SCHEMA_VERSION, lockedAt: lockedAt.toISOString(), act: act.act, context, responsible, inventories, findings };
schemaVersion: CLOSURE_SCHEMA_VERSION,
lockedAt: lockedAt.toISOString(),
act: act.act,
responsible,
inventories,
findings,
};
} }
private async deadlinePolicy(manager: EntityManager): Promise<DeadlinePolicy> { private async deadlinePolicy(manager: EntityManager): Promise<DeadlinePolicy> {
@@ -1009,7 +1077,7 @@ export class InspectionClosingService {
WHERE act.id=$1 WHERE act.id=$1
`, [actId]) as Array<{ `, [actId]) as Array<{
id: string; code: string; status: InspectionActStatus; visitId: string; id: string; code: string; status: InspectionActStatus; visitId: string;
urgency: InspectionActUrgency; deadlineDays: number | null; urgency: InspectionActUrgency | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null; deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null;
deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null; deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null;
lockedSha256: string | null; sealedAt: Date | null; currentVersion: number; lockedSha256: string | null; sealedAt: Date | null; currentVersion: number;
@@ -1018,6 +1086,7 @@ export class InspectionClosingService {
}>; }>;
if (!context) throw actNotFound(); if (!context) throw actNotFound();
const responsible = await this.loadResponsible(manager, actId); const responsible = await this.loadResponsible(manager, actId);
const representativeSuggestion = responsible ? null : await this.loadResponsibleSuggestion(manager, actId);
const closure = await this.loadClosure(manager, actId); const closure = await this.loadClosure(manager, actId);
const signatures = await this.loadSignatures(manager, actId); const signatures = await this.loadSignatures(manager, actId);
return { return {
@@ -1047,6 +1116,7 @@ export class InspectionClosingService {
actualClosedAt: context.visitActualClosedAt, actualClosedAt: context.visitActualClosedAt,
}, },
responsible, responsible,
representativeSuggestion,
closure: closure ? { closure: closure ? {
schemaVersion: closure.schemaVersion, schemaVersion: closure.schemaVersion,
preparedSha256: closure.preparedSha256, preparedSha256: closure.preparedSha256,
@@ -1079,12 +1149,30 @@ export class InspectionClosingService {
return row ?? null; return row ?? null;
} }
private async loadResponsibleSuggestion(manager: EntityManager, actId: string): Promise<ResponsibleView | null> {
const [row] = await manager.query(`
SELECT responsible.act_id AS "actId",responsible.attendance_status AS "attendanceStatus",
responsible.full_name AS "fullName",responsible.document_type AS "documentType",
responsible.document_number AS "documentNumber",responsible.position,responsible.email,responsible.phone,
responsible.absence_reason AS "absenceReason",responsible.updated_by AS "updatedBy",
responsible.created_at AS "createdAt",responsible.updated_at AS "updatedAt"
FROM inspection_acts current_act
JOIN inspection_acts previous_act
ON previous_act.visit_id=current_act.visit_id AND previous_act.id<>current_act.id
JOIN inspection_act_responsibles responsible ON responsible.act_id=previous_act.id
WHERE current_act.id=$1 AND responsible.attendance_status='PRESENT'
ORDER BY responsible.updated_at DESC,previous_act.created_at DESC
LIMIT 1
`, [actId]) as ResponsibleView[];
return row ?? null;
}
private async requireResponsible(manager: EntityManager, actId: string): Promise<ResponsibleView> { private async requireResponsible(manager: EntityManager, actId: string): Promise<ResponsibleView> {
const responsible = await this.loadResponsible(manager, actId); const responsible = await this.loadResponsible(manager, actId);
if (!responsible) { if (!responsible) {
throw new ConflictException({ throw new ConflictException({
code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED', code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED',
message: 'Debe identificarse al responsable o documentar su ausencia antes de finalizar el acta', message: 'Debe identificarse al representante de la empresa o documentar su ausencia antes de cerrar el Acta',
}); });
} }
return responsible; return responsible;
@@ -1146,6 +1234,82 @@ export class InspectionClosingService {
return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row); return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row);
} }
private async requireReusableInspectorSignature(
manager: EntityManager,
userId: string,
): Promise<ReusableInspectorSignature> {
const [row] = await manager.query(`
SELECT original_name AS "originalName", mime_type AS "mimeType",
size_bytes AS "sizeBytes", image_sha256 AS "imageSha256",
image_data AS "imageData"
FROM user_signature_profiles
WHERE user_id=$1
`, [userId]) as ReusableInspectorSignature[];
if (!row) {
throw new ConflictException({
code: 'INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED',
message: 'Para cerrar definitivamente el Acta, cargá tu firma de inspector desde Mi perfil en el Dashboard',
});
}
return row;
}
private async createInspectorSignatureFromProfile(
manager: EntityManager,
actId: string,
preparedSha256: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<{ filePath: string }> {
const profile = await this.requireReusableInspectorSignature(manager, principal.userId);
const id = randomUUID();
const storedName = `${id}.png`;
const filePath = resolve(this.signatureRoot, storedName);
await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 });
await writeFile(filePath, profile.imageData, { flag: 'wx', mode: 0o600 });
const signedAt = new Date();
const source = this.signatureSource(principal);
const signerName = `${principal.firstName} ${principal.lastName}`.trim();
const payload = {
actId, lockedSha256: preparedSha256, signerType: InspectionActSignerType.INSPECTOR,
signerUserId: principal.userId, signerName, position: 'Inspector/a',
status: InspectionActSignatureStatus.SIGNED, imageSha256: profile.imageSha256,
consentText: INSPECTOR_CONSENT, consentVersion: CONSENT_VERSION,
consentAcceptedAt: signedAt.toISOString(), signedAt: signedAt.toISOString(),
source, signatureMode: 'PROFILE_REUSABLE', uploadedBy: principal.userId,
};
const signaturePayloadSha256 = sha256CanonicalJson(payload);
try {
await manager.query(`
INSERT INTO inspection_act_signatures (
id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,position,status,
original_name,stored_name,mime_type,size_bytes,image_sha256,consent_text,consent_version,
consent_accepted_at,client_signed_at,signed_at,latitude,longitude,accuracy_m,device_label,source,
prepared_sha256,signature_payload_sha256,uploaded_by,created_at
) VALUES (
$1,$2,'INSPECTOR',$3,$4,NULL,NULL,'Inspector/a','SIGNED',
$5,$6,$7,$8,$9,$10,$11,$12,NULL,$12,NULL,NULL,NULL,$13,$14,$15,$16,$3,$12
)
`, [
id, actId, principal.userId, signerName, profile.originalName, storedName, profile.mimeType,
profile.sizeBytes, profile.imageSha256, INSPECTOR_CONSENT, CONSENT_VERSION, signedAt,
'Firma guardada en Mi perfil', source, preparedSha256, signaturePayloadSha256,
]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED,
entityType: 'inspection_act_signature',
entityId: id,
afterData: payload,
metadata: { actId, immutable: true, signatureMode: 'PROFILE_REUSABLE' },
}, manager);
return { filePath };
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
private async loadStoredSignature(manager: EntityManager, id: string): Promise<StoredSignature> { private async loadStoredSignature(manager: EntityManager, id: string): Promise<StoredSignature> {
const [row] = await manager.query(`${this.signatureSelect()} WHERE signature.id=$1`, [id]) as StoredSignature[]; const [row] = await manager.query(`${this.signatureSelect()} WHERE signature.id=$1`, [id]) as StoredSignature[];
if (!row) throw signatureNotFound(); if (!row) throw signatureNotFound();
@@ -16,6 +16,10 @@ import {
} from '../../database/entities'; } from '../../database/entities';
export class CreateInspectionEvidenceDto { export class CreateInspectionEvidenceDto {
@IsOptional()
@IsUUID('4')
operationId?: string;
@IsEnum(InspectionEvidenceKind) @IsEnum(InspectionEvidenceKind)
kind!: InspectionEvidenceKind; kind!: InspectionEvidenceKind;
@@ -16,6 +16,10 @@ const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null; typeof value === 'string' && value.trim() ? value.trim() : null;
export class CreateInspectionFindingDto { export class CreateInspectionFindingDto {
@IsOptional()
@IsUUID('4')
clientGeneratedId?: string;
@IsUUID('4') @IsUUID('4')
assetId!: string; assetId!: string;
@@ -61,7 +61,7 @@ export class F3FindingCatalogResolverService {
inventoryFamily: null, inventoryFamily: null,
catalogSource: 'INVENTORY_FAMILY' as const, catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: false, typeConfigured: false,
configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.', configurationReason: 'El elemento todavía no tiene tipo de instalación/subinstalación activo.',
categories: [], categories: [],
items: [], items: [],
other, other,
@@ -120,7 +120,7 @@ export class F3FindingCatalogResolverService {
}, },
catalogSource: 'INVENTORY_FAMILY' as const, catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: true, typeConfigured: true,
configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(), configurationReason: `Hallazgos asociados al tipo ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
categories, categories,
items, items,
other, other,
@@ -153,6 +153,22 @@ export class InspectionEvidenceService {
assertMobileInspector(principal); assertMobileInspector(principal);
} }
this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM); this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM);
if (dto.operationId) {
const [existing] = await this.dataSource.query(
`${this.evidenceSelect()} WHERE evidence.client_operation_id = $1`,
[dto.operationId],
) as StoredInspectionEvidence[];
if (existing) {
if (existing.findingId !== findingId) {
throw new ConflictException({
code: 'INSPECTION_EVIDENCE_OPERATION_REUSED',
message: 'La operación offline ya fue utilizada para otra evidencia',
});
}
const { storedName: _storedName, ...view } = existing;
return view;
}
}
const inspected = inspectInspectionEvidenceFile(file, dto.kind); const inspected = inspectInspectionEvidenceFile(file, dto.kind);
this.validatePurpose(dto, inspected.mimeType); this.validatePurpose(dto, inspected.mimeType);
@@ -210,12 +226,12 @@ export class InspectionEvidenceService {
id, finding_id, communication_id, verification_visit_id, kind, purpose, id, finding_id, communication_id, verification_visit_id, kind, purpose,
original_name, stored_name, mime_type, size_bytes, sha256, original_name, stored_name, mime_type, size_bytes, sha256,
title, description, captured_at, latitude, longitude, accuracy_m, title, description, captured_at, latitude, longitude, accuracy_m,
device_label, source, uploaded_by device_label, source, uploaded_by, client_operation_id
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17, $12, $13, $14, $15, $16, $17,
$18, $19, $20 $18, $19, $20, $21
) )
`, [ `, [
id, id,
@@ -238,6 +254,7 @@ export class InspectionEvidenceService {
dto.deviceLabel?.trim() || null, dto.deviceLabel?.trim() || null,
source, source,
principal.userId, principal.userId,
dto.operationId ?? null,
]); ]);
const created = await this.loadEvidence(manager, id); const created = await this.loadEvidence(manager, id);
await this.audit.record({ await this.audit.record({
@@ -41,9 +41,18 @@ interface FindingAssetView {
code: string; code: string;
name: string; name: string;
commonName: string | null; commonName: string | null;
typeCode: string;
typeName: string; typeName: string;
operatorCompany: FindingContextView | null; operatorCompany: FindingContextView | null;
operationalArea: FindingContextView | null; operationalArea: FindingContextView | null;
hierarchy: {
department: FindingContextView | null;
area: FindingContextView | null;
yacimiento: FindingContextView | null;
installation: FindingContextView | null;
subinstallation: FindingContextView | null;
company: FindingContextView | null;
};
} }
type FindingCatalogView = { type FindingCatalogView = {
@@ -398,6 +407,22 @@ export class InspectionFindingsService {
const visit = await this.lockVisit(manager, act.visitId); const visit = await this.lockVisit(manager, act.visitId);
this.assertActEditable(act, visit); this.assertActEditable(act, visit);
await this.assertActorAssigned(manager, visit.id, principal); await this.assertActorAssigned(manager, visit.id, principal);
if (dto.clientGeneratedId) {
const [existing] = await manager.query(`
SELECT id, act_id AS "actId", asset_id AS "assetId"
FROM inspection_findings
WHERE id=$1
`, [dto.clientGeneratedId]) as Array<{ id: string; actId: string; assetId: string }>;
if (existing) {
if (existing.actId !== actId || existing.assetId !== dto.assetId) {
throw new ConflictException({
code: 'INSPECTION_FINDING_CLIENT_ID_REUSED',
message: 'El identificador offline del Hallazgo ya fue utilizado en otro contexto',
});
}
return this.loadView(manager, existing.id);
}
}
await this.assertActAsset(manager, actId, dto.assetId); await this.assertActAsset(manager, actId, dto.assetId);
const catalog = dto.catalogItemId const catalog = dto.catalogItemId
@@ -418,6 +443,7 @@ export class InspectionFindingsService {
}); });
} }
const finding = manager.getRepository(InspectionFinding).create({ const finding = manager.getRepository(InspectionFinding).create({
id: dto.clientGeneratedId,
actId, actId,
assetId: dto.assetId, assetId: dto.assetId,
catalogItemId: catalog?.id ?? null, catalogItemId: catalog?.id ?? null,
@@ -612,6 +638,13 @@ export class InspectionFindingsService {
if (Object.keys(dto).length === 0) { if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
} }
if (dto.companyResponse !== undefined || dto.companyResponseReceivedOn !== undefined
|| dto.companyCommittedCorrectionOn !== undefined) {
throw new ConflictException({
code: 'INSPECTION_FINDING_COMPANY_RESPONSE_IN_REPORT',
message: 'La respuesta de la empresa se registra en el Informe relacionado, conservando este Hallazgo como antecedente.',
});
}
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const finding = await this.lockFinding(manager, id); const finding = await this.lockFinding(manager, id);
this.assertFindingOpen(finding); this.assertFindingOpen(finding);
@@ -785,13 +818,34 @@ export class InspectionFindingsService {
'code', asset.code, 'code', asset.code,
'name', asset.name, 'name', asset.name,
'commonName', asset.common_name, 'commonName', asset.common_name,
'typeCode', asset_type.code,
'typeName', asset_type.name, 'typeName', asset_type.name,
'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', company.id, 'code', company.code, 'name', company.name 'id', company.id, 'code', company.code, 'name', company.name
) END, ) END,
'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', area.id, 'code', area.code, 'name', area.name 'id', area.id, 'code', area.code, 'name', area.name
) END ) END,
'hierarchy', JSONB_BUILD_OBJECT(
'department', CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',department.id,'code',department.code,'name',department.name
) END,
'area', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',area.id,'code',area.code,'name',area.name
) END,
'yacimiento', CASE WHEN yacimiento.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',yacimiento.id,'code',yacimiento.code,'name',yacimiento.name
) END,
'installation', CASE WHEN installation.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',installation.id,'code',installation.code,'name',installation.name
) END,
'subinstallation', CASE WHEN subinstallation.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',subinstallation.id,'code',subinstallation.code,'name',subinstallation.name
) END,
'company', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',company.id,'code',company.code,'name',company.name
) END
)
) AS asset, ) AS asset,
CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', catalog.id, 'id', catalog.id,
@@ -837,6 +891,24 @@ export class InspectionFindingsService {
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
LEFT JOIN assets company ON company.id = asset.operator_company_id LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN assets department ON department.id = area.parent_id
LEFT JOIN assets parent_asset ON parent_asset.id = asset.parent_id
LEFT JOIN assets grandparent_asset ON grandparent_asset.id = parent_asset.parent_id
LEFT JOIN assets yacimiento ON yacimiento.id = CASE
WHEN lower(asset_type.code)='yacimiento' THEN asset.id
WHEN lower(asset_type.code)='instalacion' THEN parent_asset.id
WHEN lower(asset_type.code)='subinstalacion' THEN grandparent_asset.id
ELSE NULL
END
LEFT JOIN assets installation ON installation.id = CASE
WHEN lower(asset_type.code)='instalacion' THEN asset.id
WHEN lower(asset_type.code)='subinstalacion' THEN parent_asset.id
ELSE NULL
END
LEFT JOIN assets subinstallation ON subinstallation.id = CASE
WHEN lower(asset_type.code)='subinstalacion' THEN asset.id
ELSE NULL
END
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
LEFT JOIN finding_categories category ON category.id = catalog.category_id LEFT JOIN finding_categories category ON category.id = catalog.category_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -1166,10 +1238,13 @@ export class InspectionFindingsService {
principal: AuthPrincipal, principal: AuthPrincipal,
): Promise<number> { ): Promise<number> {
const [row] = await manager.query(` const [row] = await manager.query(`
UPDATE inspection_findings WITH updated AS (
SET current_version = current_version + 1 UPDATE inspection_findings
WHERE id = $1 SET current_version = current_version + 1
RETURNING current_version AS "versionNumber" WHERE id = $1
RETURNING current_version
)
SELECT current_version AS "versionNumber" FROM updated
`, [finding.id]) as Array<{ versionNumber: number }>; `, [finding.id]) as Array<{ versionNumber: number }>;
const versionNumber = Number(row.versionNumber); const versionNumber = Number(row.versionNumber);
finding.currentVersion = versionNumber; finding.currentVersion = versionNumber;
@@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsDateString, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class SetInspectionReportResponseDeadlineDto {
@IsDateString()
responseDueOn!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MinLength(3)
@MaxLength(1000)
reason?: string | null;
}
@@ -1,212 +1,300 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import PDFDocument = require('pdfkit');
function asRecord(value: unknown): Record<string, unknown> { export interface ActPdfImage {
return value && typeof value === 'object' && !Array.isArray(value) id: string;
? value as Record<string, unknown> findingId?: string;
: {}; assetId?: string;
signerName?: string;
title?: string;
capturedAt?: string | Date | null;
sha256: string;
buffer: Buffer;
} }
export interface ActPdfContext {
companyName?: string | null;
departmentName?: string | null;
areaName?: string | null;
scopeName?: string | null;
scopeCode?: string | null;
scopeTypeName?: string | null;
scopeTypeCode?: string | null;
yacimientoName?: string | null;
yacimientoCode?: string | null;
leadInspectorName?: string | null;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function asArray(value: unknown): Array<Record<string, unknown>> { function asArray(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value.map(asRecord) : []; return Array.isArray(value) ? value.map(asRecord) : [];
} }
function text(value: unknown, fallback = ''): string {
function text(value: unknown, fallback = '-'): string { return String(value ?? '').trim() || fallback;
const out = String(value ?? '').trim();
return out || fallback;
} }
function date(value: unknown): string { function date(value: unknown): string {
const parsed = new Date(String(value ?? '')); const parsed = new Date(String(value ?? ''));
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '-'; return Number.isFinite(parsed.getTime())
? new Intl.DateTimeFormat('es-AR', {
timeZone: 'America/Argentina/Mendoza', day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(parsed)
: '-';
}
function fullName(value: Record<string, unknown>): string {
return `${text(value.firstName)} ${text(value.lastName)}`.trim() || text(value.username);
}
function isPlaceholder(value: unknown): boolean {
return text(value).startsWith('Acta de inspección en curso. Los Hallazgos');
}
function urgency(value: unknown): string {
return text(value) === 'URGENT' ? 'Urgente' : text(value) === 'NON_URGENT' ? 'No urgente' : '-';
}
function dayType(value: unknown): string {
return text(value) === 'BUSINESS' ? 'días hábiles' : text(value) === 'CALENDAR' ? 'días corridos' : '';
}
function deadlineBasis(value: unknown): string {
return text(value) === 'ACT_DATE' ? 'Desde la fecha del Acta' : text(value) === 'GEDO_DATE' ? 'Desde la notificación formal' : '';
}
function manifestation(value: unknown): string {
return text(value) === 'CONFORMITY' ? 'Firma sin disconformidad' : text(value) === 'DISSENT' ? 'Firma en disconformidad' : '';
}
function signatureStatus(value: unknown): string {
return text(value) === 'SIGNED' ? 'Firmó' : text(value) === 'REFUSED' ? 'Se negó a firmar' : text(value) === 'ABSENT' ? 'Ausente' : 'No firmó';
} }
function clean(value: string): string { // %PDF-1.4 is the document-version contract for consolidated Actas.
return value export async function buildInspectionActPdf(
.normalize('NFKD') snapshot: Record<string, unknown>,
.replace(/[\u0300-\u036f]/g, '') images: ActPdfImage[] = [],
.replace(/[–—]/g, '-') fallbackContext: ActPdfContext = {},
.replace(/[“”]/g, '"') ): Promise<{ buffer: Buffer; sha256: string }> {
.replace(/[‘’]/g, "'")
.replace(/[^\x20-\xFF]/g, '?');
}
function escapePdf(value: string): string {
return clean(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)');
}
function wrap(value: string, max = 92): string[] {
const words = clean(value).split(/\s+/).filter(Boolean);
const out: string[] = [];
let line = '';
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > max && line) {
out.push(line);
line = word;
} else {
line = next;
}
}
if (line) out.push(line);
return out.length ? out : ['-'];
}
function lockedSnapshot(snapshot: Record<string, unknown>): {
locked: Record<string, unknown>;
signatures: Array<Record<string, unknown>>;
finalSha256: unknown;
} {
const sealed = asRecord(snapshot); const sealed = asRecord(snapshot);
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot); const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
return {
locked,
signatures: asArray(sealed.signatures),
finalSha256: sealed.finalSha256 ?? sealed.lockedSha256 ?? sealed.preparedSha256,
};
}
function urgencyLabel(value: unknown): string {
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
}
function dayTypeLabel(value: unknown): string {
return text(value, '') === 'CALENDAR' ? 'dias corridos' : 'dias habiles';
}
function lines(snapshot: Record<string, unknown>): string[] {
const source = lockedSnapshot(snapshot);
const locked = source.locked;
const act = asRecord(locked.act); const act = asRecord(locked.act);
const inspection = asRecord(act.inspection ?? asRecord(act).visit); const inspection = asRecord(act.inspection ?? act.visit);
const context = asRecord(locked.context);
const company = asRecord(context.company);
const department = asRecord(context.department);
const area = asRecord(context.area);
const scope = asRecord(context.scope);
const leadInspector = asRecord(context.leadInspector);
const inspectors = asArray(context.inspectors);
const responsible = asRecord(locked.responsible); const responsible = asRecord(locked.responsible);
const inventories = asArray(locked.inventories ?? locked.assets); const inventories = asArray(locked.inventories);
const findings = asArray(locked.findings); const findings = asArray(locked.findings);
const signatures = source.signatures; const signatures = asArray(sealed.signatures);
const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE'); const seal = asRecord(sealed.seal);
const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR'); const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
const logo = resolve(process.cwd(), 'assets/logo-mendoza.png');
const deadlineText = act.deadlineAt const doc = new PDFDocument({ size: 'A4', pdfVersion: '1.4', margins: { top: 146, bottom: 80, left: 54, right: 54 }, compress: true });
? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})` doc.registerFont('body', resolve(process.cwd(), 'assets/fonts/DejaVuSans.ttf'));
: act.deadlineBasis === 'GEDO_DATE' doc.registerFont('body-bold', resolve(process.cwd(), 'assets/fonts/DejaVuSans-Bold.ttf'));
? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO` const chunks: Buffer[] = [];
: '-'; doc.on('data', (part: Buffer) => chunks.push(part));
const done = new Promise<Buffer>((complete, reject) => { doc.on('end', () => complete(Buffer.concat(chunks))); doc.on('error', reject); });
const blue = '#162D69';
const ink = '#202939';
const muted = '#5A667C';
const out: string[] = [ const header = () => {
'ACTA DE INSPECCION', doc.font('body-bold').fillColor(blue).fontSize(11).text('MINISTERIO DE ENERGÍA Y AMBIENTE', 54, 42);
'', doc.text('DIRECCIÓN DE HIDROCARBUROS', 54, 58);
`Acta: ${text(act.code)}`, if (existsSync(logo)) doc.image(logo, 474, 32, { fit: [62, 82] });
`Inspeccion: ${text(inspection.code)}`, doc.moveTo(54, 121).lineTo(540, 121).strokeColor('#BAC5DA').stroke();
`Fecha: ${date(act.occurredAt)}`, doc.y = 146;
`Urgencia: ${urgencyLabel(act.urgency)}`, };
`Plazo: ${deadlineText}`, doc.on('pageAdded', header);
`Responsable empresa: ${text(responsible.fullName)}`, header();
`Cargo: ${text(responsible.position)}`,
'',
'RESUMEN',
...wrap(text(act.summary)),
'',
'OBSERVACIONES',
...wrap(text(act.observations)),
'',
'INVENTARIO INSPECCIONADO',
];
if (!inventories.length) out.push('-'); const need = (height: number) => { if (doc.y + height > doc.page.height - 85) doc.addPage(); };
for (const item of inventories) { const section = (label: string) => {
out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`)); need(46);
doc.moveDown(0.9);
doc.font('body-bold').fillColor(blue).fontSize(12).text(label.toUpperCase());
doc.moveTo(54, doc.y + 3).lineTo(540, doc.y + 3).strokeColor('#D5DCE8').stroke();
doc.moveDown(0.55);
};
const body = (value: unknown) => {
need(24);
doc.font('body').fillColor(ink).fontSize(10.2).text(text(value, '-'), { lineGap: 3 });
doc.moveDown(0.4);
};
const label = (name: string, value: unknown, allowEmpty = false) => {
const rendered = text(value);
if (!rendered && !allowEmpty) return;
need(24);
doc.font('body-bold').fillColor(ink).fontSize(9.8).text(`${name}: `, { continued: true });
doc.font('body').text(rendered || '-');
doc.moveDown(0.28);
};
const subheading = (value: string) => {
need(32);
doc.font('body-bold').fillColor(blue).fontSize(10.8).text(value);
doc.moveDown(0.25);
};
const image = (entry: ActPdfImage, caption: string) => {
need(235);
const y = doc.y;
doc.image(entry.buffer, 58, y, { fit: [470, 190] });
doc.y = y + 195;
doc.font('body').fontSize(8).fillColor(muted).text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 });
doc.moveDown(0.5);
};
doc.font('body-bold').fillColor(ink).fontSize(18.5).text(`ACTA DE INSPECCIÓN ${text(act.code)}`);
doc.moveDown(0.15);
doc.font('body').fillColor(muted).fontSize(9).text('Documento consolidado de actuación inspectiva');
section('1. Identificación del Acta');
label('Código del Acta', act.code);
if (act.actNumber || act.actYear) label('Número / Año', `${text(act.actNumber, '-')} / ${text(act.actYear, '-')}`);
label('Inspección', inspection.code);
label('Fecha y hora de actuación', date(act.occurredAt));
label('Estado documental', 'Sellada');
label('Versión de esquema', locked.schemaVersion ?? sealed.schemaVersion);
section('2. Contexto territorial y operativo');
label('Empresa / Operadora', company.name ?? fallbackContext.companyName);
label('Departamento', department.name ?? fallbackContext.departmentName);
label('Área', area.name ?? fallbackContext.areaName);
const pathYacimientos = inventories.flatMap((inventory) => asArray(inventory.path))
.filter((node) => text(node.typeCode).toLowerCase() === 'yacimiento');
const scopeTypeCode = text(scope.typeCode ?? fallbackContext.scopeTypeCode).toLowerCase();
const yacimientoNames = Array.from(new Set([
...(scopeTypeCode === 'yacimiento' ? [text(scope.name ?? fallbackContext.scopeName)] : []),
...pathYacimientos.map((node) => text(node.name)),
text(fallbackContext.yacimientoName),
].filter(Boolean)));
const yacimientoCodes = Array.from(new Set([
...(scopeTypeCode === 'yacimiento' ? [text(scope.code ?? fallbackContext.scopeCode)] : []),
...pathYacimientos.map((node) => text(node.code)),
text(fallbackContext.yacimientoCode),
].filter(Boolean)));
if (yacimientoNames.length) label(yacimientoNames.length > 1 ? 'Yacimientos' : 'Yacimiento', yacimientoNames.join(', '));
if (yacimientoCodes.length) label(yacimientoCodes.length > 1 ? 'Códigos de yacimiento' : 'Código de yacimiento', yacimientoCodes.join(', '));
if (scopeTypeCode && scopeTypeCode !== 'yacimiento') {
const scopeLabel = `${text(scope.typeName ?? fallbackContext.scopeTypeName)} - ${text(scope.name ?? fallbackContext.scopeName)}${text(scope.code ?? fallbackContext.scopeCode) ? ` [${text(scope.code ?? fallbackContext.scopeCode)}]` : ''}`;
label('Alcance de la inspección', scopeLabel);
}
label('Inicio efectivo de la inspección', date(inspection.actualStartedAt));
section('3. Intervinientes');
const leadName = fullName(leadInspector) || fallbackContext.leadInspectorName || '';
label('Inspector/a responsable', leadName);
const otherInspectors = inspectors.map(fullName).filter((name) => name && name !== leadName);
if (otherInspectors.length) label('Otros inspectores actuantes', otherInspectors.join(', '));
label('Situación del representante', text(responsible.attendanceStatus) === 'ABSENT' ? 'Ausente' : 'Presente');
label('Representante de la empresa', responsible.fullName);
const document = [text(responsible.documentType), text(responsible.documentNumber)].filter(Boolean).join(' ');
label('Documento', document);
label('Cargo o función', responsible.position);
label('Correo electrónico', responsible.email);
label('Teléfono', responsible.phone);
if (text(responsible.attendanceStatus) === 'ABSENT') label('Motivo de ausencia', responsible.absenceReason);
section('4. Datos del Acta');
label('Objeto / Denominación', act.title);
label('Urgencia', urgency(act.urgency));
if (act.deadlineDays != null) {
const duration = `${text(act.deadlineDays)} ${dayType(act.deadlineDayType)}`.trim();
label('Plazo', duration);
label('Cómputo del plazo', deadlineBasis(act.deadlineBasis));
if (act.deadlineBaseAt) label('Fecha base', date(act.deadlineBaseAt));
if (act.deadlineAt) label('Vencimiento', date(act.deadlineAt));
}
subheading('Descripción de lo actuado');
if (text(act.summary) && !isPlaceholder(act.summary)) body(act.summary);
else body(`Se realizó la inspección ${text(inspection.code)}. El contenido constatado se detalla en los hallazgos registrados a continuación.`);
if (text(act.observations)) {
subheading('Observaciones generales');
body(act.observations);
} }
out.push('', 'HALLAZGOS'); section('5. Hallazgos');
if (!findings.length) out.push('Sin hallazgos registrados.'); if (!findings.length) body('No se registraron hallazgos en esta Acta.');
for (const item of findings) { for (const finding of findings) {
const recurrence = item.isRecurrence const inventory = inventories.find((item) => text(item.id) === text(finding.assetId)) ?? {};
? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}` const catalog = asRecord(finding.catalog);
: ''; const recurrence = asRecord(finding.recurrence);
out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`)); const path = asArray(inventory.path);
out.push(...wrap(`Descripcion: ${text(item.description)}`)); const route = path.map((node) => `${text(node.typeName)}: ${text(node.name)}${text(node.code) ? ` [${text(node.code)}]` : ''}`).filter(Boolean).join(' > ');
if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`)); const number = text(finding.findingNumber, '?');
need(100);
doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`);
doc.moveDown(0.2);
label('Elemento afectado', `${text(inventory.typeName)} - ${text(inventory.name)}${text(inventory.code) ? ` [${text(inventory.code)}]` : ''}`);
if (text(inventory.installationTypeName)) label('Tipo de instalación', inventory.installationTypeName);
if (route) label('Ubicación / Ruta jerárquica', route);
label('Denominación del hallazgo', finding.title);
subheading('Qué se constató');
body(finding.description);
if (text(catalog.id)) {
const source = [text(catalog.categoryName), text(catalog.title), text(catalog.code), catalog.sourceNumber ? `Ítem ${text(catalog.sourceNumber)}` : ''].filter(Boolean).join(' · ');
label('Referencia de catálogo', source);
} else {
label('Referencia de catálogo', 'OTROS / hallazgo cargado en campo');
}
if (text(finding.glossary)) label('Criterio / Referencia técnica', finding.glossary);
if (text(finding.legalBasis)) label('Normativa / Base legal', finding.legalBasis);
if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`);
if (finding.isRecurrence) {
label('Reincidencia', 'Sí');
if (text(recurrence.code) || text(recurrence.title)) label('Antecedente relacionado', `${text(recurrence.code)}${text(recurrence.title) ? ` - ${text(recurrence.title)}` : ''}`);
} else {
label('Reincidencia', 'No');
}
const photos = images.filter((item) => item.findingId === text(finding.id));
if (photos.length) {
subheading(`Evidencia fotográfica (${photos.length})`);
for (const photo of photos) image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
} else {
label('Evidencia fotográfica', 'Sin fotografías asociadas');
}
doc.moveDown(0.7);
} }
out.push('', 'FIRMAS Y CONSTANCIAS'); section('6. Firmas y manifestaciones');
if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.'); for (const signature of signatures) {
for (const signature of inspectorSignatures) { const name = text(signature.signerName);
out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`)); const inspector = text(signature.signerType) === 'INSPECTOR';
} const role = inspector ? 'Inspector/a' : 'Representante de la empresa';
if (!companySignature) { subheading(`${role}: ${name}`);
out.push('Manifestacion de empresa: pendiente.'); const signedDocument = [text(signature.documentType), text(signature.documentNumber)].filter(Boolean).join(' ');
} else if (text(companySignature.status, '') === 'SIGNED') { if (signedDocument) label('Documento', signedDocument);
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY'); label('Cargo o función', signature.position);
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disidencia' : 'Empresa: firma en conformidad'); label('Resultado', signatureStatus(signature.status));
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement))); if (!inspector && text(signature.companyManifestation)) label('Manifestación', manifestation(signature.companyManifestation));
} else { if (signature.companyManifestation === 'DISSENT') label('Fundamento de la disconformidad', signature.companyStatement);
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`)); if (text(signature.status) === 'REFUSED' || text(signature.status) === 'ABSENT') label('Motivo', signature.reason);
label('Fecha y hora de la constancia', date(signature.signedAt ?? signature.createdAt));
if (text(signature.source)) label('Origen de la constancia', text(signature.source) === 'ANDROID' ? 'Aplicación móvil' : 'Dashboard web');
const signatureImage = images.find((item) => item.signerName === name && item.sha256 === text(signature.imageSha256));
if (signatureImage) {
need(100);
const y = doc.y;
doc.image(signatureImage.buffer, 60, y, { fit: [230, 60] });
doc.y = y + 66;
}
doc.moveDown(0.5);
} }
out.push( section('7. Integridad y cierre');
'', label('Contenido bloqueado', date(locked.lockedAt));
'INTEGRIDAD', label('Sellado en servidor', date(seal.serverSealedAt));
`Hash del Acta sellada: ${text(source.finalSha256)}`, label('Fecha informada por dispositivo', date(seal.deviceSealedAt));
`Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`, label('Modo de cierre', seal.uploadMode);
); body(`SHA-256 del cierre: ${hash}`);
return out; body(`SHA-256 del contenido bloqueado: ${text(sealed.lockedSha256)}`);
} need(28);
doc.fontSize(8).fillColor(muted).text(`Acta ${text(act.code)} · plantilla consolidada v3 · ${date(seal.serverSealedAt)}`, 54, doc.y);
function objectBuffer(id: number, body: Buffer | string): Buffer { doc.end();
const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1'); const buffer = await done;
return Buffer.concat([
Buffer.from(`${id} 0 obj\n`, 'ascii'),
data,
Buffer.from('\nendobj\n', 'ascii'),
]);
}
export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } {
const all = lines(snapshot);
const chunks: Array<string[]> = [];
for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56));
if (!chunks.length) chunks.push(['ACTA DE INSPECCION']);
const pageCount = chunks.length;
const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2);
const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2);
const objects: Buffer[] = [];
objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>'));
objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`));
objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
chunks.forEach((chunk, index) => {
const content = chunk
.map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`)
.join('\n');
const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1');
objects.push(objectBuffer(
pageIds[index]!,
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`,
));
objects.push(objectBuffer(
contentIds[index]!,
Buffer.concat([
Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'),
stream,
Buffer.from('\nendstream', 'ascii'),
]),
));
});
const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary');
const offsets: number[] = [0];
let position = header.length;
for (const object of objects) {
offsets.push(position);
position += object.length;
}
const xrefOffset = position;
const xref = [
`xref\n0 ${objects.length + 1}\n`,
'0000000000 65535 f \n',
...objects.map((_, index) => `${String(offsets[index + 1]).padStart(10, '0')} 00000 n \n`),
].join('');
const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
const buffer = Buffer.concat([header, ...objects, Buffer.from(xref + trailer, 'ascii')]);
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
} }
@@ -4,11 +4,15 @@ import { isAbsolute, parse, resolve } from 'node:path';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { buildInspectionActPdf } from './inspection-act-pdf-builder'; import { buildInspectionActPdf, type ActPdfContext, type ActPdfImage } from './inspection-act-pdf-builder';
import { renderableInspectionImage } from './inspection-document-images';
@Injectable() @Injectable()
export class InspectionActPdfService { export class InspectionActPdfService {
private readonly root: string; private readonly root: string;
private readonly evidenceRoot: string;
private readonly assetRoot: string;
private readonly signatureRoot: string;
constructor(private readonly dataSource: DataSource, config: ConfigService) { constructor(private readonly dataSource: DataSource, config: ConfigService) {
const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT') const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT')
@@ -16,6 +20,9 @@ export class InspectionActPdfService {
if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path');
this.root = resolve(configured); this.root = resolve(configured);
if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root');
this.evidenceRoot = resolve(config.get<string>('INSPECTION_EVIDENCE_ROOT') ?? '/app/storage/asset-media/inspection-findings');
this.assetRoot = resolve(config.get<string>('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media');
this.signatureRoot = resolve(config.get<string>('INSPECTION_SIGNATURE_ROOT') ?? '/app/storage/asset-media/inspection-signatures');
} }
async ensure(actId: string): Promise<void> { async ensure(actId: string): Promise<void> {
@@ -59,7 +66,7 @@ export class InspectionActPdfService {
`, [actId]); `, [actId]);
try { try {
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 }; const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
const built = buildInspectionActPdf(snapshot); const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId, false), await this.actContext(actId));
await mkdir(this.root, { recursive: true, mode: 0o700 }); await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${actId}.pdf`; const storedName = `${actId}.pdf`;
const originalName = `${row.code}.pdf`; const originalName = `${row.code}.pdf`;
@@ -82,6 +89,154 @@ export class InspectionActPdfService {
} }
} }
// The original sealed PDF remains immutable for historic delivery and audit.
// A consolidated presentation is stored separately and frozen at its first generation.
async consolidatedContent(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
const [existing] = await this.dataSource.query(`
SELECT stored_name AS "storedName",original_name AS "originalName",
size_bytes AS "sizeBytes",sha256
FROM inspection_act_consolidated_pdf_revisions
WHERE act_id=$1 AND template_version=3
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (existing) {
const buffer = await this.verifiedImage(this.root, existing);
return { buffer, originalName: existing.originalName, mimeType: 'application/pdf' };
}
const [row] = await this.dataSource.query(`
SELECT act.code,act.closure_sha256 AS "closureSha256",closure.final_snapshot AS "finalSnapshot"
FROM inspection_acts act
JOIN inspection_act_closures closure ON closure.act_id=act.id
WHERE act.id=$1 AND act.status='SEALED'
`, [actId]) as Array<{ code: string; closureSha256: string; finalSnapshot: Record<string, unknown> }>;
if (!row) throw new NotFoundException({
code: 'INSPECTION_ACT_NOT_SEALED',
message: 'El Acta debe estar firmada y sellada para generar el documento consolidado',
});
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId, false), await this.actContext(actId));
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${actId}-consolidado-${built.sha256.slice(0, 24)}.pdf`;
const originalName = `${row.code}-consolidada.pdf`;
await writeFile(resolve(this.root, storedName), built.buffer, { flag: 'wx', mode: 0o600 }).catch(async (error: NodeJS.ErrnoException) => {
if (error.code !== 'EEXIST') throw error;
const previous = await this.verifiedImage(this.root, {
storedName, sizeBytes: built.buffer.length, sha256: built.sha256,
});
if (!previous.equals(built.buffer)) throw this.storageError();
});
await this.dataSource.query(`
INSERT INTO inspection_act_consolidated_pdf_revisions(act_id,template_version,stored_name,original_name,size_bytes,sha256)
VALUES($1,3,$2,$3,$4,$5) ON CONFLICT (act_id,template_version) DO NOTHING
`, [actId, storedName, originalName, built.buffer.length, built.sha256]);
const [saved] = await this.dataSource.query(`
SELECT stored_name AS "storedName",original_name AS "originalName",
size_bytes AS "sizeBytes",sha256
FROM inspection_act_consolidated_pdf_revisions WHERE act_id=$1 AND template_version=3
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (!saved) throw this.storageError();
return {
buffer: await this.verifiedImage(this.root, saved),
originalName: saved.originalName, mimeType: 'application/pdf',
};
}
async consolidatedRevisionContent(actId: string, version: number): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
if (![1, 2, 3].includes(version)) throw new NotFoundException('Versión documental inexistente');
const [row] = await this.dataSource.query(`
SELECT stored_name AS "storedName",original_name AS "originalName",
size_bytes AS "sizeBytes",sha256
FROM inspection_act_consolidated_pdf_revisions
WHERE act_id=$1 AND template_version=$2
`, [actId, version]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (!row) throw new NotFoundException('Versión documental inexistente');
return { buffer: await this.verifiedImage(this.root, row), originalName: row.originalName, mimeType: 'application/pdf' };
}
private async actContext(actId: string): Promise<ActPdfContext> {
const [row] = await this.dataSource.query(`
SELECT company.name AS "companyName",department.name AS "departmentName",area.name AS "areaName",
scope.name AS "scopeName",scope.code AS "scopeCode",scope_type.name AS "scopeTypeName",scope_type.code AS "scopeTypeCode",
COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.name END,yacimiento_from_findings.name) AS "yacimientoName",
COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.code END,yacimiento_from_findings.code) AS "yacimientoCode",
btrim(concat_ws(' ',lead.first_name,lead.last_name)) AS "leadInspectorName"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
LEFT JOIN assets company ON company.id=visit.operator_company_id
LEFT JOIN assets area ON area.id=visit.operational_area_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id
LEFT JOIN users lead ON lead.id=visit.lead_inspector_user_id
LEFT JOIN LATERAL (
WITH RECURSIVE ancestors AS (
SELECT asset.id,asset.parent_id,asset.code,asset.name,asset.asset_type_id
FROM inspection_findings finding JOIN assets asset ON asset.id=finding.asset_id
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
UNION
SELECT parent.id,parent.parent_id,parent.code,parent.name,parent.asset_type_id
FROM ancestors JOIN assets parent ON parent.id=ancestors.parent_id
)
SELECT string_agg(DISTINCT ancestors.name, ', ' ORDER BY ancestors.name) AS name,
string_agg(DISTINCT ancestors.code, ', ' ORDER BY ancestors.code) AS code
FROM ancestors JOIN asset_types ancestor_type ON ancestor_type.id=ancestors.asset_type_id
WHERE lower(ancestor_type.code)='yacimiento'
) yacimiento_from_findings ON true
WHERE act.id=$1
`, [actId]) as ActPdfContext[];
return row ?? {};
}
private async verifiedImage(root: string, row: { storedName: string; sha256: string; sizeBytes: number }): Promise<Buffer> {
if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw new Error('Ruta inválida de evidencia del Acta');
const path = resolve(root, row.storedName);
if (!path.startsWith(`${root}/`)) throw new Error('Ruta inválida de evidencia del Acta');
const file = await stat(path);
if (!file.isFile() || file.size !== Number(row.sizeBytes)) throw new Error('Evidencia incompleta del Acta');
const buffer = await readFile(path);
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw new Error('Hash de evidencia distinto del registrado');
return buffer;
}
async fieldImages(actId: string, includeAssetPhotos = true): Promise<ActPdfImage[]> {
type ImageRow = { id: string; findingId?: string; assetId?: string; signerName?: string; title?: string; capturedAt?: Date; storedName: string; sha256: string; sizeBytes: number };
const findings = await this.dataSource.query(`
SELECT evidence.id, finding.id AS "findingId", evidence.title,
evidence.captured_at AS "capturedAt", evidence.stored_name AS "storedName",
evidence.sha256, evidence.size_bytes AS "sizeBytes"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id=finding.act_id
JOIN inspection_finding_evidence evidence ON evidence.finding_id=finding.id
WHERE act.id=$1 AND evidence.kind='PHOTO' AND evidence.purpose='OBSERVATION'
AND evidence.created_at<=act.locked_at
ORDER BY finding.finding_number,evidence.captured_at,evidence.id
`, [actId]) as ImageRow[];
const assets = includeAssetPhotos ? await this.dataSource.query(`
SELECT media.id,asset.id AS "assetId", asset.name AS title,
capture.device_captured_at AS "capturedAt",media.stored_name AS "storedName",
media.sha256,media.size_bytes AS "sizeBytes"
FROM inspection_acts act
JOIN inspection_act_assets link ON link.act_id=act.id AND link.included=true
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_field_capture_events capture ON capture.visit_id=act.visit_id
AND capture.asset_id=asset.id AND capture.event_type='PHOTO'
JOIN asset_media media ON media.id=capture.media_id AND media.deleted_at IS NULL AND media.kind='PHOTO'
WHERE act.id=$1 AND capture.created_at<=act.locked_at
ORDER BY capture.device_captured_at,media.id
`, [actId]) as ImageRow[] : [];
const signatures = await this.dataSource.query(`
SELECT signature.id,signature.signer_name AS "signerName",signature.stored_name AS "storedName",
signature.image_sha256 AS sha256,signature.size_bytes AS "sizeBytes"
FROM inspection_act_signatures signature WHERE signature.act_id=$1
AND signature.status='SIGNED' AND signature.stored_name IS NOT NULL
ORDER BY signature.created_at,signature.id
`, [actId]) as ImageRow[];
const result: ActPdfImage[] = [];
for (const row of findings) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.evidenceRoot, row)) });
for (const row of assets) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.assetRoot, row)) });
for (const row of signatures) result.push({ ...row, buffer: await this.verifiedImage(this.signatureRoot, row) });
return result;
}
async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> { async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT original_name AS "originalName",stored_name AS "storedName", SELECT original_name AS "originalName",stored_name AS "storedName",
@@ -281,7 +281,7 @@ export class InspectionDocumentDeliveryService {
email = company?.email ?? null; email = company?.email ?? null;
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) { } else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
const [inspector] = await this.dataSource.query(` const [inspector] = await this.dataSource.query(`
SELECT email FROM users WHERE id=$1 AND is_active=true SELECT email FROM users WHERE id=$1 AND status='ACTIVE'
`, [row.recipientUserId]) as Array<{ email: string | null }>; `, [row.recipientUserId]) as Array<{ email: string | null }>;
email = inspector?.email ?? null; email = inspector?.email ?? null;
} else { } else {
@@ -305,7 +305,14 @@ export class InspectionDocumentDeliveryService {
await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado'); await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');
return; return;
} }
if (!await this.smtp.configured()) { const [sender] = await this.dataSource.query(`
SELECT visit.lead_inspector_user_id AS "userId"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
WHERE act.id=$1
`, [row.actId]) as Array<{ userId: string | null }>;
const senderUserId = sender?.userId ?? undefined;
if (!await this.smtp.configured(senderUserId)) {
await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado'); await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');
return; return;
} }
@@ -348,7 +355,7 @@ export class InspectionDocumentDeliveryService {
: `Se adjunta el acta sellada e inmutable ${row.actCode}.`; : `Se adjunta el acta sellada e inmutable ${row.actCode}.`;
const sent = await this.smtp.send({ const sent = await this.smtp.send({
to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment, to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
}); }, senderUserId);
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_document_deliveries UPDATE inspection_document_deliveries
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2, SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
@@ -0,0 +1,14 @@
const sharp = require('sharp') as typeof import('sharp').default;
// PDFKit and the Word image renderer accept JPEG/PNG. The source bytes and
// their recorded SHA-256 remain untouched; WebP is only converted for display.
export async function renderableInspectionImage(source: Buffer): Promise<Buffer> {
const isWebp = source.length >= 12
&& source.subarray(0, 4).toString('ascii') === 'RIFF'
&& source.subarray(8, 12).toString('ascii') === 'WEBP';
if (!isWebp) return source;
return sharp(source)
.resize({ width: 1600, height: 1200, fit: 'inside', withoutEnlargement: true })
.png()
.toBuffer();
}
@@ -1,4 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
interface ZipEntry { interface ZipEntry {
name: string; name: string;
@@ -13,6 +15,10 @@ interface ReportWordInput {
frozenSnapshot: Record<string, unknown>; frozenSnapshot: Record<string, unknown>;
executiveSummary?: string | null; executiveSummary?: string | null;
reportDescription?: string | null; reportDescription?: string | null;
companyName?: string | null;
areaName?: string | null;
scopeName?: string | null;
photos?: Array<{ id: string; findingId?: string; assetId?: string; title?: string; sha256: string; buffer: Buffer }>;
} }
const crcTable = (() => { const crcTable = (() => {
@@ -68,7 +74,7 @@ function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): st
} }
function labelValue(label: string, value: string): string { function labelValue(label: string, value: string): string {
return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`; return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
} }
function table(headers: string[], rows: string[][]): string { function table(headers: string[], rows: string[][]): string {
@@ -102,73 +108,93 @@ function deadline(act: Record<string, unknown>): string {
return '—'; return '—';
} }
function documentXml(input: ReportWordInput): string { function imageDimensions(buffer: Buffer): { width: number; height: number } {
if (buffer.subarray(0, 4).toString('hex') === '89504e47') return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) break;
const marker = buffer[offset + 1]!;
if ([0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf].includes(marker)) return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
const size = buffer.readUInt16BE(offset + 2);
if (size < 2) break;
offset += size + 2;
}
return { width: 800, height: 500 };
}
function drawing(relationship: number, image: Buffer, name: string, maxWidth = 4572000, maxHeight = 2057400, right = false): string {
const dimensions = imageDimensions(image);
const scale = Math.min(maxWidth / dimensions.width, maxHeight / dimensions.height);
const cx = Math.round(dimensions.width * scale);
const cy = Math.round(dimensions.height * scale);
const alt = xmlEscape(name);
return `<w:p>${right ? '<w:pPr><w:jc w:val="right"/></w:pPr>' : ''}<w:r><w:drawing><wp:inline><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${relationship}" name="${alt}" descr="${alt}"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="${alt}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rId${relationship}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>`;
}
function documentXml(input: ReportWordInput, logo: Buffer): string {
const snapshot = f4Snapshot(input); const snapshot = f4Snapshot(input);
const findingRows = snapshot.findings.map((item) => [ const photos = input.photos ?? [];
text(item.code), const authors = snapshot.signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR').map((item) => text(item.signerName)).join(', ');
text(item.title), const legalBasis = [...new Set(snapshot.findings.map((item) => text(item.legalBasis, '')).filter(Boolean))];
text(item.description), const quantity = `${snapshot.findings.length} ${snapshot.findings.length === 1 ? 'hallazgo' : 'hallazgos'}`;
item.isRecurrence ? `${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No', const entries: string[] = [
item.severity == null ? '—' : `${text(item.severity)}/10`, paragraph('MINISTERIO DE ENERGÍA Y AMBIENTE'),
]); paragraph('DIRECCIÓN DE HIDROCARBUROS'),
const inventoryRows = snapshot.inventories.map((item) => [ drawing(2, logo, 'Identidad institucional Mendoza', 594360, 1005840, true),
text(item.code), paragraph(`Mendoza, ${input.generatedAt.toLocaleDateString('es-AR')}`),
text(item.name), paragraph(`INFORME TÉCNICO ${input.code}`, 'Title'),
text(item.typeName ?? item.typeCode), paragraph('Sr. Director de Hidrocarburos'),
]); labelValue('Referencia', text(input.scopeName ?? snapshot.inventories[0]?.name, 'Instalación inspeccionada')),
const signatureRows = snapshot.signatures.map((item) => [ labelValue('Inspector/a', authors || 'No consignado'),
text(item.signerType), labelValue('Acta', text(snapshot.source.actCode ?? snapshot.act.code)),
text(item.signerName),
text(item.status),
text(item.companyManifestation, ''),
isoDate(item.signedAt ?? item.createdAt),
]);
const executive = input.executiveSummary?.trim()
|| '[EDITAR] Incorporar aquí el resumen ejecutivo del informe.';
const description = input.reportDescription?.trim()
|| '[EDITAR] Incorporar aquí la descripción técnica, análisis y consideraciones del inspector.';
const body = [
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'),
labelValue('Informe', input.code),
labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)),
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)), labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)), labelValue('Área o Yacimiento', text(input.scopeName ?? input.areaName)),
labelValue('Urgencia', urgency(snapshot.act.urgency)), labelValue('Empresa inspeccionada', text(input.companyName)),
labelValue('Plazo', deadline(snapshot.act)), paragraph('OBJETIVOS', 'Heading1'),
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')), labelValue('General', 'Documentar los resultados de la inspección consignados en el Acta fuente.'),
paragraph('Resumen ejecutivo', 'Heading1'), labelValue('Particular', 'Analizar los hallazgos y el estado de las instalaciones inspeccionadas para definir las acciones y verificaciones que correspondan.'),
paragraph(executive), paragraph('MARCO LEGAL', 'Heading1'),
paragraph('Descripción / análisis técnico', 'Heading1'), ...(legalBasis.length ? legalBasis.map((basis) => paragraph(basis)) : [paragraph('No se consignó normativa específica en los hallazgos del Acta fuente.')]),
paragraph(description), paragraph('DESCRIPCIÓN Y ANÁLISIS TÉCNICO', 'Heading1'),
paragraph('Acta fuente · contenido inmutable', 'Heading1'), paragraph(input.reportDescription?.trim() || `Según el Acta ${text(snapshot.act.code)}, se documentaron ${quantity} durante la inspección. Se detallan las observaciones y evidencias consignadas a continuación.`),
paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'), paragraph('FOTOS Y HALLAZGOS', 'Heading1'),
labelValue('Resumen del Acta', text(snapshot.act.summary)), ];
labelValue('Observaciones del Acta', text(snapshot.act.observations)), if (!snapshot.findings.length) entries.push(paragraph('El Acta fuente no registra hallazgos.'));
labelValue('Responsable de empresa', text(snapshot.responsible.fullName)), const shownPhotos = new Set<number>();
labelValue('Cargo', text(snapshot.responsible.position)), for (const finding of snapshot.findings) {
paragraph('Inventario inspeccionado', 'Heading1'), entries.push(paragraph(`${text(finding.code)} ${text(finding.title)}`, 'Heading2'));
inventoryRows.length entries.push(labelValue('Instalación', text(snapshot.inventories.find((item) => text(item.id) === text(finding.assetId))?.name)));
? table(['Código', 'Nombre', 'Tipo'], inventoryRows) entries.push(paragraph(text(finding.description)));
: paragraph('No se registraron elementos de Inventario en el Acta.'), if (finding.legalBasis) entries.push(labelValue('Normativa', text(finding.legalBasis)));
paragraph('Hallazgos', 'Heading1'), if (finding.severity != null) entries.push(labelValue('Gravedad', `${text(finding.severity)}/10`));
findingRows.length for (let index = 0; index < photos.length; index++) {
? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows) const photo = photos[index]!;
: paragraph('El Acta no contiene Hallazgos.'), if (photo.findingId !== text(finding.id) && photo.assetId !== text(finding.assetId)) continue;
paragraph('Firmas y manifestaciones', 'Heading1'), shownPhotos.add(index);
signatureRows.length entries.push(drawing(index + 3, photo.buffer, photo.title || text(finding.title)));
? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows) entries.push(paragraph(`Fotografía vinculada · SHA-256 ${photo.sha256}`));
: paragraph('No se registraron firmas en la instantánea sellada.'), }
paragraph('Integridad de la fuente', 'Heading1'), }
labelValue('Hash de la fuente del INF', input.frozenSha256), const otherPhotos = photos.map((photo, index) => ({ photo, index }))
labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)), .filter(({ photo, index }) => photo.assetId && !shownPhotos.has(index));
labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)), if (otherPhotos.length) {
paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'), entries.push(paragraph('OTRAS INSTALACIONES INSPECCIONADAS', 'Heading1'));
].join(''); for (const { photo, index } of otherPhotos) {
entries.push(labelValue('Instalación', text(photo.title)));
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`; entries.push(drawing(index + 3, photo.buffer, photo.title || 'Fotografía de instalación'));
entries.push(paragraph(`Fotografía de inventario · SHA-256 ${photo.sha256}`));
}
}
entries.push(
paragraph('CONCLUSIONES', 'Heading1'),
paragraph(input.executiveSummary?.trim() || `La inspección registró ${quantity}. Su seguimiento y la respuesta de la empresa se documentan en el Informe.`),
paragraph('ACTA FUENTE E INTEGRIDAD', 'Heading1'),
labelValue('Acta sellada', text(snapshot.source.actCode ?? snapshot.act.code)),
labelValue('SHA-256 del cierre del Acta', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
labelValue('SHA-256 de la fuente del Informe', input.frozenSha256),
);
const body = entries.join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
} }
function buildZip(entries: ZipEntry[]): Buffer { function buildZip(entries: ZipEntry[]): Buffer {
@@ -230,10 +256,13 @@ function buildZip(entries: ZipEntry[]): Buffer {
} }
export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } { export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } {
const logo = readFileSync(resolve(process.cwd(), 'assets/logo-mendoza.png'));
const photos = input.photos ?? [];
const mediaRelationships = photos.map((photo, index) => `<Relationship Id="rId${index + 3}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}"/>`).join('');
const entries: ZipEntry[] = [ const entries: ZipEntry[] = [
{ {
name: '[Content_Types].xml', name: '[Content_Types].xml',
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'), data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="jpg" ContentType="image/jpeg"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'),
}, },
{ {
name: '_rels/.rels', name: '_rels/.rels',
@@ -241,13 +270,15 @@ export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buf
}, },
{ {
name: 'word/_rels/document.xml.rels', name: 'word/_rels/document.xml.rels',
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>', 'utf8'), data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/logo-mendoza.png"/>${mediaRelationships}</Relationships>`, 'utf8'),
}, },
{ {
name: 'word/styles.xml', name: 'word/styles.xml',
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr></w:style></w:styles>', 'utf8'), data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="20"/></w:rPr><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr><w:pPr><w:spacing w:before="160" w:after="180"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="26"/></w:rPr><w:pPr><w:keepNext/><w:spacing w:before="260" w:after="130"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr><w:pPr><w:keepNext/><w:spacing w:before="180" w:after="100"/></w:pPr></w:style></w:styles>', 'utf8'),
}, },
{ name: 'word/document.xml', data: Buffer.from(documentXml(input), 'utf8') }, { name: 'word/document.xml', data: Buffer.from(documentXml(input, logo), 'utf8') },
{ name: 'word/media/logo-mendoza.png', data: logo },
...photos.map((photo, index) => ({ name: `word/media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}`, data: photo.buffer })),
{ {
name: 'docProps/core.xml', name: 'docProps/core.xml',
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(input.title)}</dc:title><dc:creator>DH Inspección</dc:creator><cp:lastModifiedBy>DH Inspección</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${input.generatedAt.toISOString()}</dcterms:created></cp:coreProperties>`, 'utf8'), data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(input.title)}</dc:title><dc:creator>DH Inspección</dc:creator><cp:lastModifiedBy>DH Inspección</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${input.generatedAt.toISOString()}</dcterms:created></cp:coreProperties>`, 'utf8'),
@@ -5,11 +5,16 @@ import { Injectable, InternalServerErrorException, NotFoundException } from '@ne
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { buildInspectionReportWord } from './inspection-report-word-builder'; import { buildInspectionReportWord } from './inspection-report-word-builder';
import { InspectionActPdfService } from './inspection-act-pdf.service';
const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
interface WordRow { interface WordRow {
id: string; id: string;
actId: string;
companyName: string | null;
areaName: string | null;
scopeName: string | null;
code: string; code: string;
title: string; title: string;
executiveSummary: string | null; executiveSummary: string | null;
@@ -30,7 +35,7 @@ interface WordRow {
export class InspectionReportWordService { export class InspectionReportWordService {
private readonly root: string; private readonly root: string;
constructor(private readonly dataSource: DataSource, config: ConfigService) { constructor(private readonly dataSource: DataSource, private readonly actPdf: InspectionActPdfService, config: ConfigService) {
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT') const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
?? '/app/storage/asset-media/inspection-reports-word'; ?? '/app/storage/asset-media/inspection-reports-word';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path'); if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
@@ -48,6 +53,7 @@ export class InspectionReportWordService {
} catch {} } catch {}
} }
try { try {
const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId);
const built = buildInspectionReportWord({ const built = buildInspectionReportWord({
code: row.code, code: row.code,
title: row.title, title: row.title,
@@ -56,6 +62,10 @@ export class InspectionReportWordService {
frozenSnapshot: row.frozenSnapshot, frozenSnapshot: row.frozenSnapshot,
executiveSummary: row.executiveSummary, executiveSummary: row.executiveSummary,
reportDescription: row.reportDescription, reportDescription: row.reportDescription,
companyName: row.companyName,
areaName: row.areaName,
scopeName: row.scopeName,
photos,
}); });
await mkdir(this.root, { recursive: true, mode: 0o700 }); await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`; const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;
@@ -114,6 +124,69 @@ export class InspectionReportWordService {
}; };
} }
// The prior editable Word and its revision history stay available unchanged.
async consolidatedContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [existing] = await this.dataSource.query(`
SELECT stored_name AS "storedName",original_name AS "originalName",
size_bytes AS "sizeBytes",sha256
FROM inspection_report_consolidated_word_revisions
WHERE report_id=$1 AND template_version=2
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (existing) return this.verifiedConsolidated(existing);
const row = await this.load(reportId);
const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId);
const built = buildInspectionReportWord({
code: row.code, title: row.title, generatedAt: new Date(row.generatedAt),
frozenSha256: row.frozenSha256, frozenSnapshot: row.frozenSnapshot,
executiveSummary: row.executiveSummary, reportDescription: row.reportDescription,
companyName: row.companyName, areaName: row.areaName, scopeName: row.scopeName, photos,
});
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${row.id}-consolidado-${built.sha256.slice(0, 24)}.docx`;
const originalName = `${row.code}-consolidado.docx`;
await writeFile(resolve(this.root, storedName), built.buffer, { flag: 'wx', mode: 0o600 }).catch(async (error: NodeJS.ErrnoException) => {
if (error.code !== 'EEXIST') throw error;
const previous = await this.verifiedConsolidated({
storedName, originalName, sizeBytes: built.buffer.length, sha256: built.sha256,
});
if (!(await readFile(previous.filePath)).equals(built.buffer)) throw this.storageError();
});
await this.dataSource.query(`
INSERT INTO inspection_report_consolidated_word_revisions(report_id,template_version,stored_name,original_name,size_bytes,sha256)
VALUES($1,2,$2,$3,$4,$5) ON CONFLICT (report_id,template_version) DO NOTHING
`, [reportId, storedName, originalName, built.buffer.length, built.sha256]);
const [saved] = await this.dataSource.query(`
SELECT stored_name AS "storedName",original_name AS "originalName",
size_bytes AS "sizeBytes",sha256
FROM inspection_report_consolidated_word_revisions WHERE report_id=$1 AND template_version=2
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (!saved) throw this.storageError();
return this.verifiedConsolidated(saved);
}
async consolidatedRevisionContent(reportId: string, version: number): Promise<{ filePath: string; originalName: string; mimeType: string }> {
if (![1, 2].includes(version)) throw new NotFoundException('Versión documental inexistente');
const [row] = await this.dataSource.query(`
SELECT stored_name AS "storedName",original_name AS "originalName",
size_bytes AS "sizeBytes",sha256
FROM inspection_report_consolidated_word_revisions
WHERE report_id=$1 AND template_version=$2
`, [reportId, version]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
if (!row) throw new NotFoundException('Versión documental inexistente');
return this.verifiedConsolidated(row);
}
private async verifiedConsolidated(row: { storedName: string; originalName: string; sizeBytes: number; sha256: string }): Promise<{ filePath: string; originalName: string; mimeType: string }> {
if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw this.storageError();
const filePath = resolve(this.root, row.storedName);
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
const file = await stat(filePath).catch(() => null);
if (!file?.isFile() || file.size !== Number(row.sizeBytes)) throw this.storageError();
const buffer = await readFile(filePath);
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError();
return { filePath, originalName: row.originalName, mimeType: WORD_MIME };
}
private async ensureInitialRevision(row: WordRow): Promise<void> { private async ensureInitialRevision(row: WordRow): Promise<void> {
if ( if (
row.wordStatus !== 'READY' row.wordStatus !== 'READY'
@@ -150,21 +223,26 @@ export class InspectionReportWordService {
private async load(reportId: string): Promise<WordRow> { private async load(reportId: string): Promise<WordRow> {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT id,code,title, SELECT report.id,report.act_id AS "actId",report.code,report.title,
executive_summary AS "executiveSummary", company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName",
report_description AS "reportDescription", report.executive_summary AS "executiveSummary",
generated_at AS "generatedAt", report.report_description AS "reportDescription",
frozen_sha256 AS "frozenSha256", report.generated_at AS "generatedAt",
frozen_snapshot AS "frozenSnapshot", report.frozen_sha256 AS "frozenSha256",
word_status AS "wordStatus", report.frozen_snapshot AS "frozenSnapshot",
word_original_name AS "wordOriginalName", report.word_status AS "wordStatus",
word_stored_name AS "wordStoredName", report.word_original_name AS "wordOriginalName",
word_mime_type AS "wordMimeType", report.word_stored_name AS "wordStoredName",
report.word_mime_type AS "wordMimeType",
word_size_bytes::integer AS "wordSizeBytes", word_size_bytes::integer AS "wordSizeBytes",
word_sha256 AS "wordSha256", report.word_sha256 AS "wordSha256",
generated_by AS "generatedBy" report.generated_by AS "generatedBy"
FROM inspection_reports FROM inspection_reports report
WHERE id=$1 JOIN inspection_visits visit ON visit.id=report.visit_id
LEFT JOIN assets company ON company.id=visit.operator_company_id
LEFT JOIN assets area ON area.id=visit.operational_area_id
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
WHERE report.id=$1
`, [reportId]) as WordRow[]; `, [reportId]) as WordRow[];
if (!row) { if (!row) {
throw new NotFoundException({ throw new NotFoundException({
@@ -11,6 +11,8 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit'; import { administrationAuditContext } from '../administration/common/administration-audit';
import { ActAdministrationService, type UploadedActResponseFile } from '../act-administration/act-administration.service';
import type { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { import {
@@ -20,6 +22,7 @@ import {
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto'; import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import type { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024; export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
@@ -56,6 +59,7 @@ export class InspectionReportWorkflowService {
constructor( constructor(
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly administration: ActAdministrationService,
config: ConfigService, config: ConfigService,
) { ) {
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT') const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
@@ -220,6 +224,42 @@ export class InspectionReportWorkflowService {
} }
} }
async setResponseDeadline(
reportId: string,
dto: SetInspectionReportResponseDeadlineDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const report = await this.requireOfficializedReport(reportId);
const reason = dto.reason ?? `Vencimiento general de respuestas definido desde el Informe ${report.code}`;
await this.administration.setDeadline(
report.actId,
{ responseDueOn: dto.responseDueOn, reason },
principal,
request,
report.id,
);
return this.getWorkflowView(this.dataSource.manager, reportId);
}
async addCompanyResponse(
reportId: string,
dto: CreateActCompanyResponseDto,
file: UploadedActResponseFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const report = await this.requireOfficializedReport(reportId);
await this.administration.addResponse(report.actId, dto, file, principal, request, report.id);
return this.getWorkflowView(this.dataSource.manager, reportId);
}
async companyResponseContent(reportId: string, responseId: string) {
const report = await this.getReport(this.dataSource.manager, reportId);
if (!report) throw reportNotFound();
return this.administration.responseContent(responseId, reportId);
}
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT SELECT
@@ -258,6 +298,23 @@ export class InspectionReportWorkflowService {
}; };
} }
async followUpContent(reportId: string, followUpId: string): Promise<{ filePath: string; originalName: string; mimeType: string; sizeBytes: number }> {
const [item] = await this.dataSource.query(`
SELECT original_name AS "originalName",stored_name AS "storedName",
mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256
FROM inspection_report_follow_ups WHERE report_id=$1 AND id=$2 AND stored_name IS NOT NULL
`, [reportId, followUpId]) as Array<{ originalName: string; storedName: string; mimeType: string; sizeBytes: number; sha256: string }>;
if (!item) throw new NotFoundException({ code: 'REPORT_FOLLOW_UP_FILE_NOT_FOUND', message: 'Adjunto de seguimiento inexistente' });
if (!/^followup-[a-f0-9-]+\.[a-z0-9]+$/.test(item.storedName)) throw this.reportStorageError();
const filePath = resolve(this.root, item.storedName);
if (!filePath.startsWith(`${this.root}/`)) throw this.reportStorageError();
const file = await stat(filePath).catch(() => null);
if (!file?.isFile() || file.size !== item.sizeBytes) throw this.reportStorageError();
const buffer = await readFile(filePath);
if (createHash('sha256').update(buffer).digest('hex') !== item.sha256) throw this.reportStorageError();
return { filePath, originalName: item.originalName, mimeType: item.mimeType, sizeBytes: item.sizeBytes };
}
async addFollowUp( async addFollowUp(
reportId: string, reportId: string,
dto: CreateInspectionReportFollowUpDto, dto: CreateInspectionReportFollowUpDto,
@@ -283,6 +340,15 @@ export class InspectionReportWorkflowService {
try { try {
return await this.dataSource.transaction(async (manager) => { return await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId); const report = await this.lockReport(manager, reportId);
if (
(dto.type === 'COMPANY_NOTE' || dto.type === 'COMPANY_DOCUMENT')
&& report.status !== InspectionReportStatus.OFFICIALIZED
) {
throw new ConflictException({
code: 'INSPECTION_REPORT_GEDO_REQUIRED_FOR_COMPANY_RESPONSE',
message: 'Las respuestas de la empresa se registran después de cargar el PDF oficial de GEDO',
});
}
const occurredAt = new Date(dto.occurredAt); const occurredAt = new Date(dto.occurredAt);
await manager.query(` await manager.query(`
INSERT INTO inspection_report_follow_ups ( INSERT INTO inspection_report_follow_ups (
@@ -390,6 +456,18 @@ export class InspectionReportWorkflowService {
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : ''; return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
} }
private async requireOfficializedReport(reportId: string): Promise<ReportRow> {
const report = await this.getReport(this.dataSource.manager, reportId);
if (!report) throw reportNotFound();
if (report.status !== InspectionReportStatus.OFFICIALIZED) {
throw new ConflictException({
code: 'INSPECTION_REPORT_GEDO_REQUIRED',
message: 'Primero debe cargarse manualmente el PDF oficial de GEDO',
});
}
return report;
}
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> { private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
const [row] = await manager.query(` const [row] = await manager.query(`
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
@@ -4,6 +4,7 @@ import {
Get, Get,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
ParseIntPipe,
Patch, Patch,
Post, Post,
Query, Query,
@@ -14,6 +15,8 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
import { MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from '../act-administration/act-administration.service';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { Response } from 'express'; import type { Response } from 'express';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
@@ -21,6 +24,7 @@ import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-repor
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto'; import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
import { import {
InspectionReportWorkflowService, InspectionReportWorkflowService,
MAX_INSPECTION_REPORT_FILE_BYTES, MAX_INSPECTION_REPORT_FILE_BYTES,
@@ -28,6 +32,7 @@ import {
} from './inspection-report-workflow.service'; } from './inspection-report-workflow.service';
import { InspectionReportsService } from './inspection-reports.service'; import { InspectionReportsService } from './inspection-reports.service';
import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionActPdfService } from './inspection-act-pdf.service';
@Controller('inspection-reports') @Controller('inspection-reports')
export class InspectionReportsController { export class InspectionReportsController {
@@ -68,6 +73,34 @@ export class InspectionReportsController {
return response.sendFile(content.filePath); return response.sendFile(content.filePath);
} }
@Get(':id/consolidated-word')
@RequirePermissions('inspection_reports.read')
async consolidatedWordContent(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Res() response: Response,
) {
const content = await this.word.consolidatedContent(id);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
return response.sendFile(content.filePath);
}
@Get(':id/consolidated-word/revisions/:version')
@RequirePermissions('inspection_reports.read')
async consolidatedWordRevision(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Param('version', ParseIntPipe) version: number,
@Res() response: Response,
) {
const content = await this.word.consolidatedRevisionContent(id, version);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
return response.sendFile(content.filePath);
}
@Get(':id/gedo-pdf') @Get(':id/gedo-pdf')
@RequirePermissions('inspection_reports.read') @RequirePermissions('inspection_reports.read')
async gedoPdfContent( async gedoPdfContent(
@@ -106,12 +139,72 @@ export class InspectionReportsController {
return this.workflow.officialize(id, dto, file, principal, request); return this.workflow.officialize(id, dto, file, principal, request);
} }
@Patch(':id/response-deadline')
@RequirePermissions('inspection_reports.generate')
setResponseDeadline(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: SetInspectionReportResponseDeadlineDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.setResponseDeadline(id, dto, principal, request);
}
@Post(':id/company-responses')
@RequirePermissions('inspection_reports.generate')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 },
}))
addCompanyResponse(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CreateActCompanyResponseDto,
@UploadedFile() file: UploadedActResponseFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.addCompanyResponse(id, dto, file, principal, request);
}
@Get(':id/company-responses/:responseId/content')
@RequirePermissions('inspection_reports.read')
async companyResponseContent(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Param('responseId', new ParseUUIDPipe({ version: '4' })) responseId: string,
@Res() response: Response,
): Promise<void> {
const item = await this.workflow.companyResponseContent(id, responseId);
const safeName = item.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
response.setHeader('Content-Type', 'application/pdf');
response.setHeader('Content-Length', String(item.sizeBytes));
response.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(item.originalName)}`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(item.filePath, (error) => error ? rejectSend(error) : resolveSend()));
}
@Get(':id/follow-ups') @Get(':id/follow-ups')
@RequirePermissions('inspection_reports.read') @RequirePermissions('inspection_reports.read')
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.workflow.listFollowUps(id); return this.workflow.listFollowUps(id);
} }
@Get(':id/follow-ups/:followUpId/content')
@RequirePermissions('inspection_reports.read')
async followUpContent(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Param('followUpId', new ParseUUIDPipe({ version: '4' })) followUpId: string,
@Res() response: Response,
): Promise<void> {
const file = await this.workflow.followUpContent(id, followUpId);
const safeName = file.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
response.setHeader('Content-Type', file.mimeType);
response.setHeader('Content-Length', String(file.sizeBytes));
response.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(file.originalName)}`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(file.filePath, (error) => error ? rejectSend(error) : resolveSend()));
}
@Post(':id/follow-ups') @Post(':id/follow-ups')
@RequirePermissions('inspection_reports.generate') @RequirePermissions('inspection_reports.generate')
@UseInterceptors(FileInterceptor('file', { @UseInterceptors(FileInterceptor('file', {
@@ -148,3 +241,60 @@ export class InspectionActReportController {
return this.reports.generate(actId, principal, request); return this.reports.generate(actId, principal, request);
} }
} }
@Controller('inspection-acts/:actId/pdf')
export class InspectionActPdfController {
constructor(private readonly pdf: InspectionActPdfService) {}
@Get()
@RequirePermissions('inspection_acts.read')
async content(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Res() response: Response,
) {
await this.pdf.ensure(actId);
const content = await this.pdf.content(actId);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Length', String(content.buffer.length));
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
return response.send(content.buffer);
}
}
@Controller('inspection-acts/:actId/consolidated-pdf')
export class InspectionActConsolidatedPdfController {
constructor(private readonly pdf: InspectionActPdfService) {}
@Get('revisions/:version')
@RequirePermissions('inspection_acts.read')
async revision(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Param('version', ParseIntPipe) version: number,
@Res() response: Response,
) {
const content = await this.pdf.consolidatedRevisionContent(actId, version);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Length', String(content.buffer.length));
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
return response.send(content.buffer);
}
@Get()
@RequirePermissions('inspection_acts.read')
async content(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Res() response: Response,
) {
const content = await this.pdf.consolidatedContent(actId);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Length', String(content.buffer.length));
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
return response.send(content.buffer);
}
}
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { ActAdministrationModule } from '../act-administration/act-administration.module';
import { DocumentDeliveryController } from './document-delivery.controller'; import { DocumentDeliveryController } from './document-delivery.controller';
import { InspectionActPdfService } from './inspection-act-pdf.service'; import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller'; import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
@@ -7,15 +8,17 @@ import { InspectionDeadlineAdminService } from './inspection-deadline-admin.serv
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
import { InspectionReportWorkflowService } from './inspection-report-workflow.service'; import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller'; import { InspectionActConsolidatedPdfController, InspectionActPdfController, InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
import { InspectionReportsService } from './inspection-reports.service'; import { InspectionReportsService } from './inspection-reports.service';
import { SmtpDeliveryService } from './smtp-delivery.service'; import { SmtpDeliveryService } from './smtp-delivery.service';
@Module({ @Module({
imports: [AuditModule], imports: [AuditModule, ActAdministrationModule],
controllers: [ controllers: [
InspectionReportsController, InspectionReportsController,
InspectionActReportController, InspectionActReportController,
InspectionActPdfController,
InspectionActConsolidatedPdfController,
InspectionDeadlineAdminController, InspectionDeadlineAdminController,
DocumentDeliveryController, DocumentDeliveryController,
], ],
@@ -64,6 +64,16 @@ export interface InspectionReportListItem {
export interface InspectionReportView extends InspectionReportListItem { export interface InspectionReportView extends InspectionReportListItem {
frozenSnapshot: Record<string, unknown>; frozenSnapshot: Record<string, unknown>;
responseDueOn: string | null;
deadlineReason: string | null;
deadlines: Array<{ id: string; reportId: string | null; responseDueOn: string; reason: string; createdAt: Date }>;
companyResponses: Array<{
id: string; reportId: string | null; receivedOn: string; details: string | null; committedCorrectionOn: string | null;
contactName: string | null; contactEmail: string | null; originalName: string | null; sizeBytes: number | null; sha256: string | null; createdAt: Date;
}>;
findings: Array<{
id: string; code: string; title: string; status: string; assetCode: string; assetName: string; responseDueOn: string | null;
}>;
} }
export interface PendingInspectionReportItem { export interface PendingInspectionReportItem {
@@ -491,7 +501,38 @@ export class InspectionReportsService {
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1', 'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
[id], [id],
)) as Array<{ frozenSnapshot: Record<string, unknown> }>; )) as Array<{ frozenSnapshot: Record<string, unknown> }>;
return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; const deadlines = await manager.query(`
SELECT id,report_id AS "reportId",response_due_on AS "responseDueOn",reason,created_at AS "createdAt"
FROM inspection_act_deadline_events
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
ORDER BY created_at DESC,id DESC
`, [report.actId, report.id]) as InspectionReportView['deadlines'];
const currentDeadline = deadlines[0] ?? null;
const companyResponses = await manager.query(`
SELECT id,report_id AS "reportId",received_on AS "receivedOn",details,
committed_correction_on AS "committedCorrectionOn",contact_name AS "contactName",contact_email AS "contactEmail",
original_name AS "originalName",size_bytes::integer AS "sizeBytes",sha256,created_at AS "createdAt"
FROM inspection_act_company_responses
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
ORDER BY received_on DESC,created_at DESC,id DESC
`, [report.actId, report.id]) as InspectionReportView['companyResponses'];
const findings = await manager.query(`
SELECT finding.id,finding.code,finding.title,finding.status,asset.code AS "assetCode",asset.name AS "assetName",
$2::date AS "responseDueOn"
FROM inspection_findings finding
JOIN assets asset ON asset.id=finding.asset_id
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
ORDER BY finding.finding_number,finding.id
`, [report.actId, currentDeadline?.responseDueOn ?? null]) as InspectionReportView['findings'];
return {
...report,
frozenSnapshot: snapshot.frozenSnapshot,
responseDueOn: currentDeadline?.responseDueOn ?? null,
deadlineReason: currentDeadline?.reason ?? null,
deadlines,
companyResponses,
findings,
};
} }
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> { private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
@@ -1,7 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
import { connect as connectNet, Socket } from 'node:net'; import { connect as connectNet, Socket } from 'node:net';
import { connect as connectTls, TLSSocket } from 'node:tls'; import { connect as connectTls, TLSSocket } from 'node:tls';
import { Injectable } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { SmtpSecurityMode } from '../database/entities'; import { SmtpSecurityMode } from '../database/entities';
@@ -11,7 +11,7 @@ interface MailInput { to:string; subject:string; text:string; attachment:MailAtt
interface Reply { code:number; text:string; } interface Reply { code:number; text:string; }
export interface EffectiveSmtpSettings { export interface EffectiveSmtpSettings {
source: 'DATABASE' | 'ENVIRONMENT'; source: 'USER' | 'DATABASE' | 'ENVIRONMENT';
host: string; host: string;
port: number; port: number;
securityMode: SmtpSecurityMode; securityMode: SmtpSecurityMode;
@@ -47,10 +47,10 @@ export class SmtpDeliveryService {
private readonly config:ConfigService, private readonly config:ConfigService,
){} ){}
async configured():Promise<boolean>{return Boolean(await this.resolveSettings());} async configured(userId?:string):Promise<boolean>{return Boolean(await this.resolveSettings(userId));}
async fromAddress():Promise<string|null>{ async fromAddress(userId?:string):Promise<string|null>{
const settings=await this.resolveSettings(); const settings=await this.resolveSettings(userId);
if(!settings)return null; if(!settings)return null;
return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail; return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail;
} }
@@ -73,6 +73,56 @@ export class SmtpDeliveryService {
}:{source:'NONE',enabled:false}; }:{source:'NONE',enabled:false};
} }
async publicUserSettings(userId:string){
const [user]=await this.dataSource.query(`
SELECT email,first_name AS "firstName",last_name AS "lastName" FROM users WHERE id=$1
`,[userId]) as Array<{email:string;firstName:string;lastName:string}>;
if(!user)throw new Error('Usuario no encontrado');
const [row]=await this.dataSource.query(`
SELECT mode,host,port,security_mode AS "securityMode",username,
(password_enc IS NOT NULL) AS "hasPassword",from_name AS "fromName",
from_email AS "fromEmail",enabled,updated_at AS "updatedAt"
FROM user_smtp_settings WHERE user_id=$1
`,[userId]) as Array<Record<string,unknown>>;
return {
mode:row?.mode??'SYSTEM',email:user.email,generalConfigured:Boolean(await this.resolveSystemSettings()),
custom:row?{
host:row.host??'',port:row.port??587,securityMode:row.securityMode??'STARTTLS',
username:row.username??'',hasPassword:Boolean(row.hasPassword),
fromName:row.fromName??`${user.firstName} ${user.lastName}`,fromEmail:row.fromEmail??user.email,
enabled:row.enabled!==false,updatedAt:row.updatedAt??null,
}:null,
};
}
async saveUserSettings(userId:string,input:{
mode:'SYSTEM'|'CUSTOM';host?:string;port?:number;securityMode?:SmtpSecurityMode;
username?:string|null;password?:string|null;fromName?:string|null;enabled?:boolean;
}){
const [user]=await this.dataSource.query(`SELECT email,first_name AS "firstName",last_name AS "lastName" FROM users WHERE id=$1`,[userId]) as Array<{email:string;firstName:string;lastName:string}>;
if(!user?.email)throw new Error('El usuario debe tener un email configurado');
const [existing]=await this.dataSource.query(`SELECT password_enc AS "passwordEnc" FROM user_smtp_settings WHERE user_id=$1`,[userId]) as Array<{passwordEnc:string|null}>;
if(input.mode==='SYSTEM'){
await this.dataSource.query(`
INSERT INTO user_smtp_settings(user_id,mode,updated_by) VALUES($1,'SYSTEM',$1)
ON CONFLICT(user_id) DO UPDATE SET mode='SYSTEM',updated_by=$1,updated_at=CURRENT_TIMESTAMP
`,[userId]);
return this.publicUserSettings(userId);
}
if(!input.host||!input.port||!input.securityMode)throw new Error('La configuración SMTP propia está incompleta');
const passwordEnc=input.password===undefined?existing?.passwordEnc??null:input.password?this.encryptSecret(input.password):null;
const fromName=input.fromName?.trim()||`${user.firstName} ${user.lastName}`.trim();
await this.dataSource.query(`
INSERT INTO user_smtp_settings(user_id,mode,host,port,security_mode,username,password_enc,from_name,from_email,reply_to,enabled,updated_by)
VALUES($1,'CUSTOM',$2,$3,$4,$5,$6,$7,$8,$8,$9,$1)
ON CONFLICT(user_id) DO UPDATE SET mode='CUSTOM',host=EXCLUDED.host,port=EXCLUDED.port,
security_mode=EXCLUDED.security_mode,username=EXCLUDED.username,password_enc=EXCLUDED.password_enc,
from_name=EXCLUDED.from_name,from_email=EXCLUDED.from_email,reply_to=EXCLUDED.reply_to,
enabled=EXCLUDED.enabled,updated_by=$1,updated_at=CURRENT_TIMESTAMP
`,[userId,input.host,input.port,input.securityMode,input.username?.trim()||null,passwordEnc,fromName,user.email,input.enabled!==false]);
return this.publicUserSettings(userId);
}
async saveSettings(input:{ async saveSettings(input:{
host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null; host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null;
password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean; password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean;
@@ -96,8 +146,8 @@ export class SmtpDeliveryService {
return this.publicSettings(); return this.publicSettings();
} }
async send(input:MailInput):Promise<{messageId:string}>{ async send(input:MailInput,userId?:string):Promise<{messageId:string}>{
const settings=await this.resolveSettings(); const settings=await this.resolveSettings(userId);
if(!settings)throw new Error('SMTP no configurado'); if(!settings)throw new Error('SMTP no configurado');
const {host,port,securityMode,userName,password}= { const {host,port,securityMode,userName,password}= {
host:settings.host,port:settings.port,securityMode:settings.securityMode, host:settings.host,port:settings.port,securityMode:settings.securityMode,
@@ -137,7 +187,33 @@ export class SmtpDeliveryService {
return {messageId:match?.[1]??randomUUID()}; return {messageId:match?.[1]??randomUUID()};
} }
private async resolveSettings():Promise<EffectiveSmtpSettings|null>{ private async resolveSettings(userId?:string):Promise<EffectiveSmtpSettings|null>{
if(userId){
const [row]=await this.dataSource.query(`
SELECT settings.mode,settings.host,settings.port,settings.security_mode AS "securityMode",
settings.username,settings.password_enc AS "passwordEnc",settings.from_name AS "fromName",
settings.enabled,user_account.email AS "userEmail"
FROM users user_account
LEFT JOIN user_smtp_settings settings ON settings.user_id=user_account.id
WHERE user_account.id=$1
`,[userId]) as Array<{
mode:'SYSTEM'|'CUSTOM'|null;host:string|null;port:number|null;securityMode:SmtpSecurityMode|null;
username:string|null;passwordEnc:string|null;fromName:string|null;enabled:boolean|null;userEmail:string;
}>;
if(row?.mode==='CUSTOM'&&row.enabled!==false&&row.host&&row.port&&row.securityMode&&row.userEmail){
return {
source:'USER',host:row.host,port:Number(row.port),securityMode:row.securityMode,
username:row.username,password:row.passwordEnc?this.decryptSecret(row.passwordEnc):'',
fromName:row.fromName,fromEmail:row.userEmail,replyTo:row.userEmail,
};
}
const general=await this.resolveSystemSettings();
return general&&row?.userEmail?{...general,replyTo:row.userEmail}:general;
}
return this.resolveSystemSettings();
}
private async resolveSystemSettings():Promise<EffectiveSmtpSettings|null>{
const [row]=await this.dataSource.query(` const [row]=await this.dataSource.query(`
SELECT host,port,security_mode AS "securityMode",username,password_enc AS "passwordEnc", SELECT host,port,security_mode AS "securityMode",username,password_enc AS "passwordEnc",
from_name AS "fromName",from_email AS "fromEmail",reply_to AS "replyTo",enabled from_name AS "fromName",from_email AS "fromEmail",reply_to AS "replyTo",enabled
@@ -174,9 +250,9 @@ export class SmtpDeliveryService {
private encryptionKey():Buffer{ private encryptionKey():Buffer{
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY'); const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada'); if(!raw)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED', message:'La clave maestra para proteger credenciales SMTP no está configurada en el servidor' });
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64'); const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes'); if(key.length!==32)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_INVALID', message:'La clave maestra SMTP del servidor tiene un formato inválido' });
return key; return key;
} }
@@ -4,7 +4,6 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID,
Matches,
Max, Max,
MaxLength, MaxLength,
Min, Min,
@@ -24,6 +23,10 @@ export class CreateFieldFindingDto {
@IsUUID('4') @IsUUID('4')
actId!: string; actId!: string;
@IsOptional()
@IsUUID('4')
clientGeneratedId?: string;
@IsOptional() @IsOptional()
@Transform(optionalText) @Transform(optionalText)
@IsUUID('4') @IsUUID('4')
@@ -56,8 +59,4 @@ export class CreateFieldFindingDto {
@Max(10) @Max(10)
severity?: number; severity?: number;
@IsOptional()
@Transform(optionalText)
@Matches(/^\d{4}-\d{2}-\d{2}$/)
correctionDueOn?: string | null;
} }
@@ -14,6 +14,10 @@ import {
} from 'class-validator'; } from 'class-validator';
export class CreateFieldInventoryDto { export class CreateFieldInventoryDto {
@IsOptional()
@IsUUID('4')
clientGeneratedId?: string;
@IsUUID('4') @IsUUID('4')
typeId!: string; typeId!: string;
@@ -1,9 +1,15 @@
import { IsUUID } from 'class-validator'; import { IsUUID } from 'class-validator';
export class OpenMobileInspectionDto { export class OpenMobileInspectionDto {
@IsUUID('4')
departmentId!: string;
@IsUUID('4') @IsUUID('4')
operationalAreaId!: string; operationalAreaId!: string;
@IsUUID('4')
scopeAssetId!: string;
@IsUUID('4') @IsUUID('4')
operatorCompanyId!: string; operatorCompanyId!: string;
} }
@@ -4,12 +4,17 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
Max, Max,
MaxLength, MaxLength,
Min, Min,
} from 'class-validator'; } from 'class-validator';
export class UploadFieldInventoryPhotoDto { export class UploadFieldInventoryPhotoDto {
@IsOptional()
@IsUUID('4')
operationId?: string;
@IsOptional() @IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString() @IsString()
@@ -101,13 +101,13 @@ export class F3FieldInventoryStructureService {
if (familyRequired && !dto.familyId) { if (familyRequired && !dto.familyId) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_REQUIRED', code: 'FIELD_INVENTORY_FAMILY_REQUIRED',
message: 'Elegí la familia técnica o la opción Otro / no catalogado', message: 'Elegí el Tipo de instalación/subinstalación o la opción Otro / no catalogado',
}); });
} }
if (!familyRequired && dto.familyId) { if (!familyRequired && dto.familyId) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_NOT_ALLOWED', code: 'FIELD_INVENTORY_FAMILY_NOT_ALLOWED',
message: 'Este nivel estructural no utiliza familia técnica', message: 'Este nivel estructural no utiliza Tipo de instalación/subinstalación',
}); });
} }
const family = dto.familyId const family = dto.familyId
@@ -116,7 +116,7 @@ export class F3FieldInventoryStructureService {
if (dto.familyId && !family) { if (dto.familyId && !family) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_INVALID', code: 'FIELD_INVENTORY_FAMILY_INVALID',
message: 'La familia técnica no es válida para el padre seleccionado', message: 'El Tipo de instalación/subinstalación no es válido para el padre seleccionado',
}); });
} }
@@ -214,7 +214,7 @@ export class F3FieldInventoryStructureService {
if (!parent.inventoryFamilyId) { if (!parent.inventoryFamilyId) {
throw new ConflictException({ throw new ConflictException({
code: 'FIELD_INVENTORY_PARENT_FAMILY_REQUIRED', code: 'FIELD_INVENTORY_PARENT_FAMILY_REQUIRED',
message: 'La Instalación debe tener una familia técnica antes de agregar Subinstalaciones', message: 'La Instalación debe tener un Tipo de instalación antes de agregar Subinstalaciones',
}); });
} }
return this.dataSource.query(` return this.dataSource.query(`
@@ -99,13 +99,14 @@ export class FieldFindingsService {
}); });
} }
const payload: CreateInspectionFindingDto = { const payload: CreateInspectionFindingDto = {
clientGeneratedId: dto.clientGeneratedId,
assetId, assetId,
catalogItemId: dto.catalogItemId ?? null, catalogItemId: dto.catalogItemId ?? null,
customTitle: dto.customTitle ?? null, customTitle: dto.customTitle ?? null,
customLegalBasis: dto.customLegalBasis ?? null, customLegalBasis: dto.customLegalBasis ?? null,
description: dto.description, description: dto.description,
severity: dto.severity, severity: dto.severity,
correctionDueOn: dto.correctionDueOn ?? null, correctionDueOn: null,
}; };
const finding = await this.findings.create(act.id, payload, principal, request); const finding = await this.findings.create(act.id, payload, principal, request);
return { return {
@@ -278,7 +279,7 @@ export class FieldFindingsService {
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) { if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
throw new ConflictException({ throw new ConflictException({
code: 'FIELD_FINDING_FAMILY_REQUIRED', code: 'FIELD_FINDING_FAMILY_REQUIRED',
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos', message: 'La Instalación/Subinstalación debe tener su tipo definido antes de registrar Hallazgos',
}); });
} }
if (!row.insideScope) { if (!row.insideScope) {
@@ -273,6 +273,23 @@ export class FieldInventoryService {
request: RequestWithContext, request: RequestWithContext,
) { ) {
const context = await this.requireVisitContext(visitId, principal, true); const context = await this.requireVisitContext(visitId, principal, true);
if (dto.clientGeneratedId) {
const [existing] = await this.dataSource.query(`
SELECT discovery.asset_id AS "assetId", discovery.visit_id AS "visitId"
FROM asset_field_discoveries discovery
WHERE discovery.asset_id=$1::uuid
LIMIT 1
`, [dto.clientGeneratedId]) as Array<{ assetId: string; visitId: string }>;
if (existing) {
if (existing.visitId !== visitId) {
throw new ConflictException({
code: 'FIELD_INVENTORY_CLIENT_ID_REUSED',
message: 'El identificador offline del Inventario ya fue utilizado en otra inspección',
});
}
return this.detail(visitId, existing.assetId, principal);
}
}
const parentId = dto.parentId ?? context.scopeAssetId; const parentId = dto.parentId ?? context.scopeAssetId;
await this.requireParentInContext(parentId, context); await this.requireParentInContext(parentId, context);
@@ -286,6 +303,7 @@ export class FieldInventoryService {
const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(capturedAt); const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(capturedAt);
const discoveryDto: CreateFieldDiscoveryDto = { const discoveryDto: CreateFieldDiscoveryDto = {
clientGeneratedId: dto.clientGeneratedId,
visitId, visitId,
code, code,
name: dto.name, name: dto.name,
@@ -295,6 +313,7 @@ export class FieldInventoryService {
operationalAreaId: context.areaId, operationalAreaId: context.areaId,
// Compatibility-only creation snapshot. Membership never depends on it. // Compatibility-only creation snapshot. Membership never depends on it.
operatorCompanyId: context.companyId, operatorCompanyId: context.companyId,
inventoryFamilyId: dto.familyId ?? null,
description: dto.description ?? null, description: dto.description ?? null,
discoveryNotes: dto.discoveryNotes ?? null, discoveryNotes: dto.discoveryNotes ?? null,
attributes: dto.attributes, attributes: dto.attributes,
@@ -350,6 +369,28 @@ export class FieldInventoryService {
): Promise<{ media: AssetMediaView; capture: FieldInventoryCaptureStatus }> { ): Promise<{ media: AssetMediaView; capture: FieldInventoryCaptureStatus }> {
const context = await this.requireVisitContext(visitId, principal, true); const context = await this.requireVisitContext(visitId, principal, true);
await this.requireAssetInContext(assetId, context); await this.requireAssetInContext(assetId, context);
if (dto.operationId) {
const [existing] = await this.dataSource.query(`
SELECT media_id AS "mediaId", asset_id AS "assetId"
FROM asset_field_capture_events
WHERE client_operation_id=$1::uuid
LIMIT 1
`, [dto.operationId]) as Array<{ mediaId: string | null; assetId: string }>;
if (existing) {
if (existing.assetId !== assetId || !existing.mediaId) {
throw new ConflictException({
code: 'FIELD_PHOTO_OPERATION_REUSED',
message: 'La operación offline de fotografía ya fue utilizada en otro Inventario',
});
}
const media = (await this.media.list(assetId)).data.find((item) => item.id === existing.mediaId);
if (!media) throw new ConflictException({
code: 'FIELD_PHOTO_OPERATION_MEDIA_MISSING',
message: 'La fotografía sincronizada no está disponible',
});
return { media, capture: await this.captureStatus(visitId, assetId) };
}
}
if (!await this.isSelected(visitId, assetId)) { if (!await this.isSelected(visitId, assetId)) {
throw new ConflictException({ throw new ConflictException({
code: 'FIELD_INVENTORY_NOT_SELECTED', code: 'FIELD_INVENTORY_NOT_SELECTED',
@@ -382,8 +423,8 @@ export class FieldInventoryService {
device_latitude, device_longitude, device_accuracy_m, device_latitude, device_longitude, device_accuracy_m,
device_captured_at, device_label, device_captured_at, device_label,
exif_latitude, exif_longitude, exif_captured_at, exif_latitude, exif_longitude, exif_captured_at,
created_by created_by, client_operation_id
) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12) ) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
`, [ `, [
visitId, visitId,
assetId, assetId,
@@ -397,6 +438,7 @@ export class FieldInventoryService {
dto.exifLongitude ?? null, dto.exifLongitude ?? null,
dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null, dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null,
principal.userId, principal.userId,
dto.operationId ?? null,
]); ]);
return { media: uploaded, capture: await this.captureStatus(visitId, assetId) }; return { media: uploaded, capture: await this.captureStatus(visitId, assetId) };
@@ -62,6 +62,67 @@ export class InspectionPlanningHierarchyService {
return { data }; return { data };
} }
async operatorsForYacimiento(yacimientoId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(yacimientoId, 'yacimiento', 'El Yacimiento seleccionado no es válido');
const data = await this.dataSource.query(`
SELECT company.id, company.code, COALESCE(profile.legal_name,company.name) AS name
FROM assets yacimiento
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE yacimiento.id=$1::uuid
AND yacimiento.information_status<>'INACTIVE'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
AND company.information_status<>'INACTIVE'
ORDER BY name, company.code
`, [yacimientoId]) as InspectionPlanningHierarchyItem[];
return { data };
}
async validateMobileSelection(
departmentId: string,
areaId: string,
yacimientoId: string,
companyId: string,
): Promise<void> {
const [row] = await this.dataSource.query(`
SELECT 1
FROM assets department
INNER JOIN asset_types department_type ON department_type.id=department.asset_type_id
INNER JOIN assets area ON area.parent_id=department.id
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
INNER JOIN assets yacimiento ON yacimiento.parent_id=area.id
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE department.id=$1::uuid
AND area.id=$2::uuid
AND yacimiento.id=$3::uuid
AND company.id=$4::uuid
AND lower(department_type.code)='departamento'
AND lower(area_type.code)='area'
AND lower(yacimiento_type.code)='yacimiento'
AND department_type.is_active=true
AND area_type.is_active=true
AND yacimiento_type.is_active=true
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
AND department.information_status<>'INACTIVE'
AND area.information_status<>'INACTIVE'
AND yacimiento.information_status<>'INACTIVE'
AND company.information_status<>'INACTIVE'
AND yacimiento.concession_type_id IS NOT NULL
LIMIT 1
`, [departmentId, areaId, yacimientoId, companyId]) as Array<{ '?column?': number }>;
if (!row) {
throw new BadRequestException({
code: 'INSPECTION_MOBILE_CONTEXT_INVALID',
message: 'La selección debe respetar Departamento → Área → Yacimiento → Operadora',
});
}
}
async operatorsForArea( async operatorsForArea(
areaId: string, areaId: string,
at?: string, at?: string,
@@ -87,21 +87,41 @@ export class InspectionVisitsController {
return this.planningHierarchy.operatorsForArea(areaId, at); return this.planningHierarchy.operatorsForArea(areaId, at);
} }
@Get('mobile/planning-context/areas') @Get('mobile/planning-context/departments')
@RequirePermissions('inspections.execute') @RequirePermissions('inspections.execute')
mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) { mobilePlanningDepartments(@CurrentAuth() principal: AuthPrincipal) {
assertMobileInspector(principal); assertMobileInspector(principal);
return this.visits.listPlanningAreas(); return this.planningHierarchy.departments();
} }
@Get('mobile/planning-context/areas/:areaId/operators') @Get('mobile/planning-context/departments/:departmentId/areas')
@RequirePermissions('inspections.execute') @RequirePermissions('inspections.execute')
mobilePlanningOperators( mobilePlanningAreasForDepartment(
@Param('departmentId', new ParseUUIDPipe({ version: '4' })) departmentId: string,
@CurrentAuth() principal: AuthPrincipal,
) {
assertMobileInspector(principal);
return this.planningHierarchy.areasForDepartment(departmentId);
}
@Get('mobile/planning-context/areas/:areaId/yacimientos')
@RequirePermissions('inspections.execute')
mobilePlanningYacimientos(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string, @Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
@CurrentAuth() principal: AuthPrincipal, @CurrentAuth() principal: AuthPrincipal,
) { ) {
assertMobileInspector(principal); assertMobileInspector(principal);
return this.visits.listPlanningOperators(areaId); return this.planningHierarchy.yacimientosForArea(areaId);
}
@Get('mobile/planning-context/yacimientos/:yacimientoId/operators')
@RequirePermissions('inspections.execute')
mobilePlanningOperatorsForYacimiento(
@Param('yacimientoId', new ParseUUIDPipe({ version: '4' })) yacimientoId: string,
@CurrentAuth() principal: AuthPrincipal,
) {
assertMobileInspector(principal);
return this.planningHierarchy.operatorsForYacimiento(yacimientoId);
} }
@Post('mobile/open') @Post('mobile/open')
@@ -112,10 +132,15 @@ export class InspectionVisitsController {
@Req() request: RequestWithContext, @Req() request: RequestWithContext,
) { ) {
assertMobileInspector(principal); assertMobileInspector(principal);
// Android conserva por compatibilidad el alcance a nivel Área hasta que su await this.planningHierarchy.validateMobileSelection(
// flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1. dto.departmentId,
const created = await this.visits.create({ dto.operationalAreaId,
dto.scopeAssetId,
dto.operatorCompanyId,
);
const created = await this.planningCreate.create({
operationalAreaId: dto.operationalAreaId, operationalAreaId: dto.operationalAreaId,
scopeAssetId: dto.scopeAssetId,
operatorCompanyId: dto.operatorCompanyId, operatorCompanyId: dto.operatorCompanyId,
plannedStartAt: new Date().toISOString(), plannedStartAt: new Date().toISOString(),
leadInspectorUserId: principal.userId, leadInspectorUserId: principal.userId,

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