Compare commits

...
Author SHA1 Message Date
admin a5d8022720 fix(f5): make catalog SQL text parameter types explicit 2026-09-08 23:12:47 -03:00
admin bfa9110c77 fix(f5): type relation backup markers explicitly 2026-09-08 23:07:25 -03:00
admin 915a2ee299 ci(f5): include canonical hierarchy migration in rollback gate 2026-09-08 23:02:37 -03:00
admin 91884b567e fix(f5): guarantee canonical inventory hierarchy before territory preload 2026-09-08 23:02:08 -03:00
admin 9dad6888b4 fix(f5): make territory SQL parameter types explicit 2026-09-08 22:56:13 -03:00
admin 1afef0f1f3 ci(f5): bypass irreversible historical reset in clean rehearsal 2026-09-08 22:48:08 -03:00
admin 3cfd3090aa ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal 2026-09-08 22:44:30 -03:00
admin 8e2ed6e38a test(f5): model normalized yacimiento collision explicitly 2026-09-08 22:38:40 -03:00
admin e92b1623c8 test(f5): distinguish literal and normalized yacimiento counts 2026-09-08 22:35:53 -03:00
admin 78e1d5cbf6 test(f5): make merge compatibility area-based 2026-09-08 22:35:26 -03:00
admin bd110b22f3 test(f5): align operator lifecycle with area-owned inventory 2026-09-08 22:35:15 -03:00
admin a53306b9d7 test(f5): align operational navigation contract 2026-09-08 22:35:05 -03:00
admin ec535233fa ci: prove F5 migrations revert and reapply cleanly 2026-09-08 22:08:54 -03:00
admin 35fa968128 ci: rehearse all migrations on clean PostGIS before merge 2026-09-08 22:08:08 -03:00
admin ef781abaca test(dashboard): route F5 summary query mocks explicitly 2026-09-08 22:05:58 -03:00
admin 3752b78f58 test(dashboard): expect inspector activity and act follow-up metrics 2026-09-08 22:02:39 -03:00
admin 923258f5bc chore(release): expose F5 WEB phase 2026-09-08 22:00:30 -03:00
admin 00065d55a6 chore(release): align WEB package with F5 inventory cut 2026-09-08 22:00:21 -03:00
admin e92ff01981 test(release): expect F5 health metadata 2026-09-08 22:00:09 -03:00
admin 7a66d67296 chore(release): bump API for F5 inventory model 2026-09-08 21:58:20 -03:00
admin 99ea1614ff chore(release): identify F5 inventory model 2026-09-08 21:58:07 -03:00
admin 8a9f81b8de fix(web): redefine inventory configuration around hierarchy classifications and columns 2026-09-08 21:56:30 -03:00
admin 741b2f2d8e fix(web): configure finding applicability by inventory classification 2026-09-08 21:54:29 -03:00
admin afa11918a6 feat(web): add inventory classification administration API 2026-09-08 21:53:59 -03:00
admin e2bb122d9e feat(inventory): expose family classification administration 2026-09-08 21:53:31 -03:00
admin 21c51bf672 feat(inventory): administer installation and subinstallation classifications 2026-09-08 21:53:17 -03:00
admin 90a85a7c1f feat(inventory): add family administration DTOs 2026-09-08 21:52:25 -03:00
admin fde562e2b4 test(inventory): lock F5 authoritative model and merge invariants 2026-09-08 21:51:04 -03:00
admin 5e26b4a899 fix(inventory): merge by physical area and preserve sealed documents 2026-09-08 21:50:21 -03:00
admin b4d81cff68 feat(web): create companies independently from physical inventory hierarchy 2026-09-08 21:47:12 -03:00
admin 53c7ca45f1 feat(web): expose independent company master in inventory setup 2026-09-08 21:46:29 -03:00
admin 0e320a9395 fix(inventory): make guided creation area-owned and support companies 2026-09-08 21:46:12 -03:00
admin 791137eb65 feat(inventory): allow independent company master creation 2026-09-08 21:45:23 -03:00
admin bb4a4eeb17 fix(inventory): make authoritative territory preload safely reversible 2026-09-08 21:40:41 -03:00
admin 39ca9dc35f fix(inventory): harden authoritative catalog migration checks 2026-09-08 21:38:00 -03:00
admin c651ef9616 fix(inventory): preserve custom catalog and apply authoritative universal findings 2026-09-08 21:34:52 -03:00
admin a3dd705190 fix(inventory): resolve field inventory by area hierarchy, not company ownership 2026-09-08 21:31:37 -03:00
admin f6564194b6 fix(inventory): treat company on asset as non-owning creation snapshot 2026-09-08 21:30:21 -03:00
admin dcb6b9fdce fix(inventory): apply area-owned operational guard on F5 up 2026-09-08 21:26:07 -03:00
admin 4e93b29edf fix(web): load F5 flow styles 2026-09-08 20:06:14 -03:00
admin ccc5226f72 fix(web): style act follow-up tabs and F5 inventory context 2026-09-08 20:06:05 -03:00
admin 4bc54f67fe fix(web): show only real inventory instances 2026-09-08 20:05:04 -03:00
admin 3f0148e4b7 refactor(web): make inventory hierarchy area-owned 2026-09-08 20:04:35 -03:00
admin 1a6f6f5c68 feat(web): add real inventory list client 2026-09-08 20:03:32 -03:00
admin 06aaacfd3f feat(inventory): expose real inventory list 2026-09-08 20:03:13 -03:00
admin 0548115516 feat(inventory): add real inventory list endpoint logic 2026-09-08 20:02:59 -03:00
admin 7945d1a729 feat(inventory): extend inventory browser filters 2026-09-08 20:02:24 -03:00
admin 8ea8a35829 feat(web): add inventory browser API client 2026-09-08 20:01:55 -03:00
admin 31a3a207d6 fix(inventory): make operator relation temporal and non-owning 2026-09-08 20:01:17 -03:00
admin bd6d91d7ac refactor(inventory): remove function catalog and add inventory browser 2026-09-08 20:00:09 -03:00
admin 598c987031 feat(inventory): expose area-owned inventory browser 2026-09-08 19:59:50 -03:00
admin 84217b67d8 feat(inventory): add area-owned inventory browser 2026-09-08 19:59:39 -03:00
admin 9e29c9d69c feat(inventory): add inventory browser query contract 2026-09-08 19:59:18 -03:00
admin 4bbe32ba93 feat(inventory): add inventory-only tree filter 2026-09-08 19:58:18 -03:00
admin ff518ee916 feat(inventory): add inventory-only asset filter 2026-09-08 19:57:58 -03:00
admin f4cd931d51 fix(inventory): preserve non-hierarchical operator snapshot compatibility 2026-09-08 19:57:18 -03:00
admin 436b57a3da fix(findings): use only authoritative F5 family catalog 2026-09-08 19:55:04 -03:00
admin 60acadd24e feat(inventory): preload authoritative technical catalog 2026-09-08 19:54:44 -03:00
admin 218f792f13 feat(inventory): preload authoritative territory model 2026-09-08 19:54:00 -03:00
admin 91e346d4c4 feat(inventory): add authoritative F5 source snapshot 2026-09-08 19:46:59 -03:00
admin afbece7d70 fix(inventory): count only installation and subinstallation instances 2026-09-08 19:07:50 -03:00
admin 88d43b7414 feat(inventory): classify future concrete instances at database level 2026-09-08 18:23:16 -03:00
admin 6b5b522be1 fix(inventory): keep field yacimientos structural 2026-09-08 18:22:47 -03:00
admin 637e9167b4 feat(inventory): map physical instance flag on asset entity 2026-09-08 18:22:15 -03:00
admin e082f5d062 feat(inventory): mark field-created records as real instances 2026-09-08 18:08:19 -03:00
admin ead1beb9d7 fix(web): clarify dashboard act and report concepts 2026-09-08 18:07:55 -03:00
admin 8389991b2c fix(web): align dashboard summary contract 2026-09-08 18:07:26 -03:00
admin 79258836b1 fix(dashboard): align inventory and act follow-up metrics 2026-09-08 18:07:17 -03:00
admin 11052c83aa feat(inventory): distinguish physical instances from structural records 2026-09-08 18:06:11 -03:00
admin a27f17d642 fix(web): remove redundant inspections lifecycle legend 2026-09-08 18:05:44 -03:00
admin 9083883bf2 fix(web): remove legacy imports and function catalog routes 2026-09-08 18:05:16 -03:00
admin fffd1ddcc2 fix(web): simplify inventory administration menu 2026-09-08 18:04:55 -03:00
admin 1dc3282055 F4 hotfix · metadata de health
Corrige la fase publicada por el health de la API para reflejar F4 y agrega una prueba de regresión sin cambiar la versión funcional.
2026-09-08 16:18:58 -03:00
admin 7875fea3ca test(health): pin F4 phase metadata 2026-09-08 16:14:10 -03:00
admin 0d5bdd25ce fix(health): report F4 phase 2026-09-08 16:14:04 -03:00
admin a75e9bdcb0 F4 hotfix · paridad preflight deploy/CI
Alinea el preflight real del auto-deploy VPS con la barrera Docker de GitHub CI, montando contratos cross-tree en read-only y agregando una guardia de paridad.
2026-09-08 16:04:44 -03:00
admin 3027e9a8c8 ci: guard deploy preflight contract mounts 2026-09-08 15:59:00 -03:00
admin 09a190532c fix(deploy): mirror F4 contract mounts in VPS preflight 2026-09-08 15:58:46 -03:00
admin 8a8ab47453 F4 · Saneamiento backend y flujo documental
Integra el saneamiento F4 validado por CI completa: flujo Acta → INF → GEDO/IF, lifecycle documental, contratos WEB/Android, migraciones reversibles, seguimiento append-only y cierre de observaciones de revisión.
2026-09-08 15:53:41 -03:00
admin 9b86f49c9e ci: mount compose contract read-only in preflight 2026-09-08 15:27:33 -03:00
admin 8584249eb2 fix: sanitize GEDO download filename 2026-09-08 15:23:03 -03:00
github-actions[bot] 5dc7ea037d fix: close final F4 review gaps 2026-09-08 18:22:02 +00:00
admin 969ffddc76 chore: stage final F4 review fixes 2026-09-08 15:21:54 -03:00
admin 4c47916bd1 test: update multi-act company outcome semantics 2026-09-08 15:10:18 -03:00
admin 2121df0b55 test: align F4 sealing contract with documented absence 2026-09-08 15:10:01 -03:00
admin fcd64d5f43 chore: remove one-shot F4 patch workflow 2026-09-08 15:07:03 -03:00
github-actions[bot] 844c1a950f fix: close F4 pre-merge review gaps 2026-09-08 18:06:16 +00:00
admin e1e44e6352 chore: stage one-shot F4 pre-merge corrections 2026-09-08 15:06:07 -03:00
admin 94a8d5c92f ci: mount web and android contracts read-only in preflight 2026-09-08 14:51:19 -03:00
admin 3bfa3bce1b fix(f4): require current checklist before inspection execution 2026-09-08 14:43:02 -03:00
admin 4df8f40dd4 test(f4): align legacy contracts with lifecycle model 2026-09-08 14:39:55 -03:00
admin 7b6460dff2 fix(f4): name recurrence response contracts for api declarations 2026-09-08 13:55:11 -03:00
admin 30353c338b fix(f4): expose recurrence state and close ci type gaps 2026-09-08 13:52:24 -03:00
admin 95a41f39d7 fix(f4): align verification and web contracts with legacy cleanup 2026-09-08 13:44:51 -03:00
admin 100d457484 test(f4): guard Android inspection contract from legacy fields 2026-09-08 11:42:48 -03:00
admin 4d0ed8b046 test(f4): guard all active inspection consumers from legacy fields 2026-09-08 11:41:42 -03:00
admin a510d309bc test(f4): prove legacy field rollback preserves data 2026-09-08 10:30:53 -03:00
admin 9606168ac6 fix(f4): make inspection legacy field removal data reversible 2026-09-08 10:30:27 -03:00
admin 0442511065 test(f4): guard android lock seal lifecycle contract 2026-09-08 10:29:42 -03:00
admin 27d14d2d4e refactor(f4): remove legacy mobile act lifecycle aliases 2026-09-08 10:29:24 -03:00
admin fa64ba7727 fix(f4): align embedded INF status in act contract 2026-09-08 10:27:32 -03:00
admin d0b805b281 test(f4): guard locked sealed web act lifecycle 2026-09-08 10:26:51 -03:00
admin b3125b51ad fix(f4): use locked sealed lifecycle in act detail 2026-09-08 10:26:13 -03:00
admin b67db745e4 fix(f4): present locked and sealed act lifecycle 2026-09-08 10:25:46 -03:00
admin f0ffde975b refactor(f4): decouple findings panel from legacy act type 2026-09-08 10:25:10 -03:00
admin d8b02bfaf1 fix(f4): use act lifecycle contract inside inspections 2026-09-08 10:24:39 -03:00
admin 7a406df1ad fix(f4): show locked and sealed acts in document center 2026-09-08 10:24:19 -03:00
admin 8c89cd05c9 feat(f4): add explicit act lifecycle web contract 2026-09-08 10:23:49 -03:00
admin f7a4e7f228 fix(f4): align web inspection legacy contract 2026-09-08 10:18:42 -03:00
admin 7b9d2b7903 test(f4): guard inspection legacy fields across backend and web 2026-09-08 10:11:33 -03:00
admin 53db8b93bb fix(f4): preserve finding verification date contract 2026-09-08 10:10:45 -03:00
admin 61f77505df fix(f4): remove legacy inspection titles from findings 2026-09-08 10:08:39 -03:00
admin c28e5b060d test(f4): lock field finding and sequential multi-act contracts 2026-09-08 10:01:38 -03:00
admin 2c1fdf7fb2 test(f4): make lifecycle source boundaries deterministic 2026-09-08 10:00:24 -03:00
admin afb6feb4c1 test(f4): harden multi-act immutability gates 2026-09-08 09:58:12 -03:00
admin 7174d379e5 test: cover F4 inspector delivery database contract 2026-09-08 09:38:52 -03:00
admin ff7a99725e fix: align F4 document delivery recipient constraint 2026-09-08 09:38:18 -03:00
admin 6f100bf9ba test: align document center baseline with F4 report lifecycle 2026-09-08 09:35:57 -03:00
admin 2968f83b36 test: guard active Act queries from removed Inspection fields 2026-09-08 09:32:56 -03:00
admin cac3161cbb fix: restore area filter after Inspection title cleanup 2026-09-08 09:31:42 -03:00
admin 97296ba37b fix: remove active Inspection title reads from Acts service 2026-09-08 09:29:56 -03:00
admin 80275969c9 test: cover F4 Act code database constraint 2026-09-08 09:27:58 -03:00
admin 345ef76820 fix: align F4 Act code constraint with generated codes 2026-09-08 09:27:29 -03:00
admin 40e33f8312 test: lock F4 document lifecycle database contract 2026-09-08 09:25:14 -03:00
admin 57739dcb3e fix: make F4 lifecycle constraint rollback executable 2026-09-08 09:24:50 -03:00
admin 271290abe4 fix: align F4 document lifecycle database constraints 2026-09-08 09:23:55 -03:00
admin 5393a40e8b test: tighten F4 Director retirement and retained INF history 2026-09-08 09:20:33 -03:00
admin 3a1379d49a fix: fully retire Director report permissions in F4 2026-09-08 09:20:14 -03:00
admin ad44dc7ef6 test: assert Survey planning DTOs are retired in F4 2026-09-08 09:18:19 -03:00
admin 248fb6285b test: assert Survey planning authorization is retired in F4 2026-09-08 09:18:04 -03:00
admin 792e21e669 test: assert Survey execution DTOs are retired in F4 2026-09-08 09:17:48 -03:00
admin 9fab773090 test: assert Survey execution authorization is retired in F4 2026-09-08 09:17:30 -03:00
admin 38742bf140 test: align mobile multi-act lifecycle with F4 sealing 2026-09-08 09:16:23 -03:00
admin 2018e1aeba test: align document delivery contract with F4 recipients and SMTP 2026-09-08 09:15:44 -03:00
admin c11808f560 test: align automatic report contract with F4 sealing 2026-09-08 09:15:26 -03:00
admin 28ec002710 test: retire Director review contract under F4 2026-09-08 09:15:06 -03:00
admin 90851e6e7d test: preserve D5 guarantees under F4 document workflow 2026-09-08 09:11:40 -03:00
admin 0e2b524a26 test: lock F4 multi-act lifecycle invariants 2026-09-08 09:08:58 -03:00
admin 5bd392dcb4 test: move verification completion gate to inspection close 2026-09-08 09:08:42 -03:00
admin 232614ccf0 test: align multi-act contract with F4 lifecycle 2026-09-08 09:08:23 -03:00
admin b24c3787c9 fix: require verification results when closing inspection 2026-09-08 09:08:00 -03:00
admin 71d0884557 fix: decouple act locking from visit verification completion 2026-09-08 09:07:05 -03:00
admin 2506bece76 test(f4): guard retired inspection fields 2026-09-08 08:52:09 -03:00
admin a9fc9f3e8e refactor(f4): remove inspection legacy type shims 2026-09-08 08:50:42 -03:00
admin f70962a819 refactor(f4): remove inspection legacy persistence shim 2026-09-08 08:50:27 -03:00
admin e6ae2807bc refactor(f4): align inspection entity with retired columns 2026-09-08 08:46:15 -03:00
admin 5404409042 refactor(f4): retire legacy inspection fields 2026-09-08 08:45:54 -03:00
admin 49b321ca87 fix(f4): remove inspection title from asset dossier 2026-09-08 08:26:31 -03:00
admin 048b7b90af fix(f4): remove inspection title from Android contract 2026-09-08 08:16:29 -03:00
admin c53997a41c fix(f4): align finding worklist web contract 2026-09-08 08:11:46 -03:00
admin 459ad03326 fix(f4): decouple finding worklist from inspection title 2026-09-08 08:11:33 -03:00
admin a816f4cfdf fix(f4): keep legacy deadline storage compatible pending notification definition 2026-09-08 07:51:33 -03:00
admin 98545820ac refactor(f4): model nonurgent deadline on notification date 2026-09-08 07:50:39 -03:00
admin a9b2ed4b4b fix(f4): decouple GEDO officialization from notification deadline 2026-09-08 07:49:29 -03:00
admin 5386ccf9b5 refactor(f4): remove inspection title from verification contracts 2026-09-08 07:46:34 -03:00
admin 0df7935b54 refactor(f4): remove inspection title from reports UI 2026-09-08 07:43:52 -03:00
admin 2d2a268220 refactor(f4): remove inspection title from report web contract 2026-09-08 07:43:09 -03:00
admin b021189a94 refactor(f4): remove inspection title from report contracts 2026-09-08 07:42:32 -03:00
admin ce0bbf1d94 refactor(f4): align inspection wrapper with canonical contract 2026-09-08 07:41:24 -03:00
admin 3f0a1e5441 refactor(f4): remove legacy inspection lifecycle and public title/end date 2026-09-08 07:39:38 -03:00
admin bd767b7e32 fix(f4): hide SMTP controls outside system admin 2026-09-08 07:35:08 -03:00
admin d08162fca8 fix(f4): restrict SMTP administration to system admin 2026-09-08 07:32:39 -03:00
admin 2826960d71 db(f4): drop legacy director report workflow 2026-09-08 07:11:12 -03:00
admin 0718414fdb refactor(api): remove legacy director report fields 2026-09-08 07:10:53 -03:00
admin a4a57d3109 chore(web): remove legacy survey redirects 2026-09-08 07:05:23 -03:00
admin e3b24cba3d F4: remove obsolete Survey subsystem schema and permissions 2026-09-08 06:44:06 -03:00
admin cc8c5d9392 F4: remove Survey report media entity 2026-09-08 06:43:31 -03:00
admin 27f52a05c8 F4: remove Survey report version entity 2026-09-08 06:43:21 -03:00
admin b5b6942e39 F4: remove Survey target report entity 2026-09-08 06:43:07 -03:00
admin 0609b7faab F4: remove Survey campaign target entity 2026-09-08 06:42:54 -03:00
admin 0aa4e06d17 F4: remove Survey campaign entity 2026-09-08 06:42:44 -03:00
admin d2ae9f9bd4 F4: remove Survey entities from ORM registry 2026-09-08 06:42:35 -03:00
admin e3c347dbb8 F4: restore web API client exactly after accidental overwrite 2026-09-08 06:32:48 -03:00
admin 116dd24b7b F4: remove accidental api placeholder before exact restore 2026-09-08 06:28:19 -03:00
admin c2c935fd85 noop 2026-09-08 06:28:08 -03:00
admin 068cf8ae3a F4: remove legacy survey report DTO 2026-09-08 06:27:35 -03:00
admin fbf426ed90 F4: remove legacy survey review DTO 2026-09-08 06:27:24 -03:00
admin 46c2abb4d1 F4: remove legacy survey execution service 2026-09-08 06:27:10 -03:00
admin 50728e83ba F4: remove legacy survey execution module 2026-09-08 06:27:00 -03:00
admin cbf8f0c738 F4: remove obsolete survey execution controller 2026-09-07 23:16:42 -03:00
admin e2049b084f F4: remove survey planning DTOs 2026-09-07 23:16:33 -03:00
admin 73eb6fc192 F4: remove survey planning DTOs 2026-09-07 23:16:24 -03:00
admin 89f48dd82d F4: remove survey planning DTOs 2026-09-07 23:16:14 -03:00
admin 5b22dde1c6 F4: remove survey planning DTOs 2026-09-07 23:16:07 -03:00
admin 3d3d89a76a F4: remove survey planning DTOs 2026-09-07 23:15:59 -03:00
admin 29864fb2ae F4: remove survey planning DTOs 2026-09-07 23:15:51 -03:00
admin dea007581c F4: remove survey planning DTOs 2026-09-07 23:15:41 -03:00
admin de87952743 F4: remove survey planning DTOs 2026-09-07 23:15:29 -03:00
admin 0bab3e5b70 F4: remove obsolete survey planning service 2026-09-07 23:15:19 -03:00
admin 17e2e1902b F4: remove obsolete survey planning module 2026-09-07 23:15:10 -03:00
admin 3e2b021943 F4: remove obsolete survey campaigns controller 2026-09-07 23:15:02 -03:00
admin 830addb7b5 F4: make SMTP configurable from Superadmin and remove Director 2026-09-07 23:14:15 -03:00
admin 2319d06685 F4: add document delivery client without Director 2026-09-07 23:13:41 -03:00
admin 8d962e783d F4: add Superadmin SMTP settings client 2026-09-07 23:13:24 -03:00
admin b69b773094 F4: present locked and sealed Act states 2026-09-07 23:11:24 -03:00
admin 5996c3819a F4: remove obsolete generic-status inspection editor 2026-09-07 23:10:48 -03:00
admin 7ac42fa05d F4: route inspections through clean planning editor 2026-09-07 23:10:36 -03:00
admin 3180101ef7 F4: add clean inspection planning editor 2026-09-07 23:10:01 -03:00
admin 572c8a4af1 F4: add explicit inspection lifecycle client 2026-09-07 23:09:05 -03:00
admin 91480779dd F4: expose explicit inspection unplan action 2026-09-07 23:08:55 -03:00
admin 0a6500fbff F4: add explicit inspection unplan lifecycle action 2026-09-07 23:08:34 -03:00
admin 69bc720899 F4: activate inspection visit compatibility layer 2026-09-07 23:06:21 -03:00
admin ce33b32820 F4: isolate technical inspection checklist and public visit model 2026-09-07 23:06:12 -03:00
admin 212f4dcee5 F4: remove obsolete finding company-response workspace 2026-09-07 23:01:56 -03:00
admin 1775392eb1 F4: make findings worklist technical-only 2026-09-07 23:00:01 -03:00
admin 92cf0dee97 F4: add technical findings worklist client 2026-09-07 22:59:24 -03:00
admin 2e5c4c35aa F4: register technical findings worklist 2026-09-07 22:59:03 -03:00
admin 1192ed2cdd F4: route global findings to technical worklist 2026-09-07 22:58:49 -03:00
admin 4db3797afc F4: add technical finding worklist service 2026-09-07 22:58:32 -03:00
admin 7a910d7505 F4: align dashboard with technical findings and INF workflow 2026-09-07 22:57:00 -03:00
admin c999354645 F4: add dashboard contract without finding response states 2026-09-07 22:56:33 -03:00
admin bc7cd48436 F4: align dashboard counters with technical and INF workflows 2026-09-07 22:56:20 -03:00
admin 44eccaa36c F4: align verification planning UI with technical workflow 2026-09-07 22:55:02 -03:00
admin 82d8af2728 F4: decouple verification planning from company response 2026-09-07 22:53:41 -03:00
admin 408fd5990d F4: align report list with INF GEDO workflow 2026-09-07 22:48:18 -03:00
admin c715029dbc F4: add report list contracts to web client 2026-09-07 22:47:43 -03:00
admin f83ab824ff F4: replace director report review UI with INF workflow 2026-09-07 22:46:53 -03:00
admin 513fe6121b F4: add web client for report workflow 2026-09-07 22:45:57 -03:00
admin fb12ce3cff F4: add function catalog to administration navigation 2026-09-07 22:44:00 -03:00
admin 558fdbc85d F4: route inventory function catalog admin 2026-09-07 22:43:43 -03:00
admin 4a86dd15d9 F4: expose function changes in inventory history 2026-09-07 22:43:25 -03:00
admin 86e692c537 F4: add inventory function catalog admin 2026-09-07 22:41:59 -03:00
admin f8e866a140 F4: add inventory function change panel 2026-09-07 22:41:32 -03:00
admin 30f543ad08 F4 findings: center detail on immutable description and verification 2026-09-07 22:16:05 -03:00
admin 747ad21262 F4 inventory: version function changes in dossier history 2026-09-07 22:11:10 -03:00
admin a9a6b2fa1f F4 inventory: include function in temporal snapshots 2026-09-07 22:09:50 -03:00
admin 4caf6216de F4 WEB: add Inventory function API client 2026-09-07 21:45:09 -03:00
admin f20cc28045 F4: pass secure SMTP and remote signature settings to API 2026-09-07 21:43:38 -03:00
admin 3b4310b63a F4: document remote signature and SMTP secure settings 2026-09-07 21:43:15 -03:00
admin 335d327984 F4 WEB: expose public remote Act signature route 2026-09-07 21:42:56 -03:00
admin f4e5480ac8 F4 WEB: add public secure Act signature page 2026-09-07 21:42:26 -03:00
admin bdcf193a90 F4: wire remote company signature workflow 2026-09-07 21:39:05 -03:00
admin e577e4c976 F4: export SMTP delivery for remote signature invitations 2026-09-07 21:38:56 -03:00
admin 343dbe086f F4: expose secure remote company signature endpoints 2026-09-07 21:38:40 -03:00
admin a178f64763 F4: implement secure remote company signature workflow 2026-09-07 21:38:23 -03:00
admin badb79be5b F4: add remote company signature invitation DTOs 2026-09-07 21:37:13 -03:00
admin 161f4a9252 F4: add secure remote company signature invitations 2026-09-07 21:36:15 -03:00
admin 73c69c7a62 F4 Android: redesign Act screen for DRAFT LOCKED SEALED 2026-09-07 21:34:45 -03:00
admin ef084dd899 F4 Android: align ViewModel with urgency lock and seal 2026-09-07 21:33:53 -03:00
admin eeb35da02c F4: generate Act PDF only from SEALED Acts 2026-09-07 21:32:50 -03:00
admin 0db4567289 F4: include editable INF fields in generated Word 2026-09-07 21:31:57 -03:00
admin 53702009e3 F4: rebuild editable INF Word from sealed Act snapshot 2026-09-07 21:31:35 -03:00
admin f5ab6ce885 F4: rebuild Act PDF from locked and sealed snapshot 2026-09-07 21:30:43 -03:00
admin 1bed1cae98 F4 Android: keep branch compiling while UI migrates to lock/seal 2026-09-07 21:28:53 -03:00
admin d9698f11f0 F4: remove legacy per-Finding company follow-up endpoint 2026-09-07 21:26:55 -03:00
admin d0c55b8b39 F4 Android: align Act contracts with lock and seal 2026-09-07 21:25:53 -03:00
admin 009f4d10cf F4: keep function history in its temporal ledger 2026-09-07 21:22:53 -03:00
admin c30f167731 F4: audit recurrence through finding update ledger 2026-09-07 21:21:46 -03:00
admin 8b033dc297 F4: wire finding recurrence workflow 2026-09-07 21:21:14 -03:00
admin 236605a7d7 F4: expose finding recurrence endpoints 2026-09-07 21:21:04 -03:00
admin af6256ce0f F4: implement finding recurrence workflow 2026-09-07 21:20:56 -03:00
admin 0a8cb8f8e5 F4: add finding recurrence DTO 2026-09-07 21:20:31 -03:00
admin 5e14ffda17 F4: expose explicit inspection lifecycle endpoints 2026-09-07 21:19:42 -03:00
admin fcf52c60ff F4: wire explicit inspection lifecycle 2026-09-07 21:19:22 -03:00
admin 577e267f49 F4: implement explicit inspection lifecycle 2026-09-07 21:19:12 -03:00
admin 83ab2d4060 F4: add explicit inspection cancellation DTO 2026-09-07 21:18:43 -03:00
admin 2b63638c35 F4: remove legacy Act inference from finding queries 2026-09-07 21:17:56 -03:00
admin c3c798baed F4: require explicit Act for every field finding 2026-09-07 21:17:46 -03:00
admin a836c7535a F4: include technical function in merged inventory dossier 2026-09-07 21:17:33 -03:00
admin 88179eb67c F4: audit inventory function changes 2026-09-07 21:15:15 -03:00
admin 4d5c83732e F4: version inventory function changes 2026-09-07 21:14:42 -03:00
admin 0833746e1d F4: wire inventory function workflow 2026-09-07 21:14:27 -03:00
admin e02a3d9b58 F4: expose inventory function catalog and history 2026-09-07 21:14:09 -03:00
admin cbe69a8fff F4: implement temporal inventory function changes 2026-09-07 21:13:59 -03:00
admin 8b4cc99e53 F4: add inventory function DTOs 2026-09-07 21:12:57 -03:00
admin 5c1c046be6 F4: add temporal inventory function catalog 2026-09-07 21:12:43 -03:00
admin e25f72ac0a feat(f4): register deadline administration 2026-09-07 21:03:00 -03:00
admin 5e6631554a feat(f4): expose JEFE deadline policy endpoints 2026-09-07 21:02:54 -03:00
admin 9a79b7eb86 feat(f4): implement JEFE deadline policy and calendar administration 2026-09-07 21:02:44 -03:00
admin d071e6be74 feat(f4): add JEFE deadline administration DTOs 2026-09-07 21:02:27 -03:00
admin 2d97398c01 feat(f4): add Superadmin SMTP management endpoints 2026-09-07 21:01:44 -03:00
admin 489fb309b3 feat(f4): add superadmin SMTP settings DTO 2026-09-07 21:01:14 -03:00
admin a7f53f32f0 refactor(f4): remove obsolete Director delivery setting 2026-09-07 21:01:02 -03:00
admin ce3d3cf8b7 feat(f4): load SMTP from encrypted superadmin settings 2026-09-07 20:59:33 -03:00
admin 96093d9ed4 refactor(f4): deliver editable INF Word to inspector 2026-09-07 20:58:49 -03:00
admin a1d994b6d4 feat(f4): register INF workflow service 2026-09-07 20:57:47 -03:00
admin 39846c81a4 feat(f4): expose INF editing, GEDO and follow-up endpoints 2026-09-07 20:57:37 -03:00
admin 31256ea0b5 feat(f4): implement INF editing, GEDO officialization and follow-up 2026-09-07 20:57:22 -03:00
admin ad66bb3e61 feat(f4): add append-only INF follow-up DTO 2026-09-07 20:56:39 -03:00
admin 0c35e801eb feat(f4): add GEDO officialization DTO 2026-09-07 20:56:32 -03:00
admin b8fde4c32d feat(f4): add editable INF narrative DTO 2026-09-07 20:56:27 -03:00
admin 416aac335c refactor(f4): make INF editable and one-to-one with sealed Act 2026-09-07 20:56:11 -03:00
admin 97a6fb029d refactor(f4): expose lock and seal act lifecycle 2026-09-07 20:55:14 -03:00
admin f23a28e438 refactor(f4): replace READY/CLOSED lifecycle with LOCKED/SEALED 2026-09-07 20:54:53 -03:00
admin c348f3162c feat(f4): add lifecycle and document audit actions 2026-09-07 20:52:58 -03:00
admin 56f9c4234b feat(f4): add locked and sealed act version events 2026-09-07 20:52:29 -03:00
admin e7a9ff7dd9 feat(f4): align act numbering and urgency contract 2026-09-07 20:51:11 -03:00
admin 01efa2a8cc feat(f4): allow act urgency update while draft 2026-09-07 20:49:49 -03:00
admin bda4ce224d feat(f4): require explicit act urgency 2026-09-07 20:49:41 -03:00
admin 92240b21c2 feat(f4): adopt institutional INSP numbering 2026-09-07 20:47:33 -03:00
admin 6cc687d5e1 refactor(f4): remove survey runtime modules 2026-09-07 20:46:17 -03:00
admin c2d4f82a3a refactor(f4): remove Director report review service 2026-09-07 20:46:03 -03:00
admin f184c73c9e refactor(f4): remove Director report review endpoints 2026-09-07 20:45:58 -03:00
admin d834bc7b82 refactor(f4): remove Director report review circuit 2026-09-07 20:45:49 -03:00
admin c97e5ae33c feat(f4): add document workflow foundation migration 2026-09-07 20:45:29 -03:00
admin 0f1164fae8 feat(f4): register deadline, follow-up and SMTP entities 2026-09-07 20:45:04 -03:00
admin ab665e51b5 feat(f4): add superadmin SMTP settings entity 2026-09-07 20:44:42 -03:00
admin 13f2dcfa95 feat(f4): add append-only INF follow-up timeline 2026-09-07 20:44:34 -03:00
admin 2fb48ec41e feat(f4): add business-day calendar overrides 2026-09-07 20:44:27 -03:00
admin 52a068c817 feat(f4): add JEFE-managed act deadline policy 2026-09-07 20:44:21 -03:00
admin a9efd71367 feat(f4): extend INF with GEDO officialization 2026-09-07 20:44:12 -03:00
admin 7a41ce25ad feat(f4): add finding recurrence linkage 2026-09-07 20:43:55 -03:00
admin 48bbe293d9 feat(f4): extend act lifecycle and deadline snapshot 2026-09-07 20:43:40 -03:00
admin ce18f5d1a3 F3.2 · Android multi-Acta y cierre operativo
Integra selección/creación explícita de Actas, flujo móvil de firmas/cierre, soporte de Actas READY paralelas, cierre validado por servidor y Actas de verificación sin Hallazgos nuevos.
2026-09-07 17:30:53 -03:00
213 changed files with 13912 additions and 7085 deletions
+11 -1
View File
@@ -21,10 +21,20 @@ ACCESS_COOKIE_NAME=dhv2_access
REFRESH_COOKIE_NAME=dhv2_refresh REFRESH_COOKIE_NAME=dhv2_refresh
CSRF_COOKIE_NAME=dhv2_csrf CSRF_COOKIE_NAME=dhv2_csrf
INSPECTION_SIGNATURE_ROOT=/app/storage/asset-media/inspection-signatures
INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word
INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions
INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf
# Enlace público de un solo uso para firma/manifestación de empresa.
# En producción: https://dhv2.korexlabs.com/firma-acta
# En entorno de prueba: https://prueba.dhv2.korexlabs.com/firma-acta
COMPANY_SIGNATURE_PUBLIC_BASE_URL=https://dhv2.korexlabs.com/firma-acta
# Clave maestra de 32 bytes (64 hex o base64) para cifrar la contraseña SMTP guardada por Superadmin.
SMTP_SETTINGS_MASTER_KEY=CHANGE_ME_WITH_32_RANDOM_BYTES
# Fallback SMTP. Se usa sólo hasta que Superadmin guarde una configuración en la base de datos.
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=false SMTP_SECURE=false
+154
View File
@@ -62,8 +62,159 @@ jobs:
while IFS= read -r -d '' script; do while IFS= read -r -d '' script; do
bash -n "$script" bash -n "$script"
done < <(find scripts -type f -name '*.sh' -print0) done < <(find scripts -type f -name '*.sh' -print0)
- name: Validate deploy preflight parity
run: |
grep -Fq -- '$STAGE/docker-compose.yml:/docker-compose.yml:ro' scripts/deploy-github.sh
grep -Fq -- '$STAGE/web-v2:/web-v2:ro' scripts/deploy-github.sh
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
- name: Validate Compose - name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null run: docker compose --env-file .env.example config >/dev/null
- name: Rehearse migrations on clean PostGIS
run: |
set -Eeuo pipefail
cleanup() {
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup
docker compose --env-file .env.example up -d db
# The historical production reset is a one-shot operational migration,
# not a bootstrap migration: it requires production data/configuration
# that cannot exist at its timestamp in a database rebuilt from zero.
# Prove the clean chain reaches that exact guard, then mark only that
# one-shot migration as already applied and continue the reproducible
# schema chain. The historical migration itself remains untouched.
bootstrap_log="$(mktemp)"
set +e
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
bootstrap_status=${PIPESTATUS[0]}
set -e
if [ "$bootstrap_status" -eq 0 ]; then
echo "ERROR: clean migration rehearsal unexpectedly passed the historical production reset." >&2
exit 1
fi
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
echo "ERROR: migration rehearsal failed before the expected historical production-reset guard." >&2
exit 1
}
rm -f "$bootstrap_log"
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE
reset_rows integer;
BEGIN
SELECT COUNT(*) INTO reset_rows
FROM typeorm_migrations
WHERE name = 'ResetProductionOperationalData1788652800000';
IF reset_rows <> 0 THEN
RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows;
END IF;
INSERT INTO typeorm_migrations ("timestamp", name)
VALUES (1788652800000, 'ResetProductionOperationalData1788652800000');
END $$;
SQL
docker compose --env-file .env.example --profile tools run --rm migrate
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE
f5_migrations integer;
real_inventory integer;
source_areas integer;
source_yacimientos integer;
source_installations integer;
source_subinstallations integer;
source_findings integer;
BEGIN
SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
WHERE name IN (
'F5InventoryPhysicalInstance1790087100000',
'F5CanonicalInventoryHierarchy1790087150000',
'F5AuthoritativeTerritory1790087200000',
'F5OperationalContextCompatibility1790087250000',
'F5AuthoritativeInventoryCatalog1790087300000'
);
IF f5_migrations <> 5 THEN
RAISE EXCEPTION 'Expected 5 F5 migrations, got %', f5_migrations;
END IF;
SELECT COUNT(*) INTO real_inventory
FROM assets WHERE is_inventory_instance=true AND information_status<>'INACTIVE';
IF real_inventory <> 0 THEN
RAISE EXCEPTION 'Fresh F5 database must start with 0 real Inventory instances, got %', real_inventory;
END IF;
SELECT COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA'),
COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')
INTO source_areas,source_yacimientos
FROM source_documents document
JOIN asset_source_documents link ON link.document_id=document.id
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE document.document_number='DH-F5-TERRITORY';
IF source_areas <> 64 OR source_yacimientos <> 230 THEN
RAISE EXCEPTION 'F5 territory preload mismatch: areas %, yacimientos %', source_areas,source_yacimientos;
END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations
FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
RAISE EXCEPTION 'F5 family preload mismatch: installations %, subinstallations %', source_installations,source_subinstallations;
END IF;
SELECT COUNT(*) INTO source_findings
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='f5model' AND item.is_active=true;
IF source_findings <> 177 THEN
RAISE EXCEPTION 'F5 finding preload mismatch: %', source_findings;
END IF;
END $$;
SQL
# Prove the five F5 migrations are actually reversible on a clean state.
for _ in 1 2 3 4 5; do
docker compose --env-file .env.example --profile tools run --rm migrate npm run migration:revert
done
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE f5_migrations integer; instance_column integer;
BEGIN
SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
WHERE name LIKE 'F5%1790087%';
IF f5_migrations <> 0 THEN
RAISE EXCEPTION 'F5 rollback left % migration rows behind', f5_migrations;
END IF;
SELECT COUNT(*) INTO instance_column
FROM information_schema.columns
WHERE table_schema='public' AND table_name='assets' AND column_name='is_inventory_instance';
IF instance_column <> 0 THEN
RAISE EXCEPTION 'F5 rollback left is_inventory_instance behind';
END IF;
END $$;
SQL
# Reapply them once more. Each F5 migration performs its own source/cardinality checks.
docker compose --env-file .env.example --profile tools run --rm migrate
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 -Atc \
"SELECT CASE WHEN COUNT(*)=5 THEN 'F5_REAPPLY_OK' ELSE 'F5_REAPPLY_FAILED:'||COUNT(*) END FROM typeorm_migrations WHERE name IN ('F5InventoryPhysicalInstance1790087100000','F5CanonicalInventoryHierarchy1790087150000','F5AuthoritativeTerritory1790087200000','F5OperationalContextCompatibility1790087250000','F5AuthoritativeInventoryCatalog1790087300000');" \
| grep -Fx 'F5_REAPPLY_OK'
- name: VPS-equivalent isolated API preflight - name: VPS-equivalent isolated API preflight
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
@@ -72,6 +223,9 @@ jobs:
docker run --rm \ docker run --rm \
-v "$PWD/api-v3/test:/app/test:ro" \ -v "$PWD/api-v3/test:/app/test:ro" \
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ -v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
-v "$PWD/docker-compose.yml:/docker-compose.yml:ro" \
-v "$PWD/web-v2:/web-v2:ro" \
-v "$PWD/android-app:/android-app:ro" \
"$image" npm test "$image" npm test
docker image rm "$image" >/dev/null 2>&1 || true docker image rm "$image" >/dev/null 2>&1 || true
- name: Build production images - name: Build production images
@@ -151,11 +151,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun createActForSelectedInventory() { fun createActForSelectedInventory(urgency: String = "NON_URGENT") {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val asset = selectedFieldAsset?.asset val asset = selectedFieldAsset?.asset
if (currentVisit.status != "IN_PROGRESS") { if (currentVisit.status != "IN_PROGRESS") {
error = "La inspección debe estar en curso para crear un Acta." error = "La Inspección debe estar en curso para crear un Acta."
return
}
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Elegí si el Acta es urgente o no urgente."
return return
} }
if (asset == null) { if (asset == null) {
@@ -163,15 +167,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
if (acts.any { it.status == "DRAFT" }) { if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Cerrala o cancelala antes de crear la siguiente." error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
return return
} }
launchBusy { launchBusy {
val created = actsRepository.create(currentVisit.id, asset.id, currentVisit.code) val created = actsRepository.create(
currentVisit.id,
asset.id,
currentVisit.code,
urgency,
)
selectedAct = created selectedAct = created
actClosure = actsRepository.closure(created.id) actClosure = actsRepository.closure(created.id)
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
notice = "${created.code} creada. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta." val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
if (selectedFieldAsset?.capture?.readyForFinding == true) { if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id) loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
} }
@@ -196,7 +206,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy { launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la inspección." notice = "Inventario agregado a la Inspección."
inventory = repository.fieldInventory(visitId, null, null).data inventory = repository.fieldInventory(visitId, null, null).data
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
@@ -222,7 +232,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") { if (visit?.status != "IN_PROGRESS") {
error = "La inspección debe estar en curso para dar de alta Inventario." error = "La Inspección debe estar en curso para dar de alta Inventario."
return return
} }
if (type.familyRequired && familyId == null) { if (type.familyRequired && familyId == null) {
@@ -449,26 +459,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
absenceReason = reason.trim(), absenceReason = reason.trim(),
), ),
) )
notice = "Ausencia del responsable registrada." notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta."
} }
} }
fun prepareSelectedAct() { fun prepareSelectedAct() {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { launchBusy {
actClosure = actsRepository.prepare(actId) actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
notice = "Acta preparada. Su contenido quedó congelado para las firmas." notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
} }
} }
fun reopenSelectedAct() { fun reopenSelectedAct() {
val actId = selectedAct?.id ?: return error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
launchBusy {
actClosure = actsRepository.reopen(actId)
refreshSelectedActInternal(actId)
notice = "Acta reabierta. Podés corregirla antes de volver a preparar."
}
} }
fun signSelectedActAsInspector( fun signSelectedActAsInspector(
@@ -507,13 +512,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun recordCompanyOutcome(status: String, reason: String) { fun recordCompanyOutcome(status: String, reason: String) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
if (status != "REFUSED") {
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
return
}
if (reason.trim().length < 10) { if (reason.trim().length < 10) {
error = "Indicá un motivo de al menos 10 caracteres." error = "Indicá un motivo de al menos 10 caracteres."
return return
} }
launchBusy { launchBusy {
actClosure = actsRepository.companyOutcome(actId, status, reason) actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = if (status == "ABSENT") "Ausencia de empresa asentada." else "Negativa a firmar asentada." notice = "Negativa a firmar asentada."
} }
} }
@@ -521,11 +530,11 @@ 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 { launchBusy {
actClosure = actsRepository.closeAct(actId) actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState() clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} cerrada e inmutable. Podés crear otra Acta o finalizar la inspección." notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
} }
} }
@@ -534,7 +543,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy { launchBusy {
visit = actsRepository.closeVisit(visitId) visit = actsRepository.closeVisit(visitId)
loadVisitsInternal() loadVisitsInternal()
notice = "Inspección cerrada. Las Actas y documentos quedan disponibles para oficina." notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."
} }
} }
@@ -90,7 +90,6 @@ data class PersonSummary(
data class VisitSummary( data class VisitSummary(
val id: String, val id: String,
val code: String, val code: String,
val title: String? = null,
val objective: String? = null, val objective: String? = null,
val status: String, val status: String,
val scopeAsset: AssetSummary? = null, val scopeAsset: AssetSummary? = null,
@@ -153,7 +152,6 @@ data class ChecklistSummary(
data class VisitDetail( data class VisitDetail(
val id: String, val id: String,
val code: String, val code: String,
val title: String? = null,
val objective: String? = null, val objective: String? = null,
val status: String, val status: String,
val operationalArea: AssetSummary? = null, val operationalArea: AssetSummary? = null,
@@ -37,6 +37,15 @@ data class MobileActSummary(
val title: String, val title: String,
val summary: String, val summary: String,
val observations: String? = null, val observations: String? = null,
val urgency: String = "NON_URGENT",
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
val deadlineBaseAt: String? = null,
val deadlineAt: String? = null,
val lockedAt: String? = null,
val lockedSha256: String? = null,
val sealedAt: String? = null,
val currentVersion: Int = 0, val currentVersion: Int = 0,
val closedAt: String? = null, val closedAt: String? = null,
val closureSha256: String? = null, val closureSha256: String? = null,
@@ -53,6 +62,15 @@ data class MobileActDetail(
val title: String, val title: String,
val summary: String, val summary: String,
val observations: String? = null, val observations: String? = null,
val urgency: String = "NON_URGENT",
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
val deadlineBaseAt: String? = null,
val deadlineAt: String? = null,
val lockedAt: String? = null,
val lockedSha256: String? = null,
val sealedAt: String? = null,
val currentVersion: Int = 0, val currentVersion: Int = 0,
val closedAt: String? = null, val closedAt: String? = null,
val closureSha256: String? = null, val closureSha256: String? = null,
@@ -75,6 +93,7 @@ data class MobileActListResponse(
data class CreateMobileActRequest( data class CreateMobileActRequest(
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,
@@ -113,6 +132,15 @@ data class MobileActClosureHeader(
val code: String, val code: String,
val status: String, val status: String,
val visitId: String, val visitId: String,
val urgency: String = "NON_URGENT",
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
val deadlineBaseAt: String? = null,
val deadlineAt: String? = null,
val lockedAt: String? = null,
val lockedSha256: String? = null,
val sealedAt: String? = null,
val currentVersion: Int = 0, val currentVersion: Int = 0,
val closedAt: String? = null, val closedAt: String? = null,
val closureSha256: String? = null, val closureSha256: String? = null,
@@ -169,7 +197,7 @@ data class MobileCompanyOutcomeRequest(
val reason: String, val reason: String,
) )
data class MobileCloseActRequest( data class MobileSealActRequest(
val clientClosedAt: String = Instant.now().toString(), val clientClosedAt: String = Instant.now().toString(),
val uploadMode: String = "ONLINE", val uploadMode: String = "ONLINE",
) )
@@ -219,14 +247,8 @@ private interface MobileActsApi {
@Body request: MobileResponsibleRequest, @Body request: MobileResponsibleRequest,
): MobileActClosure ): MobileActClosure
@POST("inspection-acts/{actId}/ready") @POST("inspection-acts/{actId}/lock")
suspend fun ready( suspend fun lock(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActClosure
@POST("inspection-acts/{actId}/reopen")
suspend fun reopen(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("actId") actId: String, @Path("actId") actId: String,
): MobileActClosure ): MobileActClosure
@@ -268,11 +290,11 @@ private interface MobileActsApi {
@Body request: MobileCompanyOutcomeRequest, @Body request: MobileCompanyOutcomeRequest,
): MobileActClosure ): MobileActClosure
@POST("inspection-acts/{actId}/close") @POST("inspection-acts/{actId}/seal")
suspend fun closeAct( suspend fun sealAct(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("actId") actId: String, @Path("actId") actId: String,
@Body request: MobileCloseActRequest, @Body request: MobileSealActRequest,
): MobileActClosure ): MobileActClosure
@POST("inspection-visits/{visitId}/close") @POST("inspection-visits/{visitId}/close")
@@ -305,14 +327,20 @@ class MobileActsRepository(context: Context) {
api.act("Bearer ${session.accessToken}", actId) api.act("Bearer ${session.accessToken}", actId)
} }
suspend fun create(visitId: String, assetId: String, visitCode: String): MobileActDetail = authorized { session -> suspend fun create(
visitId: String,
assetId: String,
visitCode: String,
urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session ->
api.createAct( api.createAct(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
visitId, visitId,
CreateMobileActRequest( CreateMobileActRequest(
occurredAt = Instant.now().toString(), occurredAt = Instant.now().toString(),
urgency = urgency,
title = "Acta de inspección $visitCode", title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la visita.", summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = listOf(assetId), assetIds = listOf(assetId),
), ),
) )
@@ -335,12 +363,8 @@ class MobileActsRepository(context: Context) {
api.responsible("Bearer ${session.accessToken}", actId, request) api.responsible("Bearer ${session.accessToken}", actId, request)
} }
suspend fun prepare(actId: String): MobileActClosure = authorized { session -> suspend fun lock(actId: String): MobileActClosure = authorized { session ->
api.ready("Bearer ${session.accessToken}", actId) api.lock("Bearer ${session.accessToken}", actId)
}
suspend fun reopen(actId: String): MobileActClosure = authorized { session ->
api.reopen("Bearer ${session.accessToken}", actId)
} }
suspend fun signInspector( suspend fun signInspector(
@@ -378,8 +402,8 @@ class MobileActsRepository(context: Context) {
) )
} }
suspend fun closeAct(actId: String): MobileActClosure = authorized { session -> suspend fun seal(actId: String): MobileActClosure = authorized { session ->
api.closeAct("Bearer ${session.accessToken}", actId, MobileCloseActRequest()) api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
} }
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session -> suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
@@ -60,6 +60,7 @@ fun MobileActsScreen(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
var attendance by rememberSaveable(selected?.id) { var attendance by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
} }
@@ -129,19 +130,20 @@ fun MobileActsScreen(
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Actas de esta inspección", fontWeight = FontWeight.Bold) Text("Actas de esta Inspección", fontWeight = FontWeight.Bold)
if (model.acts.isEmpty()) { if (model.acts.isEmpty()) {
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.") Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
} }
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"
OutlinedButton( OutlinedButton(
onClick = { model.selectAct(act.id) }, onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
(if (active) "" else "") + (if (active) "" else "") +
"${act.code} · ${actStatusLabel(act.status)} · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}", "${act.code} · ${actStatusLabel(act.status)} · $urgency · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}",
) )
} }
} }
@@ -156,12 +158,31 @@ fun MobileActsScreen(
) { ) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Nueva Acta", fontWeight = FontWeight.Bold) Text("Nueva Acta", fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "READY" }) { if (model.acts.any { it.status == "LOCKED" }) {
Text( Text(
"Puede existir una nueva Acta en borrador aunque haya Actas preparadas pendientes de firma de empresa. Sólo se permite un borrador a la vez.", "Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { newActUrgency = "URGENT" },
label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla."
} else {
"El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla."
},
style = MaterialTheme.typography.bodySmall,
)
val selectedInventory = model.selectedFieldAsset?.asset val selectedInventory = model.selectedFieldAsset?.asset
if (selectedInventory == null) { if (selectedInventory == null) {
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.") Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
@@ -171,7 +192,7 @@ fun MobileActsScreen(
} else { } else {
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}") Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
Button( Button(
onClick = { model.createActForSelectedInventory() }, onClick = { model.createActForSelectedInventory(newActUrgency) },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") } ) { Text("Crear nueva Acta") }
@@ -186,9 +207,14 @@ fun MobileActsScreen(
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status)) Text(actStatusLabel(selected.status))
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario") Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
Text(selected.summary, style = MaterialTheme.typography.bodySmall) selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
selected.closureSha256?.let { Text("Hash final: $it", style = MaterialTheme.typography.bodySmall) } if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
}
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
} }
} }
@@ -234,31 +260,28 @@ fun MobileActsScreen(
HorizontalDivider() HorizontalDivider()
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita. Un Acta de verificación puede prepararse sin Hallazgos nuevos si la verificación ya fue registrada.") Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") } Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
HorizontalDivider() HorizontalDivider()
Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al preparar, el contenido se congela y se calcula su hash. El servidor exige al menos un Hallazgo o una verificación registrada.") Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
Button( Button(
onClick = { model.prepareSelectedAct() }, onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null, enabled = !model.busy && closure?.responsible != null,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Preparar Acta para firmas") } ) { Text("Finalizar y BLOQUEAR Acta") }
} }
"READY" -> { "LOCKED" -> {
val signatures = closure?.signatures.orEmpty() val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" } val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" } val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) } Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
if (signatures.isEmpty()) { closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) {
Text("Volver a borrador")
}
}
HorizontalDivider() HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold) Text("Firma del inspector", fontWeight = FontWeight.Bold)
@@ -274,29 +297,21 @@ fun MobileActsScreen(
} }
HorizontalDivider() HorizontalDivider()
Text("Recepción de la empresa", fontWeight = FontWeight.Bold) Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null) { if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) { val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada" "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar" "REFUSED" -> "Negativa a firmar"
"ABSENT" -> "Responsable ausente"
else -> companyOutcome.status else -> companyOutcome.status
} }
Text("$detail", color = MaterialTheme.colorScheme.primary) Text("$detail", color = MaterialTheme.colorScheme.primary)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") { } else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Text("El responsable fue registrado como ausente.") Text(
Button( "El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
onClick = { style = MaterialTheme.typography.bodyMedium,
model.recordCompanyOutcome( )
"ABSENT",
closure.responsible.absenceReason ?: "Responsable de empresa ausente durante la inspección",
)
},
enabled = !model.busy && inspectorSigned,
modifier = Modifier.fillMaxWidth(),
) { Text("Asentar ausencia en el Acta") }
} else { } else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -333,28 +348,28 @@ fun MobileActsScreen(
} }
HorizontalDivider() HorizontalDivider()
if (inspectorSigned && companyOutcome != null) { if (inspectorSigned && companyResolved) {
Button( Button(
onClick = { model.closeSelectedAct() }, onClick = { model.closeSelectedAct() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta definitivamente") } ) { Text("SELLAR Acta definitivamente") }
} else if (inspectorSigned) { } else {
Text( Text(
"La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.", "Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
} }
"CLOSED" -> { "SEALED" -> {
Card( Card(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) { ) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold) Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)
Text("El PDF, informe Word y entregas documentales se generan desde este cierre.") Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")
closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) } closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) }
} }
} }
@@ -366,16 +381,19 @@ fun MobileActsScreen(
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) { if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
HorizontalDivider() HorizontalDivider()
val drafts = model.acts.count { it.status == "DRAFT" } val activeActs = model.acts.filter { it.status != "CANCELLED" }
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) val pendingActs = activeActs.filter { it.status != "SEALED" }
Text( Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
"No puede quedar ninguna Acta en borrador. Al cerrar, el servidor verifica que cada Acta preparada tenga firma de inspector; si falta alguna, indicará cuál debe completarse.", if (pendingActs.isEmpty()) {
) Text("Todas las Actas están SELLADAS. La Inspección ya puede cerrarse.")
} else {
Text("No puede cerrarse todavía. Falta SELLAR: ${pendingActs.joinToString { it.code }}")
}
Button( Button(
onClick = { model.closeInspection() }, onClick = { model.closeInspection() },
enabled = !model.busy && drafts == 0, enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar inspección y salir de la empresa") } ) { Text("Cerrar Inspección y salir de la empresa") }
} }
} }
} }
@@ -397,8 +415,8 @@ private fun F32ActMessage(model: MainViewModel) {
private fun actStatusLabel(status: String): String = when (status) { private fun actStatusLabel(status: String): String = when (status) {
"DRAFT" -> "Borrador" "DRAFT" -> "Borrador"
"READY" -> "Preparada para firmas" "LOCKED" -> "BLOQUEADA"
"CLOSED" -> "Cerrada" "SEALED" -> "SELLADA"
"CANCELLED" -> "Cancelada" "CANCELLED" -> "Cancelada"
else -> status else -> status
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.25.0-1", "version": "0.26.0-1",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -49,7 +49,7 @@ export class ActAdministrationService {
COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_count COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_count
FROM inspection_findings f WHERE f.act_id = ia.id FROM inspection_findings f WHERE f.act_id = ia.id
) fc ON true ) fc ON true
WHERE ia.status IN ('CLOSED', 'RECTIFIED') WHERE ia.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
), classified AS ( ), classified AS (
SELECT *, CASE SELECT *, CASE
WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED' WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED'
@@ -110,7 +110,7 @@ export class ActAdministrationService {
const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]); const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]);
const act = rows[0]; const act = rows[0];
if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' }); if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' });
if (!['CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_CLOSED', message: 'El seguimiento administrativo comienza cuando el Acta está cerrada.' }); if (!['SEALED', 'CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_SEALED', message: 'El seguimiento administrativo comienza cuando el Acta está sellada.' });
return act; return act;
} }
@@ -129,7 +129,7 @@ export class FieldBriefingService {
ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC
LIMIT 1 LIMIT 1
) response ON true ) response ON true
WHERE act.status IN ('CLOSED', 'RECTIFIED') WHERE act.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
AND source_visit.id <> $1 AND source_visit.id <> $1
AND source_visit.operational_area_id = $2::uuid AND source_visit.operational_area_id = $2::uuid
AND source_visit.operator_company_id = $3::uuid AND source_visit.operator_company_id = $3::uuid
-4
View File
@@ -15,8 +15,6 @@ import { HealthController } from './health.controller';
import { HealthService } from './health.service'; import { HealthService } from './health.service';
import { DashboardModule } from './dashboard/dashboard.module'; import { DashboardModule } from './dashboard/dashboard.module';
import { AssetMasterModule } from './asset-master/asset-master.module'; import { AssetMasterModule } from './asset-master/asset-master.module';
import { SurveyPlanningModule } from './survey-planning/survey-planning.module';
import { SurveyExecutionModule } from './survey-execution/survey-execution.module';
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module'; import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
import { InspectionActsModule } from './inspection-acts/inspection-acts.module'; import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module'; import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
@@ -69,8 +67,6 @@ function required(config: ConfigService, key: string): string {
AdministrationModule, AdministrationModule,
DashboardModule, DashboardModule,
AssetMasterModule, AssetMasterModule,
SurveyPlanningModule,
SurveyExecutionModule,
InspectionVisitsModule, InspectionVisitsModule,
InspectionActsModule, InspectionActsModule,
InspectionFindingsModule, InspectionFindingsModule,
+55 -158
View File
@@ -41,14 +41,14 @@ export interface AssetVersionDetail extends AssetVersionSummary {
function assetNotFound(): NotFoundException { function assetNotFound(): NotFoundException {
return new NotFoundException({ return new NotFoundException({
code: 'ASSET_NOT_FOUND', code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado', message: 'Inventario no encontrado',
}); });
} }
function versionNotFound(): NotFoundException { function versionNotFound(): NotFoundException {
return new NotFoundException({ return new NotFoundException({
code: 'ASSET_VERSION_NOT_FOUND', code: 'ASSET_VERSION_NOT_FOUND',
message: 'Versión de activo no encontrada', message: 'Versión de Inventario no encontrada',
}); });
} }
@@ -79,7 +79,7 @@ export class AssetHistoryService {
const versionNumber = Number(versionRow.current_version); const versionNumber = Number(versionRow.current_version);
if (!Number.isInteger(versionNumber) || versionNumber < 1) { if (!Number.isInteger(versionNumber) || versionNumber < 1) {
throw new Error(`Versión de activo inválida después de incrementar: ${versionRow.current_version}`); throw new Error(`Versión de Inventario inválida después de incrementar: ${versionRow.current_version}`);
} }
const snapshot = await this.loadCurrentSnapshot(manager, assetId); const snapshot = await this.loadCurrentSnapshot(manager, assetId);
@@ -101,17 +101,8 @@ export class AssetHistoryService {
asset_id, version_number, change_type, changed_fields, snapshot, asset_id, version_number, change_type, changed_fields, snapshot,
actor_user_id, actor_username, source, request_id actor_user_id, actor_username, source, request_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[ [assetId, versionNumber, changeType, changedFields, snapshot,
assetId, principal.userId, principal.username, source, request.requestId],
versionNumber,
changeType,
changedFields,
snapshot,
principal.userId,
principal.username,
source,
request.requestId,
],
); );
return versionNumber; return versionNumber;
} }
@@ -123,38 +114,21 @@ export class AssetHistoryService {
parameters.push(value); parameters.push(value);
return `$${parameters.length}`; return `$${parameters.length}`;
}; };
if (query.search?.trim()) { if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`); const search = add(`%${query.search.trim()}%`);
conditions.push(`( conditions.push(`(version.snapshot->>'code' ILIKE ${search} OR version.snapshot->>'name' ILIKE ${search} OR version.actor_username ILIKE ${search})`);
version.snapshot->>'code' ILIKE ${search}
OR version.snapshot->>'name' ILIKE ${search}
OR version.actor_username ILIKE ${search}
)`);
}
if (query.typeId) {
conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
}
if (query.status) {
conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
}
if (query.changeType) {
conditions.push(`version.change_type = ${add(query.changeType)}`);
} }
if (query.typeId) conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
if (query.status) conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
if (query.changeType) conditions.push(`version.change_type = ${add(query.changeType)}`);
if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`); if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`);
if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`); if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`);
return this.listWithConditions(query.page, query.pageSize, conditions, parameters); return this.listWithConditions(query.page, query.pageSize, conditions, parameters);
} }
async listForAsset(assetId: string, query: AssetVersionPageQueryDto) { async listForAsset(assetId: string, query: AssetVersionPageQueryDto) {
await this.requireAsset(assetId); await this.requireAsset(assetId);
return this.listWithConditions( return this.listWithConditions(query.page, query.pageSize, ['version.asset_id = $1'], [assetId]);
query.page,
query.pageSize,
['version.asset_id = $1'],
[assetId],
);
} }
async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> { async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> {
@@ -170,17 +144,9 @@ export class AssetHistoryService {
return row; return row;
} }
private async listWithConditions( private async listWithConditions(page: number, pageSize: number, conditions: string[], parameters: unknown[]) {
page: number,
pageSize: number,
conditions: string[],
parameters: unknown[],
) {
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [countRow] = (await this.dataSource.query( const [countRow] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`, parameters)) as Array<{ total: number }>;
`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0); const total = Number(countRow?.total ?? 0);
const paginated = [...parameters, pageSize, (page - 1) * pageSize]; const paginated = [...parameters, pageSize, (page - 1) * pageSize];
const limit = `$${parameters.length + 1}`; const limit = `$${parameters.length + 1}`;
@@ -194,51 +160,29 @@ export class AssetHistoryService {
LIMIT ${limit} OFFSET ${offset}`, LIMIT ${limit} OFFSET ${offset}`,
paginated, paginated,
)) as AssetVersionSummary[]; )) as AssetVersionSummary[];
return { data, meta: { page, pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) } };
return {
data,
meta: {
page,
pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / pageSize),
},
};
} }
private selectSummary(): string { private selectSummary(): string {
return `SELECT return `SELECT
version.id, version.id, version.asset_id AS "assetId",
version.asset_id AS "assetId", version.snapshot->>'code' AS "assetCode", version.snapshot->>'name' AS "assetName",
version.snapshot->>'code' AS "assetCode", version.snapshot #>> '{type,id}' AS "typeId", version.snapshot #>> '{type,name}' AS "typeName",
version.snapshot->>'name' AS "assetName",
version.snapshot #>> '{type,id}' AS "typeId",
version.snapshot #>> '{type,name}' AS "typeName",
version.snapshot->>'informationStatus' AS "informationStatus", version.snapshot->>'informationStatus' AS "informationStatus",
version.snapshot->>'operationalStatus' AS "operationalStatus", version.snapshot->>'operationalStatus' AS "operationalStatus",
version.version_number AS "versionNumber", version.version_number AS "versionNumber", version.change_type AS "changeType",
version.change_type AS "changeType", version.changed_fields AS "changedFields", version.occurred_at AS "occurredAt",
version.changed_fields AS "changedFields", version.actor_user_id AS "actorUserId", version.actor_username AS "actorUsername",
version.occurred_at AS "occurredAt", version.source, version.request_id AS "requestId",
version.actor_user_id AS "actorUserId",
version.actor_username AS "actorUsername",
version.source,
version.request_id AS "requestId",
(version.version_number = current_asset.current_version) AS "isCurrent"`; (version.version_number = current_asset.current_version) AS "isCurrent"`;
} }
private async requireAsset(assetId: string): Promise<void> { private async requireAsset(assetId: string): Promise<void> {
const [row] = (await this.dataSource.query( const [row] = (await this.dataSource.query('SELECT 1 FROM assets WHERE id = $1', [assetId])) as unknown[];
'SELECT 1 FROM assets WHERE id = $1',
[assetId],
)) as unknown[];
if (!row) throw assetNotFound(); if (!row) throw assetNotFound();
} }
private async loadCurrentSnapshot( private async loadCurrentSnapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
manager: EntityManager,
assetId: string,
): Promise<Record<string, unknown>> {
const [row] = (await manager.query( const [row] = (await manager.query(
`SELECT JSONB_BUILD_OBJECT( `SELECT JSONB_BUILD_OBJECT(
'id', asset.id, 'id', asset.id,
@@ -246,82 +190,49 @@ export class AssetHistoryService {
'name', asset.name, 'name', asset.name,
'commonName', asset.common_name, 'commonName', asset.common_name,
'description', asset.description, 'description', asset.description,
'type', JSONB_BUILD_OBJECT( 'type', JSONB_BUILD_OBJECT('id', asset_type.id,'code', asset_type.code,'name', asset_type.name,'operationalRole', asset_type.operational_role),
'id', asset_type.id, 'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', parent.id,'code', parent.code,'name', parent.name) END,
'code', asset_type.code, 'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operational_area.id,'code', operational_area.code,'name', operational_area.name) END,
'name', asset_type.name, 'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operator_company.id,'code', operator_company.code,'name', operator_company.name) END,
'operationalRole', asset_type.operational_role
),
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', parent.id,
'code', parent.code,
'name', parent.name
) END,
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', operational_area.id,
'code', operational_area.code,
'name', operational_area.name
) END,
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', operator_company.id,
'code', operator_company.code,
'name', operator_company.name
) END,
'informationStatus', asset.information_status, 'informationStatus', asset.information_status,
'operationalStatus', asset.operational_status, 'operationalStatus', asset.operational_status,
'currentFunction', (
SELECT JSONB_BUILD_OBJECT(
'assignmentId', assignment.id,
'id', fn.id,
'code', fn.code,
'name', fn.name,
'validFrom', assignment.valid_from,
'reason', assignment.change_reason
)
FROM inventory_function_assignments assignment
JOIN inventory_functions fn ON fn.id=assignment.function_id
WHERE assignment.asset_id=asset.id AND assignment.valid_until IS NULL
ORDER BY assignment.valid_from DESC LIMIT 1
),
'attributes', COALESCE(( 'attributes', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT( SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'definitionId', definition.id, 'definitionId', definition.id,'code', definition.code,'name', definition.name,
'code', definition.code, 'dataType', definition.data_type,'isRequired', definition.is_required,
'name', definition.name, 'unit', definition.unit,'options', definition.options,'sortOrder', definition.sort_order,'value', value.value
'dataType', definition.data_type,
'isRequired', definition.is_required,
'unit', definition.unit,
'options', definition.options,
'sortOrder', definition.sort_order,
'value', value.value
) ORDER BY definition.sort_order, definition.name) ) ORDER BY definition.sort_order, definition.name)
FROM asset_attribute_definitions definition FROM asset_attribute_definitions definition
LEFT JOIN asset_attribute_values value LEFT JOIN asset_attribute_values value ON value.definition_id = definition.id AND value.asset_id = asset.id
ON value.definition_id = definition.id WHERE definition.asset_type_id = asset.asset_type_id AND definition.is_active = true
AND value.asset_id = asset.id
WHERE definition.asset_type_id = asset.asset_type_id
AND definition.is_active = true
), '[]'::jsonb), ), '[]'::jsonb),
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'assetId', geometry.asset_id, 'assetId', geometry.asset_id,'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,'geometryType', geometry.geometry_type,
'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb, 'source', geometry.source,'accuracyM', geometry.accuracy_m::double precision,'capturedAt', geometry.captured_at,
'geometryType', geometry.geometry_type, 'deviceLabel', geometry.device_label,'createdAt', geometry.created_at,'updatedAt', geometry.updated_at,'updatedBy', geometry.updated_by
'source', geometry.source,
'accuracyM', geometry.accuracy_m::double precision,
'capturedAt', geometry.captured_at,
'deviceLabel', geometry.device_label,
'createdAt', geometry.created_at,
'updatedAt', geometry.updated_at,
'updatedBy', geometry.updated_by
) END, ) END,
'media', COALESCE(( 'media', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT( SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', media.id, 'id', media.id,'kind', media.kind,'originalName', media.original_name,'mimeType', media.mime_type,
'kind', media.kind, 'sizeBytes', media.size_bytes,'sha256', media.sha256,'title', media.title,'description', media.description,
'originalName', media.original_name, 'capturedAt', media.captured_at,'latitude', media.latitude,'longitude', media.longitude,'accuracyM', media.accuracy_m,
'mimeType', media.mime_type, 'source', media.source,'uploadedBy', media.uploaded_by,'createdAt', media.created_at,'updatedAt', media.updated_at
'sizeBytes', media.size_bytes,
'sha256', media.sha256,
'title', media.title,
'description', media.description,
'capturedAt', media.captured_at,
'latitude', media.latitude,
'longitude', media.longitude,
'accuracyM', media.accuracy_m,
'source', media.source,
'uploadedBy', media.uploaded_by,
'createdAt', media.created_at,
'updatedAt', media.updated_at
) ORDER BY media.created_at, media.id) ) ORDER BY media.created_at, media.id)
FROM asset_media media FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL
WHERE media.asset_id = asset.id
AND media.deleted_at IS NULL
), '[]'::jsonb), ), '[]'::jsonb),
'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id), 'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id),
'organizationMemberships', COALESCE(( 'organizationMemberships', COALESCE((
@@ -337,22 +248,8 @@ export class AssetHistoryService {
'legalRights', COALESCE(( 'legalRights', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id
),'[]'::jsonb), ),'[]'::jsonb),
'provenance', JSONB_BUILD_OBJECT( 'provenance', JSONB_BUILD_OBJECT('origin', asset.data_origin,'sourceName', asset.source_name,'sourceReference', asset.source_reference,'observedAt', asset.source_observed_at,'notes', asset.source_notes,'verifiedAt', asset.provenance_verified_at,'verifiedBy', asset.provenance_verified_by,'updatedAt', asset.provenance_updated_at,'updatedBy', asset.provenance_updated_by),
'origin', asset.data_origin, 'createdAt', asset.created_at,'updatedAt', asset.updated_at,'createdBy', asset.created_by,'updatedBy', asset.updated_by,'currentVersion', asset.current_version
'sourceName', asset.source_name,
'sourceReference', asset.source_reference,
'observedAt', asset.source_observed_at,
'notes', asset.source_notes,
'verifiedAt', asset.provenance_verified_at,
'verifiedBy', asset.provenance_verified_by,
'updatedAt', asset.provenance_updated_at,
'updatedBy', asset.provenance_updated_by
),
'createdAt', asset.created_at,
'updatedAt', asset.updated_at,
'createdBy', asset.created_by,
'updatedBy', asset.updated_by,
'currentVersion', asset.current_version
) AS snapshot ) AS snapshot
FROM assets asset FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
@@ -29,6 +29,8 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller'; import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service'; import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service';
@Module({ @Module({
imports: [AuditModule], imports: [AuditModule],
@@ -37,6 +39,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
AssetsController, AssetsController,
InventoryStructureController, InventoryStructureController,
InventoryFamilyCatalogController, InventoryFamilyCatalogController,
InventoryBrowserController,
InventoryMergeController, InventoryMergeController,
FieldInventoryMergeController, FieldInventoryMergeController,
AssetGeometriesController, AssetGeometriesController,
@@ -53,6 +56,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
AssetsService, AssetsService,
InventoryStructureService, InventoryStructureService,
InventoryFamilyCatalogService, InventoryFamilyCatalogService,
InventoryBrowserService,
InventoryMergeService, InventoryMergeService,
MergedInventoryDossierService, MergedInventoryDossierService,
AssetGeometriesService, AssetGeometriesService,
@@ -79,23 +79,15 @@ export class AssetOperationalRelationsService {
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> { async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA); await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
const data = (await this.dataSource.query(` const data = (await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName" SELECT DISTINCT company.id, company.code, company.name,
FROM ( company.common_name AS "commonName", company_type.name AS "typeName"
SELECT relation.company_id FROM area_company_relations relation
FROM area_company_relations relation INNER JOIN assets company ON company.id = relation.company_id
WHERE relation.area_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
UNION
SELECT asset.operator_company_id AS company_id
FROM assets asset
WHERE asset.operational_area_id = $1
AND asset.operator_company_id IS NOT NULL
AND asset.information_status <> 'INACTIVE'
) linked
INNER JOIN assets company ON company.id = linked.company_id
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
WHERE company.information_status <> 'INACTIVE' WHERE relation.area_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
AND company.information_status <> 'INACTIVE'
ORDER BY company.name, company.code ORDER BY company.name, company.code
`, [areaId])) as OperationalAssetSummary[]; `, [areaId])) as OperationalAssetSummary[];
return { data }; return { data };
@@ -104,23 +96,15 @@ export class AssetOperationalRelationsService {
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> { async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY); await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
const data = (await this.dataSource.query(` const data = (await this.dataSource.query(`
SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName" SELECT DISTINCT area.id, area.code, area.name,
FROM ( area.common_name AS "commonName", area_type.name AS "typeName"
SELECT relation.area_id FROM area_company_relations relation
FROM area_company_relations relation INNER JOIN assets area ON area.id = relation.area_id
WHERE relation.company_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
UNION
SELECT asset.operational_area_id AS area_id
FROM assets asset
WHERE asset.operator_company_id = $1
AND asset.operational_area_id IS NOT NULL
AND asset.information_status <> 'INACTIVE'
) linked
INNER JOIN assets area ON area.id = linked.area_id
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
WHERE area.information_status <> 'INACTIVE' WHERE relation.company_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
AND area.information_status <> 'INACTIVE'
ORDER BY area.name, area.code ORDER BY area.name, area.code
`, [companyId])) as OperationalAssetSummary[]; `, [companyId])) as OperationalAssetSummary[];
return { data }; return { data };
@@ -157,12 +141,49 @@ export class AssetOperationalRelationsService {
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]); const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' }); if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
} }
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
const [currentOperator] = (await manager.query(`
SELECT relation.id,company.name AS "companyName"
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND relation.company_id<>$2
ORDER BY relation.valid_from DESC
LIMIT 1
FOR UPDATE OF relation
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
if (currentOperator) {
throw new ConflictException({
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
});
}
}
const [row] = (await manager.query(` const [row] = (await manager.query(`
INSERT INTO area_company_relations ( INSERT INTO area_company_relations (
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>; `, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// Compatibility snapshot only. This does not move or re-parent Inventory.
// area_company_relations remains the temporal source of truth.
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
await manager.query(`
UPDATE assets asset
SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid
FROM asset_types type
WHERE type.id=asset.asset_type_id
AND type.operational_role='GENERIC'
AND asset.operational_area_id=$1::uuid
AND asset.operator_company_id IS DISTINCT FROM $2::uuid
`,[dto.areaId,dto.companyId,principal.userId]);
}
const created = await this.loadRelation(manager, row.id); const created = await this.loadRelation(manager, row.id);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -170,6 +191,9 @@ export class AssetOperationalRelationsService {
entityType: 'area_company_relation', entityType: 'area_company_relation',
entityId: row.id, entityId: row.id,
afterData: this.auditView(created), afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
: undefined,
}, manager); }, manager);
return created; return created;
}); });
@@ -198,12 +222,9 @@ export class AssetOperationalRelationsService {
message: 'La relación ya se encuentra finalizada', message: 'La relación ya se encuentra finalizada',
}); });
} }
if (before.assignedAssetCount > 0) {
throw new ConflictException({ // F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
code: 'AREA_COMPANY_RELATION_IN_USE', // Ending an operator relation must never be blocked by existing Inventory.
message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`,
});
}
await manager.query(` await manager.query(`
UPDATE area_company_relations UPDATE area_company_relations
SET valid_until = CURRENT_TIMESTAMP, SET valid_until = CURRENT_TIMESTAMP,
@@ -220,6 +241,10 @@ export class AssetOperationalRelationsService {
entityId: id, entityId: id,
beforeData: this.auditView(before), beforeData: this.auditView(before),
afterData: this.auditView(updated), afterData: this.auditView(updated),
metadata: {
inventoryHierarchyChanged:false,
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
},
}, manager); }, manager);
return updated; return updated;
}); });
@@ -330,7 +355,8 @@ export class AssetOperationalRelationsService {
(relation.valid_until IS NULL) AS active, (relation.valid_until IS NULL) AS active,
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
WHERE asset.operational_area_id = relation.area_id WHERE asset.operational_area_id = relation.area_id
AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount" AND asset.operator_company_id = relation.company_id
AND asset.is_inventory_instance=true) ELSE 0 END AS "assignedAssetCount"
FROM area_company_relations relation FROM area_company_relations relation
INNER JOIN assets area ON area.id = relation.area_id INNER JOIN assets area ON area.id = relation.area_id
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
+6 -6
View File
@@ -298,7 +298,7 @@ export class AssetsService {
const [visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions] = await Promise.all([ const [visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions] = await Promise.all([
this.dataSource.query( this.dataSource.query(
`SELECT DISTINCT visit.id, visit.code, visit.title, visit.status, `SELECT DISTINCT visit.id, visit.code, visit.status,
visit.planned_start_at AS "plannedStartAt", visit.planned_start_at AS "plannedStartAt",
visit.actual_started_at AS "actualStartedAt", visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt", visit.actual_closed_at AS "actualClosedAt",
@@ -322,7 +322,7 @@ export class AssetsService {
`SELECT DISTINCT act.id, act.visit_id AS "visitId", act.code, act.status, `SELECT DISTINCT act.id, act.visit_id AS "visitId", act.code, act.status,
act.occurred_at AS "occurredAt", act.title, act.summary, act.occurred_at AS "occurredAt", act.title, act.summary,
act.closed_at AS "closedAt", act.current_version AS "currentVersion", act.closed_at AS "closedAt", act.current_version AS "currentVersion",
visit.code AS "visitCode", visit.title AS "visitTitle" visit.code AS "visitCode"
FROM inspection_acts act FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id JOIN inspection_visits visit ON visit.id = act.visit_id
LEFT JOIN inspection_act_assets act_asset LEFT JOIN inspection_act_assets act_asset
@@ -342,7 +342,7 @@ export class AssetsService {
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes", finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt", finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
act.code AS "actCode", act.occurred_at AS "actOccurredAt", act.code AS "actCode", act.occurred_at AS "actOccurredAt",
visit.id AS "visitId", visit.code AS "visitCode", visit.title AS "visitTitle" visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_findings finding FROM inspection_findings finding
JOIN inspection_acts act ON act.id = finding.act_id JOIN inspection_acts act ON act.id = finding.act_id
JOIN inspection_visits visit ON visit.id = act.visit_id JOIN inspection_visits visit ON visit.id = act.visit_id
@@ -389,7 +389,7 @@ export class AssetsService {
verification_link.result_recorded_at AS "resultRecordedAt", verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn", verification_link.rescheduled_control_on AS "rescheduledControlOn",
finding.code AS "findingCode", finding.title AS "findingTitle", finding.code AS "findingCode", finding.title AS "findingTitle",
visit.code AS "visitCode", visit.title AS "visitTitle", visit.status AS "visitStatus", visit.code AS "visitCode", visit.status AS "visitStatus",
(SELECT COUNT(*)::integer (SELECT COUNT(*)::integer
FROM inspection_finding_evidence verification_evidence FROM inspection_finding_evidence verification_evidence
WHERE verification_evidence.finding_id = finding.id WHERE verification_evidence.finding_id = finding.id
@@ -484,7 +484,7 @@ export class AssetsService {
kind: 'INSPECTION', kind: 'INSPECTION',
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt, occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
title: `Inspección ${String(visit.code)}`, title: `Inspección ${String(visit.code)}`,
description: visit.title, description: null,
href: `/inspecciones/${String(visit.id)}`, href: `/inspecciones/${String(visit.id)}`,
meta: { status: visit.status }, meta: { status: visit.status },
})); }));
@@ -867,7 +867,7 @@ export class AssetsService {
private fieldDiscoverySelect(): string { private fieldDiscoverySelect(): string {
return `SELECT discovery.id, discovery.status, discovery.discovery_notes AS "discoveryNotes", discovery.observed_at AS "observedAt", discovery.reviewed_at AS "reviewedAt", discovery.review_notes AS "reviewNotes", discovery.created_at AS "createdAt", return `SELECT discovery.id, discovery.status, discovery.discovery_notes AS "discoveryNotes", discovery.observed_at AS "observedAt", discovery.reviewed_at AS "reviewedAt", discovery.review_notes AS "reviewNotes", discovery.created_at AS "createdAt",
JSONB_BUILD_OBJECT('id',asset.id,'code',asset.code,'name',asset.name,'commonName',asset.common_name,'informationStatus',asset.information_status,'typeId',asset.asset_type_id,'typeName',asset_type.name,'parentId',asset.parent_id,'operationalAreaId',asset.operational_area_id,'operatorCompanyId',asset.operator_company_id) AS asset, JSONB_BUILD_OBJECT('id',asset.id,'code',asset.code,'name',asset.name,'commonName',asset.common_name,'informationStatus',asset.information_status,'typeId',asset.asset_type_id,'typeName',asset_type.name,'parentId',asset.parent_id,'operationalAreaId',asset.operational_area_id,'operatorCompanyId',asset.operator_company_id) AS asset,
JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'title',visit.title,'status',visit.status) AS visit, JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'status',visit.status) AS visit,
JSONB_BUILD_OBJECT('id',creator.id,'username',creator.username,'firstName',creator.first_name,'lastName',creator.last_name) AS creator, JSONB_BUILD_OBJECT('id',creator.id,'username',creator.username,'firstName',creator.first_name,'lastName',creator.last_name) AS creator,
CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',reviewer.id,'username',reviewer.username,'firstName',reviewer.first_name,'lastName',reviewer.last_name) END AS reviewer, CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',reviewer.id,'username',reviewer.username,'firstName',reviewer.first_name,'lastName',reviewer.last_name) END AS reviewer,
CASE WHEN matched.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',matched.id,'code',matched.code,'name',matched.name,'commonName',matched.common_name) END AS "matchedAsset" CASE WHEN matched.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',matched.id,'code',matched.code,'name',matched.name,'commonName',matched.common_name) END AS "matchedAsset"
@@ -10,6 +10,7 @@ import {
} from 'class-validator'; } from 'class-validator';
export const INVENTORY_STRUCTURE_KINDS = [ export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA',
'AREA', 'AREA',
'YACIMIENTO', 'YACIMIENTO',
'INSTALACION', 'INSTALACION',
@@ -0,0 +1,63 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class InventoryBrowserQueryDto {
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
needsValidation?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
}
@@ -0,0 +1,71 @@
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateInventoryFamilyDto {
@IsIn(['INSTALLATION', 'SUBINSTALLATION'])
level!: 'INSTALLATION' | 'SUBINSTALLATION';
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(240)
name!: string;
@IsOptional()
@IsUUID('4')
parentFamilyId?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsString({ each: true })
@MaxLength(160, { each: true })
informationLabels?: string[];
}
export class UpdateInventoryFamilyDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(240)
name?: string;
@IsOptional()
@IsUUID('4')
parentFamilyId?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsString({ each: true })
@MaxLength(160, { each: true })
informationLabels?: string[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class ReplaceInventoryFamilyFindingsDto {
@IsArray()
@ArrayMaxSize(2000)
@IsUUID('4', { each: true })
itemIds!: string[];
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(5)
@MaxLength(2000)
reason!: string;
}
@@ -0,0 +1,79 @@
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsISO8601,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export class CreateInventoryFunctionDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') : value)
@IsString()
@MinLength(2)
@MaxLength(120)
code!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(240)
name!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsInt()
@Min(0)
@Max(100000)
sortOrder?: number;
}
export class UpdateInventoryFunctionDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(240)
name?: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsInt()
@Min(0)
@Max(100000)
sortOrder?: number;
}
export class ChangeInventoryFunctionDto {
@IsUUID('4')
functionId!: string;
@IsOptional()
@IsISO8601({ strict: true })
effectiveAt?: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
reason?: string | null;
}
@@ -37,4 +37,9 @@ export class ListAssetTreeQueryDto {
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean() @IsBoolean()
hasGeometry?: boolean; hasGeometry?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
inventoryOnly?: boolean;
} }
@@ -39,7 +39,6 @@ export class ListAssetsQueryDto {
@IsEnum(AssetInformationStatus) @IsEnum(AssetInformationStatus)
status?: AssetInformationStatus; status?: AssetInformationStatus;
@IsOptional() @IsOptional()
@IsEnum(AssetOperationalStatus) @IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus; operationalStatus?: AssetOperationalStatus;
@@ -54,6 +53,11 @@ export class ListAssetsQueryDto {
@IsBoolean() @IsBoolean()
hasGeometry?: boolean; hasGeometry?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
inventoryOnly?: boolean;
@IsOptional() @IsOptional()
@IsUUID('4') @IsUUID('4')
parentId?: string; parentId?: string;
@@ -0,0 +1,28 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
import { InventoryBrowserService } from './inventory-browser.service';
@Controller('inventory-browser')
@RequirePermissions('assets.read')
export class InventoryBrowserController {
constructor(private readonly inventoryBrowser: InventoryBrowserService) {}
@Get('items')
items(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.items(query);
}
@Get('areas')
areas(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.areas(query);
}
@Get(':parentId/children')
children(
@Param('parentId', new ParseUUIDPipe({ version: '4' })) parentId: string,
@Query() query: InventoryBrowserQueryDto,
) {
return this.inventoryBrowser.children(parentId, query);
}
}
@@ -0,0 +1,283 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
type ParentContext = {
id: string;
code: string;
name: string;
typeCode: string;
};
@Injectable()
export class InventoryBrowserService {
constructor(private readonly dataSource: DataSource) {}
async items(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
'asset.is_inventory_instance=true',
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
];
const add = (value: unknown): string => {
params.push(value);
return `$${params.length}`;
};
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
}
if (query.typeId) conditions.push(`asset.asset_type_id=${add(query.typeId)}::uuid`);
if (query.status) conditions.push(`asset.information_status=${add(query.status)}::asset_information_status`);
if (query.operationalStatus) conditions.push(`asset.operational_status=${add(query.operationalStatus)}::asset_operational_status`);
if (query.needsValidation === true) conditions.push("asset.information_status NOT IN ('VALIDATED','INACTIVE')");
if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'");
if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.operationalAreaId) conditions.push(`asset.operational_area_id=${add(query.operationalAreaId)}::uuid`);
if (query.operatorCompanyId) {
const company = add(query.operatorCompanyId);
conditions.push(`EXISTS (
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=asset.operational_area_id
AND relation.company_id=${company}::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
)`);
}
const where = conditions.join(' AND ');
const [countRow] = (await this.dataSource.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE ${where}
`,params)) as Array<{total:number}>;
const total=Number(countRow?.total ?? 0);
const offset=(query.page-1)*query.pageSize;
params.push(query.pageSize);
const limit=`$${params.length}`;
params.push(offset);
const offsetParam=`$${params.length}`;
const data=await this.dataSource.query(`
SELECT
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',parent.id,'code',parent.code,'name',parent.name
) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',area.id,'code',area.code,'name',area.name
) END AS "operationalArea",
(
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=asset.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC
LIMIT 1
) AS "operatorCompany",
asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus",
0::integer AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType",
(SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount",
asset.data_origin AS "dataOrigin",
(asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified",
asset.current_version AS "currentVersion",
asset.updated_at AS "updatedAt"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN LATERAL (
SELECT ST_GeometryType(geometry.geometry)::text AS type
FROM asset_geometries geometry
WHERE geometry.asset_id=asset.id
ORDER BY geometry.updated_at DESC
LIMIT 1
) geometry_type ON true
WHERE ${where}
ORDER BY asset.name,asset.code
LIMIT ${limit} OFFSET ${offsetParam}
`,params);
return {
data,
meta:{
page:query.page,
pageSize:query.pageSize,
total,
totalPages:total===0 ? 0 : Math.ceil(total/query.pageSize),
},
};
}
async areas(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"type.operational_role='AREA'",
"area.information_status<>'INACTIVE'",
'type.is_active=true',
];
const add = (value: unknown): string => {
params.push(value);
return `$${params.length}`;
};
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`);
}
if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`);
if (query.operatorCompanyId) {
const p = add(query.operatorCompanyId);
conditions.push(`EXISTS (
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=area.id
AND relation.company_id=${p}::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
)`);
}
const data = await this.dataSource.query(`
SELECT
area.id,area.code,area.name,area.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
area.information_status AS "informationStatus",
area.operational_status AS "operationalStatus",
(
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=area.id
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC
LIMIT 1
) AS "currentOperator",
(
SELECT COUNT(*)::integer
FROM assets yacimiento
JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=area.id
AND lower(ytype.code)='yacimiento'
AND yacimiento.information_status<>'INACTIVE'
) AS "yacimientoCount",
(
SELECT COUNT(*)::integer
FROM assets inventory
WHERE inventory.is_inventory_instance=true
AND inventory.information_status<>'INACTIVE'
AND inventory.operational_area_id=area.id
) AS "inventoryCount"
FROM assets area
JOIN asset_types type ON type.id=area.asset_type_id
WHERE ${conditions.join(' AND ')}
ORDER BY area.name,area.code
`, params);
return { data, meta: { count: data.length } };
}
async children(parentId: string, query: InventoryBrowserQueryDto) {
const parent = await this.parent(parentId);
const allowedChildType = this.allowedChildType(parent.typeCode);
if (!allowedChildType) return { parent, data: [], meta: { count: 0, hasMore: false } };
const params: unknown[] = [parentId, allowedChildType];
const conditions = [
'asset.parent_id=$1::uuid',
'lower(type.code)=lower($2)',
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
];
if (allowedChildType === 'instalacion' || allowedChildType === 'subinstalacion') {
conditions.push('asset.is_inventory_instance=true');
}
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
const p = `$${params.length}`;
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
}
params.push(201);
const limit = `$${params.length}`;
const rows = await this.dataSource.query(`
SELECT
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',parent_asset.id,'code',parent_asset.code,'name',parent_asset.name
) END AS parent,
asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus",
asset.is_inventory_instance AS "isInventoryInstance",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level
) END AS "inventoryFamily",
(
SELECT COUNT(*)::integer
FROM assets child
WHERE child.parent_id=asset.id
AND child.information_status<>'INACTIVE'
AND (
lower(type.code)='area'
OR child.is_inventory_instance=true
OR EXISTS (
SELECT 1 FROM asset_types child_type
WHERE child_type.id=child.asset_type_id AND lower(child_type.code)='yacimiento'
)
)
) AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
asset.updated_at AS "updatedAt"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent_asset ON parent_asset.id=asset.parent_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE ${conditions.join(' AND ')}
ORDER BY asset.name,asset.code
LIMIT ${limit}
`, params);
const hasMore = rows.length > 200;
const data = hasMore ? rows.slice(0,200) : rows;
return { parent, data, meta: { count: data.length, hasMore } };
}
private async parent(parentId: string): Promise<ParentContext> {
const [parent] = await this.dataSource.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
`,[parentId]) as ParentContext[];
if (!parent) {
throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' });
}
if (!['area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) {
throw new BadRequestException({
code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID',
message:'La navegación de Inventarios admite Área → Yacimiento → Instalación → Subinstalación',
});
}
return parent;
}
private allowedChildType(typeCode: string): string | null {
switch (typeCode.toLowerCase()) {
case 'area': return 'yacimiento';
case 'yacimiento': return 'instalacion';
case 'instalacion': return 'subinstalacion';
default: return null;
}
}
}
@@ -1,11 +1,66 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common'; import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Req,
} from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
CreateInventoryFamilyDto,
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
@Controller('inventory-families') @Controller('inventory-families')
export class InventoryFamilyCatalogController { export class InventoryFamilyCatalogController {
constructor(private readonly families: InventoryFamilyCatalogService) {} constructor(private readonly families: InventoryFamilyCatalogService) {}
@Get('admin')
@RequirePermissions('asset_types.read')
admin() {
return this.families.listAdmin();
}
@Post()
@RequirePermissions('asset_types.manage')
create(
@Body() dto: CreateInventoryFamilyDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.families.create(dto,principal,request);
}
@Patch(':familyId')
@RequirePermissions('asset_types.manage')
update(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:UpdateInventoryFamilyDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.update(familyId,dto,principal,request);
}
@Put(':familyId/findings')
@RequirePermissions('finding_catalog.manage')
replaceFindings(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:ReplaceInventoryFamilyFindingsDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.replaceFindings(familyId,dto,principal,request);
}
@Get(':familyId/findings') @Get(':familyId/findings')
@RequirePermissions('assets.read') @RequirePermissions('assets.read')
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) { findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
@@ -1,28 +1,71 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { randomUUID } from 'node:crypto';
import { DataSource } from 'typeorm'; import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import type {
CreateInventoryFamilyDto,
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
sourceReference: string | null;
isActive: boolean;
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
assetCount: number;
findingCount: number;
findingItemIds: string[];
};
@Injectable() @Injectable()
export class InventoryFamilyCatalogService { export class InventoryFamilyCatalogService {
constructor(private readonly dataSource: DataSource) {} constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listAdmin() {
const data = await this.dataSource.query(`
SELECT
family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",
family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((
SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY item.title,item.id)
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
WHERE mapping.inventory_family_id=family.id
),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
ORDER BY family.level,family.is_active DESC,
COALESCE(parent.name,''),family.name,family.code
`) as FamilyRow[];
return { data };
}
async findings(familyId: string) { async findings(familyId: string) {
const [family] = await this.dataSource.query(` const family = await this.family(familyId, false);
SELECT id,code,name,level,information_labels AS "informationLabels"
FROM inventory_families
WHERE id=$1::uuid AND is_active=true
`, [familyId]) as Array<{
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
}>;
if (!family) {
throw new NotFoundException({
code: 'INVENTORY_FAMILY_NOT_FOUND',
message: 'La familia técnica no existe',
});
}
const items = await this.dataSource.query(` const items = await this.dataSource.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title, SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity", item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
@@ -36,4 +79,221 @@ export class InventoryFamilyCatalogService {
`, [familyId]); `, [familyId]);
return { family, items, count: items.length }; return { family, items, count: items.length };
} }
async create(
dto: CreateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null);
const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`;
const [inserted] = (await manager.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F5',true)
RETURNING id
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[inserted.id,parentId]);
}
const created = await this.family(inserted.id,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: inserted.id,
afterData: created as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' },
},manager);
return created;
});
}
async update(
familyId: string,
dto: UpdateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const before = await this.family(familyId,false,manager,true);
const nextParentId = dto.parentFamilyId === undefined
? before.parentFamilyId
: dto.parentFamilyId;
const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId);
await manager.query(`
UPDATE inventory_families SET
name=COALESCE($2::varchar,name),
information_labels=COALESCE($3::jsonb,information_labels),
is_active=COALESCE($4::boolean,is_active),
updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`,[
familyId,
dto.name ?? null,
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
dto.isActive ?? null,
]);
if (before.level==='SUBINSTALLATION') {
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[familyId,parentId]);
}
}
const after = await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: familyId,
beforeData: before as unknown as Record<string,unknown>,
afterData: after as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_UPDATED' },
},manager);
return after;
});
}
async replaceFindings(
familyId: string,
dto: ReplaceInventoryFamilyFindingsDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const family = await this.family(familyId,false,manager,true);
const uniqueIds=[...new Set(dto.itemIds)];
if (uniqueIds.length) {
const [count] = (await manager.query(`
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true
`,[uniqueIds])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==uniqueIds.length) {
throw new BadRequestException({
code:'INVENTORY_FAMILY_FINDING_INVALID',
message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
});
}
}
const beforeIds=family.findingItemIds;
await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]);
if (uniqueIds.length) {
await manager.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
SELECT item_id,$2::uuid FROM UNNEST($1::uuid[]) AS selected(item_id)
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`,[uniqueIds,familyId]);
}
const after=await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_findings',
entityId:familyId,
beforeData:{ itemIds:beforeIds },
afterData:{ itemIds:after.findingItemIds },
metadata:{ operation:'INVENTORY_FAMILY_FINDINGS_REPLACED', reason:dto.reason },
},manager);
return this.findingsWithManager(manager,familyId);
});
}
private async findingsWithManager(manager:EntityManager,familyId:string) {
const family=await this.family(familyId,false,manager);
const items=await manager.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
category.id AS "categoryId",category.code AS "categoryCode",category.name AS "categoryName"
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
JOIN finding_categories category ON category.id=item.category_id
WHERE mapping.inventory_family_id=$1::uuid
AND item.is_active=true AND category.is_active=true
ORDER BY category.sort_order,item.source_number,item.title
`,[familyId]);
return {family,items,count:items.length};
}
private async family(
familyId:string,
activeOnly:boolean,
manager:EntityManager=this.dataSource.manager,
lock=false,
):Promise<FamilyRow> {
const rows=(await manager.query(`
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
${lock ? 'FOR UPDATE OF family' : ''}
`,[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
});
return rows[0];
}
private async validateParent(
manager:EntityManager,
level:'INSTALLATION'|'SUBINSTALLATION',
parentFamilyId:string|null,
ownId:string|null,
):Promise<string|null> {
if (level==='INSTALLATION') {
if (parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Una clasificación de Instalación no tiene clasificación padre',
});
return null;
}
if (!parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_REQUIRED',
message:'Una Subinstalación debe pertenecer a un tipo de Instalación',
});
if (parentFamilyId===ownId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre',
});
const rows=(await manager.query(`
SELECT id FROM inventory_families
WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true
LIMIT 1
`,[parentFamilyId])) as Array<{id:string}>;
if (!rows[0]) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_INVALID',
message:'La Subinstalación debe vincularse a una clasificación de Instalación activa',
});
return parentFamilyId;
}
private cleanLabels(labels:string[]):string[] {
const unique=new Map<string,string>();
for (const raw of labels) {
const clean=raw.trim();
if (!clean) continue;
const identity=clean.toLocaleLowerCase('es-AR');
if (!unique.has(identity)) unique.set(identity,clean);
}
if (unique.size>100) throw new ConflictException({
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información',
});
return [...unique.values()];
}
} }
@@ -0,0 +1,69 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Req,
} from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
ChangeInventoryFunctionDto,
CreateInventoryFunctionDto,
UpdateInventoryFunctionDto,
} from './dto/inventory-function.dto';
import { InventoryFunctionService } from './inventory-function.service';
@Controller()
export class InventoryFunctionController {
constructor(private readonly functions: InventoryFunctionService) {}
@Get('inventory-functions')
@RequirePermissions('assets.read')
list(@Query('includeInactive') includeInactive?: string) {
return this.functions.list(includeInactive === 'true');
}
@Post('inventory-functions')
@RequirePermissions('asset_types.manage')
create(
@Body() dto: CreateInventoryFunctionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.functions.create(dto, principal, request);
}
@Patch('inventory-functions/:id')
@RequirePermissions('asset_types.manage')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateInventoryFunctionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.functions.update(id, dto, principal, request);
}
@Get('assets/:id/function-history')
@RequirePermissions('assets.read_history')
history(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.functions.getForAsset(id);
}
@Post('assets/:id/function')
@RequirePermissions('assets.update')
change(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeInventoryFunctionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.functions.changeForAsset(id, dto, principal, request);
}
}
@@ -0,0 +1,186 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AssetVersionChangeType, AuditAction } from '../database/entities';
import type {
ChangeInventoryFunctionDto,
CreateInventoryFunctionDto,
UpdateInventoryFunctionDto,
} from './dto/inventory-function.dto';
import { AssetHistoryService } from './asset-history.service';
export interface InventoryFunctionRow {
id: string; code: string; name: string; description: string | null;
isActive: boolean; sortOrder: number; createdAt: Date; updatedAt: Date;
}
export interface FunctionAssignmentRow {
id: string; assetId: string; functionId: string; functionCode: string; functionName: string;
validFrom: Date; validUntil: Date | null; reason: string | null;
changedBy: string | null; changedByUsername: string | null; createdAt: Date;
}
interface FunctionEligibleAsset {
id: string; code: string; name: string; typeCode: string; typeName: string;
familyCode: string | null; familyName: string | null;
}
function normalized(value: string | null): string {
return (value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
@Injectable()
export class InventoryFunctionService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async list(includeInactive = false): Promise<{ data: InventoryFunctionRow[] }> {
const data = await this.dataSource.query(`
SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
created_at AS "createdAt",updated_at AS "updatedAt"
FROM inventory_functions
${includeInactive ? '' : 'WHERE is_active=true'}
ORDER BY sort_order,name,code
`) as InventoryFunctionRow[];
return { data };
}
async create(dto: CreateInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext): Promise<InventoryFunctionRow> {
return this.dataSource.transaction(async (manager) => {
const [duplicate] = await manager.query('SELECT 1 FROM inventory_functions WHERE lower(code)=lower($1) LIMIT 1', [dto.code]) as unknown[];
if (duplicate) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CODE_EXISTS', message: 'Ya existe una función con ese código' });
const [created] = await manager.query(`
INSERT INTO inventory_functions(code,name,description,is_active,sort_order,created_by,updated_by)
VALUES($1,$2,$3,true,$4,$5,$5)
RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
created_at AS "createdAt",updated_at AS "updatedAt"
`, [dto.code, dto.name, dto.description ?? null, dto.sortOrder ?? 0, principal.userId]) as InventoryFunctionRow[];
await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INVENTORY_FUNCTION_CREATED,
entityType: 'inventory_function', entityId: created.id, afterData: created as unknown as Record<string, unknown> }, manager);
return created;
});
}
async update(id: string, dto: UpdateInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext): Promise<InventoryFunctionRow> {
if (Object.keys(dto).length === 0) throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
return this.dataSource.transaction(async (manager) => {
const before = await this.requireFunction(manager, id, false);
const [updated] = await manager.query(`
UPDATE inventory_functions SET
name=COALESCE($2,name), description=CASE WHEN $3::boolean THEN $4 ELSE description END,
is_active=COALESCE($5,is_active), sort_order=COALESCE($6,sort_order),
updated_by=$7,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
created_at AS "createdAt",updated_at AS "updatedAt"
`, [id, dto.name ?? null, dto.description !== undefined, dto.description ?? null, dto.isActive ?? null, dto.sortOrder ?? null, principal.userId]) as InventoryFunctionRow[];
await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INVENTORY_FUNCTION_UPDATED,
entityType: 'inventory_function', entityId: id, beforeData: before as unknown as Record<string, unknown>,
afterData: updated as unknown as Record<string, unknown> }, manager);
return updated;
});
}
async getForAsset(assetId: string) {
return this.dataSource.transaction((manager) => this.getForAssetWithManager(manager, assetId));
}
async changeForAsset(assetId: string, dto: ChangeInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext) {
return this.dataSource.transaction(async (manager) => {
const asset = await this.requireEligibleAsset(manager, assetId, true);
const nextFunction = await this.requireFunction(manager, dto.functionId, true);
const effectiveAt = dto.effectiveAt ? new Date(dto.effectiveAt) : new Date();
if (!Number.isFinite(effectiveAt.getTime())) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_INVALID', message: 'La fecha efectiva del cambio de función no es válida' });
if (effectiveAt.getTime() > Date.now() + 60_000) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_FUTURE_DATE_NOT_ALLOWED', message: 'El cambio de función no puede registrarse con fecha futura' });
const current = await this.currentAssignment(manager, assetId, true);
if (current?.functionId === nextFunction.id) return this.getForAssetWithManager(manager, assetId);
if (current && effectiveAt.getTime() <= new Date(current.validFrom).getTime()) {
throw new ConflictException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_OVERLAP', message: 'La fecha efectiva debe ser posterior al inicio de la función vigente' });
}
if (current) await manager.query(`UPDATE inventory_function_assignments SET valid_until=$2 WHERE id=$1 AND valid_until IS NULL`, [current.id, effectiveAt]);
await manager.query(`INSERT INTO inventory_function_assignments(asset_id,function_id,valid_from,change_reason,changed_by) VALUES($1,$2,$3,$4,$5)`,
[assetId, nextFunction.id, effectiveAt, dto.reason ?? null, principal.userId]);
await manager.query(`UPDATE assets SET updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [assetId, principal.userId]);
const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.FUNCTION_CHANGED, principal, request);
const after = await this.getForAssetWithManager(manager, assetId);
await this.audit.record({
...administrationAuditContext(principal, request), action: AuditAction.ASSET_FUNCTION_CHANGED,
entityType: 'asset', entityId: assetId,
beforeData: current ? { functionId: current.functionId, functionCode: current.functionCode, functionName: current.functionName, validFrom: current.validFrom } : { functionId: null },
afterData: { functionId: nextFunction.id, functionCode: nextFunction.code, functionName: nextFunction.name, effectiveAt, reason: dto.reason ?? null, versionNumber },
metadata: { inventoryCode: asset.code, inventoryName: asset.name, temporal: true, historySource: 'inventory_function_assignments', assetVersionChangeType: AssetVersionChangeType.FUNCTION_CHANGED },
}, manager);
return after;
});
}
private async getForAssetWithManager(manager: EntityManager, assetId: string) {
const asset = await this.requireEligibleAsset(manager, assetId, false);
const currentFunction = await this.currentAssignment(manager, assetId, false);
const history = await manager.query(`
SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId",
fn.code AS "functionCode",fn.name AS "functionName",
assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil",
assignment.change_reason AS reason,assignment.changed_by AS "changedBy",
actor.username AS "changedByUsername",assignment.created_at AS "createdAt"
FROM inventory_function_assignments assignment
JOIN inventory_functions fn ON fn.id=assignment.function_id
LEFT JOIN users actor ON actor.id=assignment.changed_by
WHERE assignment.asset_id=$1
ORDER BY assignment.valid_from DESC,assignment.created_at DESC
`, [assetId]) as FunctionAssignmentRow[];
return { asset: { id: asset.id, code: asset.code, name: asset.name, typeCode: asset.typeCode, typeName: asset.typeName, familyCode: asset.familyCode, familyName: asset.familyName }, currentFunction, history };
}
private async currentAssignment(manager: EntityManager, assetId: string, lock: boolean): Promise<FunctionAssignmentRow | null> {
const [row] = await manager.query(`
SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId",
fn.code AS "functionCode",fn.name AS "functionName",
assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil",
assignment.change_reason AS reason,assignment.changed_by AS "changedBy",
actor.username AS "changedByUsername",assignment.created_at AS "createdAt"
FROM inventory_function_assignments assignment
JOIN inventory_functions fn ON fn.id=assignment.function_id
LEFT JOIN users actor ON actor.id=assignment.changed_by
WHERE assignment.asset_id=$1 AND assignment.valid_until IS NULL
${lock ? 'FOR UPDATE OF assignment' : ''}
`, [assetId]) as FunctionAssignmentRow[];
return row ?? null;
}
private async requireFunction(manager: EntityManager, id: string, active: boolean): Promise<InventoryFunctionRow> {
const [row] = await manager.query(`
SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",created_at AS "createdAt",updated_at AS "updatedAt"
FROM inventory_functions WHERE id=$1 ${active ? 'AND is_active=true' : ''}
`, [id]) as InventoryFunctionRow[];
if (!row) throw new NotFoundException({ code: 'INVENTORY_FUNCTION_NOT_FOUND', message: active ? 'La función seleccionada no existe o está inactiva' : 'Función no encontrada' });
return row;
}
private async requireEligibleAsset(manager: EntityManager, assetId: string, lock: boolean): Promise<FunctionEligibleAsset> {
const [asset] = await manager.query(`
SELECT asset.id,asset.code,asset.name,asset_type.code AS "typeCode",asset_type.name AS "typeName",
family.code AS "familyCode",family.name AS "familyName"
FROM assets asset JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1 ${lock ? 'FOR UPDATE OF asset' : ''}
`, [assetId]) as FunctionEligibleAsset[];
if (!asset) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Inventario no encontrado' });
const values = [asset.typeCode, asset.typeName, asset.familyCode, asset.familyName].map(normalized);
const eligible = values.some((value) => value === 'estacion' || value === 'subestacion' || value.includes('estacion ') || value.includes('subestacion ') || value.endsWith(' estacion') || value.endsWith(' subestacion'));
if (!eligible) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CHANGE_NOT_ALLOWED', message: 'El cambio de función sólo está habilitado para Inventarios de Estación o Subestación' });
return asset;
}
}
@@ -39,6 +39,22 @@ type MergeRow = {
requestId: string | null; requestId: string | null;
}; };
type DocumentInvariantRow = {
actId: string;
actStatus: string;
lockedSha256: string | null;
closureSha256: string | null;
sealedAt: Date | null;
actVersion: number;
reportId: string | null;
reportStatus: string | null;
reportActClosureSha256: string | null;
reportFrozenSha256: string | null;
gedoPdfSha256: string | null;
wordSha256: string | null;
reportRevision: number | null;
};
const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']); const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
@Injectable() @Injectable()
@@ -161,6 +177,19 @@ export class InventoryMergeService {
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' }); if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
this.validatePair(source, canonical); this.validatePair(source, canonical);
// Empresa is temporal inspection context, not physical ownership. A merge is
// valid when both records resolve to the same Area in the physical hierarchy,
// even if their historical operator snapshots differ.
const [sourceAreaId, canonicalAreaId] = await Promise.all([
this.resolvePhysicalAreaId(manager, source.id),
this.resolvePhysicalAreaId(manager, canonical.id),
]);
if (!sourceAreaId || sourceAreaId !== canonicalAreaId) throw new BadRequestException({
code: 'INVENTORY_MERGE_AREA_MISMATCH',
message: 'Los duplicados deben pertenecer a la misma Área física',
});
const sourceParentCanonical = source.parentId const sourceParentCanonical = source.parentId
? await this.resolveCanonicalId(manager, source.parentId) ? await this.resolveCanonicalId(manager, source.parentId)
: null; : null;
@@ -197,6 +226,8 @@ export class InventoryMergeService {
}); });
} }
const affectedAssetIds = [source.id, canonical.id];
const documentInvariantsBefore = await this.documentInvariants(manager, affectedAssetIds);
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([ const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
this.snapshot(manager, source.id), this.snapshot(manager, source.id),
this.snapshot(manager, canonical.id), this.snapshot(manager, canonical.id),
@@ -279,6 +310,17 @@ export class InventoryMergeService {
request, request,
); );
// No historical Acta/Finding/Informe foreign key is rewritten by a merge.
// Verify that legal/documentary fingerprints are byte-for-byte unchanged
// before committing the transaction; otherwise rollback the entire merge.
const documentInvariantsAfter = await this.documentInvariants(manager, affectedAssetIds);
if (JSON.stringify(documentInvariantsBefore) !== JSON.stringify(documentInvariantsAfter)) {
throw new ConflictException({
code: 'INVENTORY_MERGE_DOCUMENT_INVARIANT_BROKEN',
message: 'La fusión intentó alterar la huella documental histórica y fue revertida',
});
}
const result = { const result = {
merge: mergeRecord, merge: mergeRecord,
source: { id: source.id, code: source.code, name: source.name }, source: { id: source.id, code: source.code, name: source.name },
@@ -286,6 +328,7 @@ export class InventoryMergeService {
sourceVersionNumber, sourceVersionNumber,
reparentedChildIds, reparentedChildIds,
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED', historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
documentaryInvariantsVerified: true,
}; };
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -298,10 +341,12 @@ export class InventoryMergeService {
operation: 'CHRONOLOGICAL_MERGE', operation: 'CHRONOLOGICAL_MERGE',
sourceAssetId: source.id, sourceAssetId: source.id,
canonicalAssetId: canonical.id, canonicalAssetId: canonical.id,
physicalAreaId: sourceAreaId,
reason: dto.reason, reason: dto.reason,
sourceVersionNumber, sourceVersionNumber,
reparentedChildIds, reparentedChildIds,
historicalReferencesRewritten: false, historicalReferencesRewritten: false,
documentaryInvariantsVerified: true,
}, },
}, manager); }, manager);
return result; return result;
@@ -322,14 +367,59 @@ export class InventoryMergeService {
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE', code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
message: 'El registro canónico no puede estar inactivo', message: 'El registro canónico no puede estar inactivo',
}); });
if (!source.operationalAreaId || !source.operatorCompanyId }
|| source.operationalAreaId !== canonical.operationalAreaId
|| source.operatorCompanyId !== canonical.operatorCompanyId) { private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise<string | null> {
throw new BadRequestException({ const rows = (await manager.query(`
code: 'INVENTORY_MERGE_CONTEXT_MISMATCH', WITH RECURSIVE lineage AS (
message: 'Los registros deben pertenecer a la misma Área y Operadora', SELECT asset.id,asset.parent_id,asset.asset_type_id,0 AS depth
}); FROM assets asset WHERE asset.id=$1::uuid
} UNION ALL
SELECT parent.id,parent.parent_id,parent.asset_type_id,lineage.depth+1
FROM assets parent
JOIN lineage ON lineage.parent_id=parent.id
WHERE lineage.depth<32
)
SELECT lineage.id
FROM lineage
JOIN asset_types type ON type.id=lineage.asset_type_id
WHERE type.operational_role='AREA'
ORDER BY lineage.depth
LIMIT 1
`, [assetId])) as Array<{ id: string }>;
return rows[0]?.id ?? null;
}
private async documentInvariants(manager: EntityManager, assetIds: string[]): Promise<DocumentInvariantRow[]> {
return (await manager.query(`
WITH affected_acts AS (
SELECT DISTINCT act.id
FROM inspection_acts act
LEFT JOIN inspection_act_assets act_asset
ON act_asset.act_id=act.id AND act_asset.included=true
LEFT JOIN inspection_findings finding ON finding.act_id=act.id
WHERE act_asset.asset_id=ANY($1::uuid[])
OR finding.asset_id=ANY($1::uuid[])
)
SELECT
act.id AS "actId",
act.status AS "actStatus",
act.locked_sha256 AS "lockedSha256",
act.closure_sha256 AS "closureSha256",
act.sealed_at AS "sealedAt",
act.current_version AS "actVersion",
report.id AS "reportId",
report.status AS "reportStatus",
report.act_closure_sha256 AS "reportActClosureSha256",
report.frozen_sha256 AS "reportFrozenSha256",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
report.word_sha256 AS "wordSha256",
report.current_revision_number AS "reportRevision"
FROM affected_acts affected
JOIN inspection_acts act ON act.id=affected.id
LEFT JOIN inspection_reports report ON report.act_id=act.id
ORDER BY act.id,report.id NULLS FIRST
`, [assetIds])) as DocumentInvariantRow[];
} }
private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> { private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
@@ -36,18 +36,17 @@ type ParentRow = {
code: string; code: string;
name: string; name: string;
typeCode: string; typeCode: string;
operationalAreaId: string | null;
operatorCompanyId: string | null;
inventoryFamilyId: string | null; inventoryFamilyId: string | null;
}; };
const TYPE_CODE_BY_KIND: Record<InventoryStructureKind, string> = { const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
AREA: 'area', AREA: 'area',
YACIMIENTO: 'yacimiento', YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion', INSTALACION: 'instalacion',
SUBINSTALACION: 'subinstalacion', SUBINSTALACION: 'subinstalacion',
}; };
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = { const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
EMPRESA: null,
AREA: null, AREA: null,
YACIMIENTO: 'area', YACIMIENTO: 'area',
INSTALACION: 'yacimiento', INSTALACION: 'yacimiento',
@@ -70,18 +69,29 @@ export class InventoryStructureService {
const types = (await this.dataSource.query(` const types = (await this.dataSource.query(`
SELECT id,code,name SELECT id,code,name
FROM asset_types FROM asset_types
WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion') WHERE (
AND is_active=true lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
ORDER BY CASE lower(code) OR operational_role='COMPANY'
WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2 ) AND is_active=true
WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END ORDER BY CASE
WHEN operational_role='COMPANY' THEN 0
WHEN lower(code)='area' THEN 1
WHEN lower(code)='yacimiento' THEN 2
WHEN lower(code)='instalacion' THEN 3
WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END
`)) as StructureTypeRow[]; `)) as StructureTypeRow[];
if (types.length !== 4) { const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase()));
const area = types.find((item) => item.code.toLowerCase()==='area');
const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento');
const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion');
const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion');
if (!company || !area || !yacimiento || !instalacion || !subinstalacion) {
throw new ConflictException({ throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE', code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
message: 'La estructura del Inventario todavía no está completamente configurada', message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
}); });
} }
const families = (await this.dataSource.query(` const families = (await this.dataSource.query(`
SELECT family.id,family.code,family.name,family.level, SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode", family.legacy_type_code AS "legacyTypeCode",
@@ -93,12 +103,16 @@ export class InventoryStructureService {
WHERE family.is_active=true WHERE family.is_active=true
ORDER BY family.level,family.name,family.code ORDER BY family.level,family.name,family.code
`)) as FamilyRow[]; `)) as FamilyRow[];
return { return {
independentMasters: [
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
],
levels: [ levels: [
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false }, { kind: 'AREA', label: 'Área', type: area, parentKind: null, requiresFamily: false },
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false }, { kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false },
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true }, { kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true }, { kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
], ],
installationFamilies: families.filter((item) => item.level==='INSTALLATION'), installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'), subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
@@ -107,7 +121,7 @@ export class InventoryStructureService {
async parents(kindValue: string, search?: string) { async parents(kindValue: string, search?: string) {
const kind = kindValue.toUpperCase() as InventoryStructureKind; const kind = kindValue.toUpperCase() as InventoryStructureKind;
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA') { if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA' || kind === 'EMPRESA') {
throw new BadRequestException({ throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID', code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
message: 'El nivel indicado no requiere un registro padre', message: 'El nivel indicado no requiere un registro padre',
@@ -154,9 +168,8 @@ export class InventoryStructureService {
const type = await this.requireStructureType(manager, dto.kind); const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null); const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent); const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent?.operationalAreaId ?? null; const operationalAreaId = parent ? await this.resolveAreaId(manager, parent) : null;
const operatorCompanyId = parent?.operatorCompanyId ?? null;
const inserted = (await manager.query(` const inserted = (await manager.query(`
INSERT INTO assets ( INSERT INTO assets (
@@ -164,30 +177,37 @@ export class InventoryStructureService {
code,name,common_name,description,information_status,operational_status, code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES ( ) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid, $1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status, $5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid $11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
) RETURNING id ) RETURNING id
`, [ `, [
type.id, type.id,
parent?.id ?? null, parent?.id ?? null,
operationalAreaId, operationalAreaId,
operatorCompanyId,
family?.id ?? null, family?.id ?? null,
code, generatedCode,
dto.name, dto.name,
dto.commonName ?? null, dto.commonName ?? null,
dto.description ?? null, dto.description ?? null,
AssetInformationStatus.DRAFT, AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN, AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL, AssetDataOrigin.MANUAL,
'Inventario estructural F3.1', dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5',
`inventory-structure:${dto.kind.toLowerCase()}`, dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family ? `Familia técnica: ${family.code} · ${family.name}` : null, family ? `Familia técnica: ${family.code} · ${family.name}` : null,
principal.userId, principal.userId,
])) as Array<{ id: string }>; ])) as Array<{ id: string }>;
const id = inserted[0]?.id; const id = inserted[0]?.id;
if (!id) throw new Error('No se pudo crear el registro estructural'); if (!id) throw new Error('No se pudo crear el registro');
if (dto.kind === 'EMPRESA') {
await manager.query(`
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
ON CONFLICT (asset_id) DO UPDATE SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
`,[id,dto.name,principal.userId]);
}
const versionNumber = await this.history.capture( const versionNumber = await this.history.capture(
manager, manager,
@@ -200,13 +220,12 @@ export class InventoryStructureService {
INSERT INTO asset_context_history ( INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from, asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8) ) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
`, [ `, [
id, id,
parent?.id ?? null, parent?.id ?? null,
operationalAreaId, operationalAreaId,
operatorCompanyId, dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5',
'Alta guiada de Inventario estructural F3.1',
versionNumber, versionNumber,
request.requestId, request.requestId,
principal.userId, principal.userId,
@@ -223,6 +242,7 @@ export class InventoryStructureService {
inventoryStructureKind: dto.kind, inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null, inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null, inventoryFamilyCode: family?.code ?? null,
operatorOwnership: false,
}, },
}, manager); }, manager);
return created; return created;
@@ -231,7 +251,7 @@ export class InventoryStructureService {
if (isUniqueViolation(error)) { if (isUniqueViolation(error)) {
throw new ConflictException({ throw new ConflictException({
code: 'ASSET_CODE_ALREADY_EXISTS', code: 'ASSET_CODE_ALREADY_EXISTS',
message: 'Ya existe un registro de Inventario con ese código', message: 'Ya existe un registro con ese código',
}); });
} }
throw error; throw error;
@@ -239,10 +259,11 @@ export class InventoryStructureService {
} }
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> { private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
const rows = (await manager.query(` const sql = kind === 'EMPRESA'
SELECT id,code,name FROM asset_types ? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1 : `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
`, [TYPE_CODE_BY_KIND[kind]])) as StructureTypeRow[]; const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
const rows = (await manager.query(sql,params)) as StructureTypeRow[];
if (!rows[0]) { if (!rows[0]) {
throw new ConflictException({ throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
@@ -261,8 +282,10 @@ export class InventoryStructureService {
if (!expectedType) { if (!expectedType) {
if (parentId) { if (parentId) {
throw new BadRequestException({ throw new BadRequestException({
code: 'INVENTORY_AREA_MUST_BE_ROOT', code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
message: 'Un Área se crea como registro raíz y no puede tener padre', message: kind === 'EMPRESA'
? 'Una Empresa es un maestro independiente y no puede tener padre'
: 'Un Área es un registro raíz y no puede tener padre',
}); });
} }
return null; return null;
@@ -275,8 +298,6 @@ export class InventoryStructureService {
} }
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode", SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId",
asset.inventory_family_id AS "inventoryFamilyId" asset.inventory_family_id AS "inventoryFamilyId"
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id JOIN asset_types type ON type.id=asset.asset_type_id
@@ -294,6 +315,30 @@ export class InventoryStructureService {
return parent; return parent;
} }
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
if (parent.typeCode.toLowerCase()==='area') return parent.id;
const rows = (await manager.query(`
WITH RECURSIVE lineage AS (
SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid
UNION ALL
SELECT parent.id,parent.parent_id,parent.asset_type_id
FROM assets parent JOIN lineage child ON child.parent_id=parent.id
)
SELECT lineage.id
FROM lineage JOIN asset_types type ON type.id=lineage.asset_type_id
WHERE type.operational_role='AREA'
LIMIT 1
`,[parent.id])) as IdRow[];
const areaId=rows[0]?.id;
if (!areaId) {
throw new ConflictException({
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
message:'La ubicación seleccionada no pertenece a un Área válida',
});
}
return areaId;
}
private async requireFamily( private async requireFamily(
manager: EntityManager, manager: EntityManager,
kind: InventoryStructureKind, kind: InventoryStructureKind,
@@ -304,7 +349,7 @@ export class InventoryStructureService {
if (!expectedLevel) { if (!expectedLevel) {
if (familyId) throw new BadRequestException({ if (familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED', code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
message: 'Área y Yacimiento no llevan familia técnica', message: 'Empresa, Área y Yacimiento no llevan familia técnica',
}); });
return null; return null;
} }
@@ -339,7 +384,7 @@ export class InventoryStructureService {
} }
private generatedCode(kind: InventoryStructureKind, name: string): string { private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA'; const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
const readable = name const readable = name
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
@@ -356,6 +401,7 @@ export class InventoryStructureService {
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus", asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level, 'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels 'informationLabels',family.information_labels
@@ -364,9 +410,12 @@ export class InventoryStructureService {
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid WHERE asset.id=$1::uuid
`, [id]); `, [id]);
return rows[0]; return rows[0];
} }
} }
type IdRow = { id: string };
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AssetsService } from './assets.service'; import { AssetsService } from './assets.service';
import { InventoryFunctionService } from './inventory-function.service';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>; type LooseRecord = Record<string, any>;
@@ -29,6 +30,7 @@ export class MergedInventoryDossierService {
constructor( constructor(
private readonly assets: AssetsService, private readonly assets: AssetsService,
private readonly merges: InventoryMergeService, private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService,
) {} ) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> { async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
@@ -44,7 +46,8 @@ export class MergedInventoryDossierService {
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => { const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
const dossier = await this.assets.dossier(assetId) as LooseRecord; const dossier = await this.assets.dossier(assetId) as LooseRecord;
const identity = dossier.asset as LooseRecord; const identity = dossier.asset as LooseRecord;
return { assetId, identity, dossier }; const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier };
})); }));
const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({ const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({
@@ -75,6 +78,15 @@ export class MergedInventoryDossierService {
const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']); const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']);
const versions = sortDesc(collect('versions'), ['occurredAt']); const versions = sortDesc(collect('versions'), ['occurredAt']);
const functionHistory = sortDesc(
dossiers.flatMap(({ identity, functionDossier }) =>
(((functionDossier?.history ?? []) as LooseRecord[]).map((entry) => enrich(entry, identity))),
),
['validFrom', 'createdAt'],
);
const canonicalFunctionDossier = dossiers.find((item) => item.assetId === canonical.id)?.functionDossier ?? null;
const currentFunction = canonicalFunctionDossier?.currentFunction ?? null;
const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) => const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) =>
((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({ ((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({
...event, ...event,
@@ -91,6 +103,25 @@ export class MergedInventoryDossierService {
); );
const timeline: LooseRecord[] = dedupeById<LooseRecord>(timelineSource); const timeline: LooseRecord[] = dedupeById<LooseRecord>(timelineSource);
for (const assignment of functionHistory) {
timeline.push({
id: `function:${String(assignment.id)}`,
kind: 'FUNCTION_CHANGED',
occurredAt: assignment.validFrom,
title: `Cambio de función · ${String(assignment.functionName)}`,
description: assignment.reason ?? null,
meta: {
functionId: assignment.functionId,
functionCode: assignment.functionCode,
functionName: assignment.functionName,
validFrom: assignment.validFrom,
validUntil: assignment.validUntil,
changedByUsername: assignment.changedByUsername,
historicalInventory: assignment.historicalInventory,
},
});
}
for (const alias of aliases) { for (const alias of aliases) {
timeline.push({ timeline.push({
id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`, id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`,
@@ -121,6 +152,7 @@ export class MergedInventoryDossierService {
code: canonical.code, code: canonical.code,
name: canonical.name, name: canonical.name,
commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null, commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null,
currentFunction,
}, },
requestedAsset: { requestedAsset: {
id: requested.id, id: requested.id,
@@ -146,7 +178,10 @@ export class MergedInventoryDossierService {
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length, documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length, photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length,
reports: reports.length + inspectionReports.length, reports: reports.length + inspectionReports.length,
functionChanges: functionHistory.length,
}, },
currentFunction,
functionHistory,
visits, visits,
acts, acts,
findings, findings,
+104 -51
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
export interface DashboardAuditItem { export interface DashboardInspectorActivityItem {
id: string; id: string;
occurredAt: Date; occurredAt: Date;
actorUsername: string | null; actorUsername: string | null;
@@ -19,12 +19,13 @@ interface DashboardCountsRow {
inactiveUsers: number | string; inactiveUsers: number | string;
activeSessions: number | string; activeSessions: number | string;
openFindings: number | string; openFindings: number | string;
awaitingCompanyResponse: number | string; actsInFollowUp: number | string;
overdueCompanyResponses: number | string; findingsWithoutControlDate: number | string;
companyResponsesDueNext7Days: number | string;
awaitingVerificationSchedule: number | string;
overdueControls: number | string; overdueControls: number | string;
controlsNext30Days: number | string; controlsNext30Days: number | string;
reportsWorking: number | string;
reportsOfficialized: number | string;
sealedActsWithoutReport: number | string;
} }
export interface DashboardUpcomingControl { export interface DashboardUpcomingControl {
@@ -49,9 +50,14 @@ export class DashboardService {
return this.dataSource.transaction('REPEATABLE READ', async (manager) => { return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
const [counts] = (await manager.query(` const [counts] = (await manager.query(`
SELECT SELECT
(SELECT COUNT(*) FROM assets WHERE information_status <> 'INACTIVE') AS "totalAssets", (SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status <> 'INACTIVE') AS "totalAssets",
(SELECT COUNT(*) FROM assets WHERE information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation", (SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation",
(SELECT COUNT(*) FROM assets asset WHERE asset.information_status <> 'INACTIVE' AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)) AS "assetsWithoutGeometry", (
SELECT COUNT(*) FROM assets asset
WHERE asset.is_inventory_instance=true
AND asset.information_status <> 'INACTIVE'
AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)
) AS "assetsWithoutGeometry",
(SELECT COUNT(*) FROM inspection_visits WHERE status='PLANNED') AS "plannedInspections", (SELECT COUNT(*) FROM inspection_visits WHERE status='PLANNED') AS "plannedInspections",
(SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers", (SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers",
(SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers", (SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers",
@@ -61,50 +67,74 @@ export class DashboardService {
WHERE revoked_at IS NULL WHERE revoked_at IS NULL
AND expires_at > CURRENT_TIMESTAMP AND expires_at > CURRENT_TIMESTAMP
) AS "activeSessions", ) AS "activeSessions",
(SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN') AS "openFindings",
( (
SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN' SELECT COUNT(*)
) AS "openFindings", FROM inspection_acts act
WHERE act.status IN ('SEALED','CLOSED','RECTIFIED')
AND NOT (
EXISTS (
SELECT 1 FROM inspection_findings finding
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
)
AND NOT EXISTS (
SELECT 1 FROM inspection_findings finding
WHERE finding.act_id=act.id AND finding.status='OPEN'
)
)
) AS "actsInFollowUp",
( (
SELECT COUNT(*) FROM inspection_findings SELECT COUNT(*)
WHERE status = 'OPEN' AND company_response_received_on IS NULL FROM inspection_findings finding
) AS "awaitingCompanyResponse", WHERE finding.status = 'OPEN'
AND finding.next_control_on IS NULL
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
) AS "findingsWithoutControlDate",
( (
SELECT COUNT(*) FROM inspection_findings SELECT COUNT(*)
WHERE status = 'OPEN' FROM inspection_findings finding
AND company_response_received_on IS NULL WHERE finding.status = 'OPEN'
AND correction_due_on IS NOT NULL AND finding.next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
AND correction_due_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date AND COALESCE((
) AS "overdueCompanyResponses", SELECT verification.outcome
( FROM inspection_finding_verification_visits verification
SELECT COUNT(*) FROM inspection_findings WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
WHERE status = 'OPEN' ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
AND company_response_received_on IS NULL LIMIT 1
AND correction_due_on BETWEEN ), '') <> 'RESOLVED'
(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 7
) AS "companyResponsesDueNext7Days",
(
SELECT COUNT(*) FROM inspection_findings
WHERE status = 'OPEN'
AND company_response_received_on IS NOT NULL
AND next_control_on IS NULL
) AS "awaitingVerificationSchedule",
(
SELECT COUNT(*) FROM inspection_findings
WHERE status = 'OPEN'
AND company_response_received_on IS NOT NULL
AND next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
) AS "overdueControls", ) AS "overdueControls",
( (
SELECT COUNT(*) FROM inspection_findings SELECT COUNT(*)
WHERE status = 'OPEN' FROM inspection_findings finding
AND next_control_on BETWEEN WHERE finding.status = 'OPEN'
AND finding.next_control_on BETWEEN
(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 30 AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 30
) AS "controlsNext30Days" AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
) AS "controlsNext30Days",
(SELECT COUNT(*) FROM inspection_reports WHERE status='WORKING') AS "reportsWorking",
(SELECT COUNT(*) FROM inspection_reports WHERE status='OFFICIALIZED') AS "reportsOfficialized",
(
SELECT COUNT(*)
FROM inspection_acts act
WHERE act.status='SEALED'
AND NOT EXISTS (SELECT 1 FROM inspection_reports report WHERE report.act_id=act.id)
) AS "sealedActsWithoutReport"
`)) as DashboardCountsRow[]; `)) as DashboardCountsRow[];
const recentAudit = (await manager.query(` const recentInspectorActivity = (await manager.query(`
SELECT SELECT
event.id, event.id,
event.occurred_at AS "occurredAt", event.occurred_at AS "occurredAt",
@@ -113,9 +143,25 @@ export class DashboardService {
event.entity_type AS "entityType", event.entity_type AS "entityType",
event.entity_id AS "entityId" event.entity_id AS "entityId"
FROM audit_events event FROM audit_events event
WHERE event.actor_user_id IS NOT NULL
AND (
EXISTS (
SELECT 1 FROM inspection_visit_members member
WHERE member.user_id=event.actor_user_id AND member.included=true
)
OR EXISTS (
SELECT 1 FROM inspection_visits visit
WHERE visit.lead_inspector_user_id=event.actor_user_id
)
)
AND (
event.action LIKE 'INSPECTION_%'
OR event.action LIKE 'ASSET_%'
OR event.action LIKE 'FINDING_%'
)
ORDER BY event.occurred_at DESC, event.id DESC ORDER BY event.occurred_at DESC, event.id DESC
LIMIT 6 LIMIT 8
`)) as DashboardAuditItem[]; `)) as DashboardInspectorActivityItem[];
const upcomingControls = (await manager.query(` const upcomingControls = (await manager.query(`
SELECT SELECT
@@ -135,8 +181,14 @@ export class DashboardService {
INNER JOIN inspection_visits visit ON visit.id = act.visit_id INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE finding.status = 'OPEN' WHERE finding.status = 'OPEN'
AND finding.company_response_received_on IS NOT NULL
AND finding.next_control_on IS NOT NULL AND finding.next_control_on IS NOT NULL
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
ORDER BY finding.next_control_on, finding.code ORDER BY finding.next_control_on, finding.code
LIMIT 8 LIMIT 8
`)) as DashboardUpcomingControl[]; `)) as DashboardUpcomingControl[];
@@ -151,14 +203,15 @@ export class DashboardService {
inactiveUsers: Number(counts?.inactiveUsers ?? 0), inactiveUsers: Number(counts?.inactiveUsers ?? 0),
activeSessions: Number(counts?.activeSessions ?? 0), activeSessions: Number(counts?.activeSessions ?? 0),
openFindings: Number(counts?.openFindings ?? 0), openFindings: Number(counts?.openFindings ?? 0),
awaitingCompanyResponse: Number(counts?.awaitingCompanyResponse ?? 0), actsInFollowUp: Number(counts?.actsInFollowUp ?? 0),
overdueCompanyResponses: Number(counts?.overdueCompanyResponses ?? 0), findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0),
companyResponsesDueNext7Days: Number(counts?.companyResponsesDueNext7Days ?? 0),
awaitingVerificationSchedule: Number(counts?.awaitingVerificationSchedule ?? 0),
overdueControls: Number(counts?.overdueControls ?? 0), overdueControls: Number(counts?.overdueControls ?? 0),
controlsNext30Days: Number(counts?.controlsNext30Days ?? 0), controlsNext30Days: Number(counts?.controlsNext30Days ?? 0),
reportsWorking: Number(counts?.reportsWorking ?? 0),
reportsOfficialized: Number(counts?.reportsOfficialized ?? 0),
sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0),
}, },
recentAudit, recentInspectorActivity,
upcomingControls, upcomingControls,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
}; };
@@ -5,6 +5,7 @@ export enum AssetVersionChangeType {
CREATED = 'CREATED', CREATED = 'CREATED',
UPDATED = 'UPDATED', UPDATED = 'UPDATED',
CONTEXT_CHANGED = 'CONTEXT_CHANGED', CONTEXT_CHANGED = 'CONTEXT_CHANGED',
FUNCTION_CHANGED = 'FUNCTION_CHANGED',
STATUS_CHANGED = 'STATUS_CHANGED', STATUS_CHANGED = 'STATUS_CHANGED',
OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED', OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED',
REGISTRY_UPDATED = 'REGISTRY_UPDATED', REGISTRY_UPDATED = 'REGISTRY_UPDATED',
@@ -57,6 +57,9 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true }) @Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
inventoryFamilyId!: string | null; inventoryFamilyId!: string | null;
@Column({ name: 'is_inventory_instance', type: 'boolean', default: false })
isInventoryInstance!: boolean;
@Column({ type: 'varchar', length: 120 }) @Column({ type: 'varchar', length: 120 })
code!: string; code!: string;
@@ -35,6 +35,9 @@ export enum AuditAction {
ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED', ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED',
ASSET_UPDATED = 'ASSET_UPDATED', ASSET_UPDATED = 'ASSET_UPDATED',
ASSET_CONTEXT_CHANGED = 'ASSET_CONTEXT_CHANGED', ASSET_CONTEXT_CHANGED = 'ASSET_CONTEXT_CHANGED',
ASSET_FUNCTION_CHANGED = 'ASSET_FUNCTION_CHANGED',
INVENTORY_FUNCTION_CREATED = 'INVENTORY_FUNCTION_CREATED',
INVENTORY_FUNCTION_UPDATED = 'INVENTORY_FUNCTION_UPDATED',
ASSET_INFORMATION_STATUS_CHANGED = 'ASSET_INFORMATION_STATUS_CHANGED', ASSET_INFORMATION_STATUS_CHANGED = 'ASSET_INFORMATION_STATUS_CHANGED',
ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED', ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED',
ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED', ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED',
@@ -82,15 +85,24 @@ export enum AuditAction {
INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED', INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED',
INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED', INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED',
INSPECTION_ACT_RESPONSIBLE_UPDATED = 'INSPECTION_ACT_RESPONSIBLE_UPDATED', INSPECTION_ACT_RESPONSIBLE_UPDATED = 'INSPECTION_ACT_RESPONSIBLE_UPDATED',
INSPECTION_ACT_LOCKED = 'INSPECTION_ACT_LOCKED',
INSPECTION_ACT_SEALED = 'INSPECTION_ACT_SEALED',
INSPECTION_ACT_READY = 'INSPECTION_ACT_READY', INSPECTION_ACT_READY = 'INSPECTION_ACT_READY',
INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED', INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED',
INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED', INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED',
INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED', INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED',
INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED', INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED',
INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED', INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED',
INSPECTION_REPORT_UPDATED = 'INSPECTION_REPORT_UPDATED',
INSPECTION_REPORT_REVISION_ADDED = 'INSPECTION_REPORT_REVISION_ADDED', INSPECTION_REPORT_REVISION_ADDED = 'INSPECTION_REPORT_REVISION_ADDED',
INSPECTION_REPORT_OFFICIALIZED = 'INSPECTION_REPORT_OFFICIALIZED',
INSPECTION_REPORT_FOLLOW_UP_ADDED = 'INSPECTION_REPORT_FOLLOW_UP_ADDED',
INSPECTION_REPORT_APPROVED = 'INSPECTION_REPORT_APPROVED', INSPECTION_REPORT_APPROVED = 'INSPECTION_REPORT_APPROVED',
INSPECTION_REPORT_SIGNED = 'INSPECTION_REPORT_SIGNED', INSPECTION_REPORT_SIGNED = 'INSPECTION_REPORT_SIGNED',
INSPECTION_DEADLINE_POLICY_UPDATED = 'INSPECTION_DEADLINE_POLICY_UPDATED',
INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED',
SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED',
SMTP_TEST_SENT = 'SMTP_TEST_SENT',
DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED', DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED',
DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED', DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT', DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
+27 -80
View File
@@ -6,14 +6,8 @@ export { Role } from './role.entity';
export { UserRole } from './user-role.entity'; export { UserRole } from './user-role.entity';
export { User, UserStatus } from './user.entity'; export { User, UserStatus } from './user.entity';
export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity'; export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity';
export { export { AssetVersion, AssetVersionChangeType } from './asset-version.entity';
AssetVersion, export { AssetAttributeDataType, AssetAttributeDefinition } from './asset-attribute-definition.entity';
AssetVersionChangeType,
} from './asset-version.entity';
export {
AssetAttributeDataType,
AssetAttributeDefinition,
} from './asset-attribute-definition.entity';
export { AssetAttributeValue } from './asset-attribute-value.entity'; export { AssetAttributeValue } from './asset-attribute-value.entity';
export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity'; export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity';
export { OrganizationProfile, OrganizationKind } from './organization-profile.entity'; export { OrganizationProfile, OrganizationKind } from './organization-profile.entity';
@@ -25,69 +19,33 @@ export { AreaLegalRight, AreaLegalRightType, AreaLegalRightStatus } from './area
export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity'; export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity';
export { AssetTypeParentRule } from './asset-type-parent-rule.entity'; export { AssetTypeParentRule } from './asset-type-parent-rule.entity';
export { AssetType, AssetTypeOperationalRole } from './asset-type.entity'; export { AssetType, AssetTypeOperationalRole } from './asset-type.entity';
export { export { AssetGeometry, AssetGeometrySource, AssetGeometryType } from './asset-geometry.entity';
AssetGeometry, export { AssetMedia, AssetMediaKind, AssetMediaSource } from './asset-media.entity';
AssetGeometrySource, export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entity';
AssetGeometryType,
} from './asset-geometry.entity';
export {
AssetMedia,
AssetMediaKind,
AssetMediaSource,
} from './asset-media.entity';
export {
SurveyCampaign,
SurveyCampaignStatus,
} from './survey-campaign.entity';
export {
SurveyCampaignTarget,
SurveyTargetStatus,
} from './survey-campaign-target.entity';
export {
SurveyReportOutcome,
SurveyReportStatus,
SurveyTargetReport,
} from './survey-target-report.entity';
export { SurveyTargetReportMedia } from './survey-target-report-media.entity';
export {
SurveyReportVersionEvent,
SurveyTargetReportVersion,
} from './survey-target-report-version.entity';
export {
InspectionVisit,
InspectionVisitStatus,
} from './inspection-visit.entity';
export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity'; export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
export { InspectionVisitMember } from './inspection-visit-member.entity'; export { InspectionVisitMember } from './inspection-visit-member.entity';
export { export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity';
DocumentAnnualSequence,
DocumentSequenceType,
} from './document-annual-sequence.entity';
export { export {
InspectionAct, InspectionAct,
InspectionActStatus, InspectionActStatus,
InspectionActUrgency,
InspectionDeadlineBasis,
InspectionDeadlineDayType,
} from './inspection-act.entity'; } from './inspection-act.entity';
export { InspectionActAsset } from './inspection-act-asset.entity'; export { InspectionActAsset } from './inspection-act-asset.entity';
export { export {
InspectionReport, InspectionReport,
InspectionReportPdfStatus, InspectionReportPdfStatus,
InspectionReportReviewStatus,
InspectionReportStatus, InspectionReportStatus,
InspectionReportWordStatus, InspectionReportWordStatus,
} from './inspection-report.entity'; } from './inspection-report.entity';
export { export { InspectionActVersion, InspectionActVersionEvent } from './inspection-act-version.entity';
InspectionActVersion,
InspectionActVersionEvent,
} from './inspection-act-version.entity';
export { export {
InspectionActResponsible, InspectionActResponsible,
InspectionResponsibleAttendanceStatus, InspectionResponsibleAttendanceStatus,
InspectionResponsibleDocumentType, InspectionResponsibleDocumentType,
} from './inspection-act-responsible.entity'; } from './inspection-act-responsible.entity';
export { export { InspectionActClosure, InspectionActUploadMode } from './inspection-act-closure.entity';
InspectionActClosure,
InspectionActUploadMode,
} from './inspection-act-closure.entity';
export { export {
InspectionActSignature, InspectionActSignature,
InspectionActSignatureSource, InspectionActSignatureSource,
@@ -95,21 +53,18 @@ export {
InspectionActSignerType, InspectionActSignerType,
InspectionCompanySignatureManifestation, InspectionCompanySignatureManifestation,
} from './inspection-act-signature.entity'; } from './inspection-act-signature.entity';
export { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
export { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity';
export { InspectionReportFollowUp, InspectionReportFollowUpType } from './inspection-report-follow-up.entity';
export { SystemSmtpSettings, SmtpSecurityMode } from './system-smtp-settings.entity';
export { FindingCategory } from './finding-category.entity'; export { FindingCategory } from './finding-category.entity';
export { FindingCatalogItem } from './finding-catalog-item.entity'; export { FindingCatalogItem } from './finding-catalog-item.entity';
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity'; export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity'; export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity'; export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity'; export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
export { export { InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus } from './inspection-finding.entity';
InspectionFinding, export { InspectionFindingVersion, InspectionFindingVersionEvent } from './inspection-finding-version.entity';
InspectionFindingResponseDueBasis,
InspectionFindingStatus,
} from './inspection-finding.entity';
export {
InspectionFindingVersion,
InspectionFindingVersionEvent,
} from './inspection-finding-version.entity';
export { export {
InspectionCommunicationChannel, InspectionCommunicationChannel,
InspectionCommunicationDirection, InspectionCommunicationDirection,
@@ -122,14 +77,8 @@ export {
InspectionEvidenceSource, InspectionEvidenceSource,
InspectionFindingEvidence, InspectionFindingEvidence,
} from './inspection-finding-evidence.entity'; } from './inspection-finding-evidence.entity';
export { export { InspectionFindingVerificationVisit, InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity';
InspectionFindingVerificationVisit, export { InspectionFindingVerificationEvent, InspectionFindingVerificationEventType } from './inspection-finding-verification-event.entity';
InspectionVerificationOutcome,
} from './inspection-finding-verification-visit.entity';
export {
InspectionFindingVerificationEvent,
InspectionFindingVerificationEventType,
} from './inspection-finding-verification-event.entity';
import { AuditEvent } from './audit-event.entity'; import { AuditEvent } from './audit-event.entity';
import { AuthSession } from './auth-session.entity'; import { AuthSession } from './auth-session.entity';
@@ -154,11 +103,6 @@ import { AreaLegalRightOrganization } from './area-legal-right-organization.enti
import { AssetGeometry } from './asset-geometry.entity'; import { AssetGeometry } from './asset-geometry.entity';
import { AssetVersion } from './asset-version.entity'; import { AssetVersion } from './asset-version.entity';
import { AssetMedia } from './asset-media.entity'; import { AssetMedia } from './asset-media.entity';
import { SurveyCampaign } from './survey-campaign.entity';
import { SurveyCampaignTarget } from './survey-campaign-target.entity';
import { SurveyTargetReport } from './survey-target-report.entity';
import { SurveyTargetReportMedia } from './survey-target-report-media.entity';
import { SurveyTargetReportVersion } from './survey-target-report-version.entity';
import { InspectionVisit } from './inspection-visit.entity'; import { InspectionVisit } from './inspection-visit.entity';
import { InspectionVisitAsset } from './inspection-visit-asset.entity'; import { InspectionVisitAsset } from './inspection-visit-asset.entity';
import { InspectionVisitMember } from './inspection-visit-member.entity'; import { InspectionVisitMember } from './inspection-visit-member.entity';
@@ -170,6 +114,10 @@ import { InspectionActVersion } from './inspection-act-version.entity';
import { InspectionActResponsible } from './inspection-act-responsible.entity'; import { InspectionActResponsible } from './inspection-act-responsible.entity';
import { InspectionActClosure } from './inspection-act-closure.entity'; import { InspectionActClosure } from './inspection-act-closure.entity';
import { InspectionActSignature } from './inspection-act-signature.entity'; import { InspectionActSignature } from './inspection-act-signature.entity';
import { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
import { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity';
import { InspectionReportFollowUp } from './inspection-report-follow-up.entity';
import { SystemSmtpSettings } from './system-smtp-settings.entity';
import { FindingCategory } from './finding-category.entity'; import { FindingCategory } from './finding-category.entity';
import { FindingCatalogItem } from './finding-catalog-item.entity'; import { FindingCatalogItem } from './finding-catalog-item.entity';
import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity'; import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
@@ -207,11 +155,6 @@ export const PHASE_A_ENTITIES = [
AssetGeometry, AssetGeometry,
AssetVersion, AssetVersion,
AssetMedia, AssetMedia,
SurveyCampaign,
SurveyCampaignTarget,
SurveyTargetReport,
SurveyTargetReportMedia,
SurveyTargetReportVersion,
InspectionVisit, InspectionVisit,
InspectionVisitAsset, InspectionVisitAsset,
InspectionVisitMember, InspectionVisitMember,
@@ -223,6 +166,10 @@ export const PHASE_A_ENTITIES = [
InspectionActResponsible, InspectionActResponsible,
InspectionActClosure, InspectionActClosure,
InspectionActSignature, InspectionActSignature,
InspectionDeadlinePolicy,
InspectionBusinessCalendarDay,
InspectionReportFollowUp,
SystemSmtpSettings,
FindingCategory, FindingCategory,
FindingCatalogItem, FindingCatalogItem,
FindingCatalogItemAssetType, FindingCatalogItemAssetType,
@@ -3,6 +3,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum InspectionActVersionEvent { export enum InspectionActVersionEvent {
CREATED = 'CREATED', CREATED = 'CREATED',
UPDATED = 'UPDATED', UPDATED = 'UPDATED',
LOCKED = 'LOCKED',
SEALED = 'SEALED',
READY = 'READY', READY = 'READY',
REOPENED = 'REOPENED', REOPENED = 'REOPENED',
CLOSED = 'CLOSED', CLOSED = 'CLOSED',
@@ -3,12 +3,33 @@ import { TimestampedEntity } from './timestamped.entity';
export enum InspectionActStatus { export enum InspectionActStatus {
DRAFT = 'DRAFT', DRAFT = 'DRAFT',
LOCKED = 'LOCKED',
SEALED = 'SEALED',
READY = 'READY', READY = 'READY',
CLOSED = 'CLOSED', CLOSED = 'CLOSED',
CANCELLED = 'CANCELLED', CANCELLED = 'CANCELLED',
RECTIFIED = 'RECTIFIED', RECTIFIED = 'RECTIFIED',
} }
export enum InspectionActUrgency {
URGENT = 'URGENT',
NON_URGENT = 'NON_URGENT',
}
export enum InspectionDeadlineDayType {
BUSINESS = 'BUSINESS',
CALENDAR = 'CALENDAR',
}
export enum InspectionDeadlineBasis {
ACT_DATE = 'ACT_DATE',
// La columna F4 temprana usó GEDO_DATE. Se conserva el valor físico por
// compatibilidad de migración, pero funcionalmente significa "pendiente de
// fecha de notificación" hasta que el procedimiento defina el evento válido.
NOTIFICATION_DATE = 'GEDO_DATE',
GEDO_DATE = 'GEDO_DATE',
}
@Entity({ name: 'inspection_acts' }) @Entity({ name: 'inspection_acts' })
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" }) @Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true }) @Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
@@ -16,6 +37,7 @@ export enum InspectionActStatus {
@Index('idx_inspection_acts_visit_status', ['visitId', 'status']) @Index('idx_inspection_acts_visit_status', ['visitId', 'status'])
@Index('idx_inspection_acts_occurred_at', ['occurredAt']) @Index('idx_inspection_acts_occurred_at', ['occurredAt'])
@Index('idx_inspection_acts_created_by', ['createdBy']) @Index('idx_inspection_acts_created_by', ['createdBy'])
@Index('idx_inspection_acts_deadline', ['deadlineAt'])
export class InspectionAct extends TimestampedEntity { export class InspectionAct extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@@ -29,7 +51,7 @@ export class InspectionAct extends TimestampedEntity {
@Column({ name: 'act_number', type: 'integer' }) @Column({ name: 'act_number', type: 'integer' })
actNumber!: number; actNumber!: number;
@Column({ type: 'varchar', length: 24 }) @Column({ type: 'varchar', length: 40 })
code!: string; code!: string;
@Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT }) @Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT })
@@ -47,6 +69,39 @@ export class InspectionAct extends TimestampedEntity {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
observations!: string | null; observations!: string | null;
@Column({ name: 'urgency', type: 'varchar', length: 24, default: InspectionActUrgency.NON_URGENT })
urgency!: InspectionActUrgency;
@Column({ name: 'deadline_days', type: 'integer', nullable: true })
deadlineDays!: number | null;
@Column({ name: 'deadline_day_type', type: 'varchar', length: 24, nullable: true })
deadlineDayType!: InspectionDeadlineDayType | null;
@Column({ name: 'deadline_basis', type: 'varchar', length: 24, nullable: true })
deadlineBasis!: InspectionDeadlineBasis | null;
@Column({ name: 'deadline_base_at', type: 'timestamptz', nullable: true })
deadlineBaseAt!: Date | null;
@Column({ name: 'deadline_at', type: 'timestamptz', nullable: true })
deadlineAt!: Date | null;
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
lockedAt!: Date | null;
@Column({ name: 'locked_by', type: 'uuid', nullable: true })
lockedBy!: string | null;
@Column({ name: 'locked_sha256', type: 'char', length: 64, nullable: true })
lockedSha256!: string | null;
@Column({ name: 'sealed_at', type: 'timestamptz', nullable: true })
sealedAt!: Date | null;
@Column({ name: 'sealed_by', type: 'uuid', nullable: true })
sealedBy!: string | null;
@Column({ name: 'current_version', type: 'integer', default: 0 }) @Column({ name: 'current_version', type: 'integer', default: 0 })
currentVersion!: number; currentVersion!: number;
@@ -0,0 +1,21 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'inspection_business_calendar_days' })
@Index('uq_inspection_business_calendar_day', ['date'], { unique: true })
export class InspectionBusinessCalendarDay extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'date' })
date!: string;
@Column({ name: 'is_business_day', type: 'boolean', default: false })
isBusinessDay!: boolean;
@Column({ type: 'varchar', length: 200 })
label!: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,24 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { InspectionDeadlineDayType } from './inspection-act.entity';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'inspection_deadline_policies' })
export class InspectionDeadlinePolicy extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'urgent_days', type: 'integer', default: 5 })
urgentDays!: number;
@Column({ name: 'urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS })
urgentDayType!: InspectionDeadlineDayType;
@Column({ name: 'non_urgent_days', type: 'integer', default: 10 })
nonUrgentDays!: number;
@Column({ name: 'non_urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS })
nonUrgentDayType!: InspectionDeadlineDayType;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -1,6 +1,7 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity'; import { TimestampedEntity } from './timestamped.entity';
/** @deprecated Los plazos de respuesta pasan al nivel Acta/Informe en F4. */
export enum InspectionFindingResponseDueBasis { export enum InspectionFindingResponseDueBasis {
FINDING_DATE = 'FINDING_DATE', FINDING_DATE = 'FINDING_DATE',
REPORT_NOTIFICATION = 'REPORT_NOTIFICATION', REPORT_NOTIFICATION = 'REPORT_NOTIFICATION',
@@ -18,6 +19,7 @@ export enum InspectionFindingStatus {
@Index('idx_inspection_findings_status_control', ['status', 'nextControlOn']) @Index('idx_inspection_findings_status_control', ['status', 'nextControlOn'])
@Index('idx_inspection_findings_asset_status', ['assetId', 'status']) @Index('idx_inspection_findings_asset_status', ['assetId', 'status'])
@Index('idx_inspection_findings_catalog_item', ['catalogItemId']) @Index('idx_inspection_findings_catalog_item', ['catalogItemId'])
@Index('idx_inspection_findings_recurrence_of', ['recurrenceOfFindingId'])
export class InspectionFinding extends TimestampedEntity { export class InspectionFinding extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@@ -61,9 +63,16 @@ export class InspectionFinding extends TimestampedEntity {
@Column({ type: 'smallint', nullable: true }) @Column({ type: 'smallint', nullable: true })
severity!: number | null; severity!: number | null;
@Column({ name: 'is_recurrence', type: 'boolean', default: false })
isRecurrence!: boolean;
@Column({ name: 'recurrence_of_finding_id', type: 'uuid', nullable: true })
recurrenceOfFindingId!: string | null;
@Column({ name: 'correction_due_on', type: 'date', nullable: true }) @Column({ name: 'correction_due_on', type: 'date', nullable: true })
correctionDueOn!: string | null; correctionDueOn!: string | null;
// Campos legacy conservados temporalmente para migrar datos sin pérdida.
@Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true }) @Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true })
responseDueBasis!: InspectionFindingResponseDueBasis | null; responseDueBasis!: InspectionFindingResponseDueBasis | null;
@@ -0,0 +1,50 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum InspectionReportFollowUpType {
COMPANY_NOTE = 'COMPANY_NOTE',
COMPANY_DOCUMENT = 'COMPANY_DOCUMENT',
INTERNAL_NOTE = 'INTERNAL_NOTE',
VERIFICATION = 'VERIFICATION',
OTHER = 'OTHER',
}
@Entity({ name: 'inspection_report_follow_ups' })
@Index('idx_inspection_report_follow_ups_report_created', ['reportId', 'createdAt'])
export class InspectionReportFollowUp extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'report_id', type: 'uuid' })
reportId!: string;
@Column({ type: 'varchar', length: 40 })
type!: InspectionReportFollowUpType;
@Column({ name: 'external_reference', type: 'varchar', length: 255, nullable: true })
externalReference!: string | null;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true })
originalName!: string | null;
@Column({ name: 'stored_name', type: 'varchar', length: 255, nullable: true })
storedName!: string | null;
@Column({ name: 'mime_type', type: 'varchar', length: 120, nullable: true })
mimeType!: string | null;
@Column({ name: 'size_bytes', type: 'integer', nullable: true })
sizeBytes!: number | null;
@Column({ name: 'sha256', type: 'char', length: 64, nullable: true })
sha256!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
}
@@ -2,6 +2,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity'; import { TimestampedEntity } from './timestamped.entity';
export enum InspectionReportStatus { export enum InspectionReportStatus {
WORKING = 'WORKING',
OFFICIALIZED = 'OFFICIALIZED',
FROZEN = 'FROZEN', FROZEN = 'FROZEN',
CANCELLED = 'CANCELLED', CANCELLED = 'CANCELLED',
} }
@@ -18,12 +20,6 @@ export enum InspectionReportWordStatus {
FAILED = 'FAILED', FAILED = 'FAILED',
} }
export enum InspectionReportReviewStatus {
PENDING_REVIEW = 'PENDING_REVIEW',
APPROVED = 'APPROVED',
SIGNED = 'SIGNED',
}
@Entity({ name: 'inspection_reports' }) @Entity({ name: 'inspection_reports' })
@Index('idx_inspection_reports_visit_id', ['visitId']) @Index('idx_inspection_reports_visit_id', ['visitId'])
@Index('uq_inspection_reports_act', ['actId'], { unique: true }) @Index('uq_inspection_reports_act', ['actId'], { unique: true })
@@ -31,6 +27,7 @@ export enum InspectionReportReviewStatus {
@Index('uq_inspection_reports_code', ['code'], { unique: true }) @Index('uq_inspection_reports_code', ['code'], { unique: true })
@Index('idx_inspection_reports_generated_at', ['generatedAt']) @Index('idx_inspection_reports_generated_at', ['generatedAt'])
@Index('idx_inspection_reports_status', ['status', 'pdfStatus']) @Index('idx_inspection_reports_status', ['status', 'pdfStatus'])
@Index('idx_inspection_reports_gedo_officialized_at', ['gedoOfficializedAt'])
export class InspectionReport extends TimestampedEntity { export class InspectionReport extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@@ -47,12 +44,39 @@ export class InspectionReport extends TimestampedEntity {
@Column({ name: 'report_number', type: 'integer' }) @Column({ name: 'report_number', type: 'integer' })
reportNumber!: number; reportNumber!: number;
@Column({ type: 'varchar', length: 24 }) @Column({ type: 'varchar', length: 40 })
code!: string; code!: string;
@Column({ type: 'varchar', length: 24, default: InspectionReportStatus.FROZEN }) @Column({ type: 'varchar', length: 24, default: InspectionReportStatus.WORKING })
status!: InspectionReportStatus; status!: InspectionReportStatus;
@Column({ name: 'executive_summary', type: 'text', nullable: true })
executiveSummary!: string | null;
@Column({ name: 'report_description', type: 'text', nullable: true })
reportDescription!: string | null;
@Column({ name: 'gedo_if_identifier', type: 'varchar', length: 255, nullable: true })
gedoIfIdentifier!: string | null;
@Column({ name: 'gedo_officialized_at', type: 'timestamptz', nullable: true })
gedoOfficializedAt!: Date | null;
@Column({ name: 'gedo_pdf_original_name', type: 'varchar', length: 255, nullable: true })
gedoPdfOriginalName!: string | null;
@Column({ name: 'gedo_pdf_stored_name', type: 'varchar', length: 255, nullable: true })
gedoPdfStoredName!: string | null;
@Column({ name: 'gedo_pdf_mime_type', type: 'varchar', length: 120, nullable: true })
gedoPdfMimeType!: string | null;
@Column({ name: 'gedo_pdf_size_bytes', type: 'integer', nullable: true })
gedoPdfSizeBytes!: number | null;
@Column({ name: 'gedo_pdf_sha256', type: 'char', length: 64, nullable: true })
gedoPdfSha256!: string | null;
@Column({ name: 'pdf_status', type: 'varchar', length: 24, default: InspectionReportPdfStatus.PENDING }) @Column({ name: 'pdf_status', type: 'varchar', length: 24, default: InspectionReportPdfStatus.PENDING })
pdfStatus!: InspectionReportPdfStatus; pdfStatus!: InspectionReportPdfStatus;
@@ -80,27 +104,9 @@ export class InspectionReport extends TimestampedEntity {
@Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true }) @Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true })
wordError!: string | null; wordError!: string | null;
@Column({ name: 'review_status', type: 'varchar', length: 32, default: InspectionReportReviewStatus.PENDING_REVIEW })
reviewStatus!: InspectionReportReviewStatus;
@Column({ name: 'current_revision_number', type: 'integer', default: 0 }) @Column({ name: 'current_revision_number', type: 'integer', default: 0 })
currentRevisionNumber!: number; currentRevisionNumber!: number;
@Column({ name: 'approved_revision_id', type: 'uuid', nullable: true })
approvedRevisionId!: string | null;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedBy!: string | null;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt!: Date | null;
@Column({ name: 'review_note', type: 'varchar', length: 1000, nullable: true })
reviewNote!: string | null;
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt!: Date | null;
@Column({ type: 'varchar', length: 220 }) @Column({ type: 'varchar', length: 220 })
title!: string; title!: string;
@@ -14,7 +14,7 @@ export enum InspectionVisitStatus {
@Index('idx_inspection_visits_status', ['status']) @Index('idx_inspection_visits_status', ['status'])
@Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId']) @Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId'])
@Index('idx_inspection_visits_lead_inspector_user_id', ['leadInspectorUserId']) @Index('idx_inspection_visits_lead_inspector_user_id', ['leadInspectorUserId'])
@Index('idx_inspection_visits_planned_dates', ['plannedStartAt', 'plannedEndAt']) @Index('idx_inspection_visits_planned_start_at', ['plannedStartAt'])
@Index('idx_inspection_visits_operational_context', ['operationalAreaId', 'operatorCompanyId']) @Index('idx_inspection_visits_operational_context', ['operationalAreaId', 'operatorCompanyId'])
export class InspectionVisit extends TimestampedEntity { export class InspectionVisit extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@@ -23,9 +23,6 @@ export class InspectionVisit extends TimestampedEntity {
@Column({ type: 'varchar', length: 80 }) @Column({ type: 'varchar', length: 80 })
code!: string; code!: string;
@Column({ type: 'varchar', length: 200 })
title!: string;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
objective!: string | null; objective!: string | null;
@@ -47,9 +44,6 @@ export class InspectionVisit extends TimestampedEntity {
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true }) @Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
plannedStartAt!: Date | null; plannedStartAt!: Date | null;
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
plannedEndAt!: Date | null;
@Column({ name: 'actual_started_at', type: 'timestamptz', nullable: true }) @Column({ name: 'actual_started_at', type: 'timestamptz', nullable: true })
actualStartedAt!: Date | null; actualStartedAt!: Date | null;
@@ -1,44 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyTargetStatus {
PENDING = 'PENDING',
IN_PROGRESS = 'IN_PROGRESS',
SUBMITTED = 'SUBMITTED',
COMPLETED = 'COMPLETED',
SKIPPED = 'SKIPPED',
}
@Entity({ name: 'survey_campaign_targets' })
@Index('uq_survey_campaign_targets_campaign_asset', ['campaignId', 'assetId'], { unique: true })
@Index('idx_survey_campaign_targets_campaign_status', ['campaignId', 'status'])
@Index('idx_survey_campaign_targets_asset_id', ['assetId'])
@Index('idx_survey_campaign_targets_assigned_user_id', ['assignedUserId'])
export class SurveyCampaignTarget extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'campaign_id', type: 'uuid' })
campaignId!: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ name: 'assigned_user_id', type: 'uuid', nullable: true })
assignedUserId!: string | null;
@Column({ type: 'varchar', length: 24, default: SurveyTargetStatus.PENDING })
status!: SurveyTargetStatus;
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
dueAt!: Date | null;
@Column({ type: 'text', nullable: true })
instructions!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -1,51 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyCampaignStatus {
DRAFT = 'DRAFT',
PLANNED = 'PLANNED',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
@Entity({ name: 'survey_campaigns' })
@Index('uq_survey_campaigns_code', ['code'], { unique: true })
@Index('idx_survey_campaigns_status', ['status'])
@Index('idx_survey_campaigns_scope_asset_id', ['scopeAssetId'])
@Index('idx_survey_campaigns_coordinator_user_id', ['coordinatorUserId'])
@Index('idx_survey_campaigns_planned_dates', ['plannedStartAt', 'plannedEndAt'])
export class SurveyCampaign extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80 })
code!: string;
@Column({ type: 'varchar', length: 200 })
name!: string;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'varchar', length: 24, default: SurveyCampaignStatus.DRAFT })
status!: SurveyCampaignStatus;
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
plannedStartAt!: Date | null;
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
plannedEndAt!: Date | null;
@Column({ name: 'scope_asset_id', type: 'uuid', nullable: true })
scopeAssetId!: string | null;
@Column({ name: 'coordinator_user_id', type: 'uuid', nullable: true })
coordinatorUserId!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -1,19 +0,0 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'survey_target_report_media' })
@Index('idx_survey_target_report_media_media_id', ['mediaId'])
@Index('idx_survey_target_report_media_included', ['reportId', 'included'])
export class SurveyTargetReportMedia extends TimestampedEntity {
@PrimaryColumn({ name: 'report_id', type: 'uuid' })
reportId!: string;
@PrimaryColumn({ name: 'media_id', type: 'uuid' })
mediaId!: string;
@Column({ type: 'boolean', default: true })
included!: boolean;
@Column({ name: 'added_by', type: 'uuid', nullable: true })
addedBy!: string | null;
}
@@ -1,36 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum SurveyReportVersionEvent {
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity({ name: 'survey_target_report_versions' })
@Index('uq_survey_target_report_versions_number', ['reportId', 'versionNumber'], { unique: true })
@Index('idx_survey_target_report_versions_created_at', ['createdAt'])
export class SurveyTargetReportVersion {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'report_id', type: 'uuid' })
reportId!: string;
@Column({ name: 'version_number', type: 'integer' })
versionNumber!: number;
@Column({ type: 'varchar', length: 24 })
event!: SurveyReportVersionEvent;
@Column({ type: 'jsonb' })
snapshot!: Record<string, unknown>;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId!: string | null;
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
actorUsername!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -1,73 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyReportOutcome {
CONFIRMED = 'CONFIRMED',
CHANGES_RECORDED = 'CHANGES_RECORDED',
NOT_LOCATED = 'NOT_LOCATED',
}
export enum SurveyReportStatus {
DRAFT = 'DRAFT',
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity({ name: 'survey_target_reports' })
@Index('uq_survey_target_reports_target_id', ['targetId'], { unique: true })
@Index('idx_survey_target_reports_status', ['status'])
@Index('idx_survey_target_reports_submitted_by', ['submittedBy'])
@Index('idx_survey_target_reports_reviewed_by', ['reviewedBy'])
export class SurveyTargetReport extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'target_id', type: 'uuid' })
targetId!: string;
@Column({ type: 'varchar', length: 32, nullable: true })
outcome!: SurveyReportOutcome | null;
@Column({ type: 'varchar', length: 24, default: SurveyReportStatus.DRAFT })
status!: SurveyReportStatus;
@Column({ name: 'observed_at', type: 'timestamptz', nullable: true })
observedAt!: Date | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
latitude!: string | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
longitude!: string | null;
@Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true })
accuracyM!: string | null;
@Column({ type: 'text', nullable: true })
notes!: string | null;
@Column({ name: 'asset_version_at_submission', type: 'integer', nullable: true })
assetVersionAtSubmission!: number | null;
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt!: Date | null;
@Column({ name: 'submitted_by', type: 'uuid', nullable: true })
submittedBy!: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt!: Date | null;
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
reviewedBy!: string | null;
@Column({ name: 'review_notes', type: 'text', nullable: true })
reviewNotes!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,44 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SmtpSecurityMode {
NONE = 'NONE',
STARTTLS = 'STARTTLS',
TLS = 'TLS',
}
@Entity({ name: 'system_smtp_settings' })
export class SystemSmtpSettings extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 255 })
host!: string;
@Column({ type: 'integer' })
port!: number;
@Column({ name: 'security_mode', type: 'varchar', length: 24, default: SmtpSecurityMode.STARTTLS })
securityMode!: SmtpSecurityMode;
@Column({ type: 'varchar', length: 255, nullable: true })
username!: string | null;
@Column({ name: 'password_enc', type: 'text', nullable: true })
passwordEnc!: string | null;
@Column({ name: 'from_name', type: 'varchar', length: 200 })
fromName!: string;
@Column({ name: 'from_email', type: 'varchar', length: 320 })
fromEmail!: string;
@Column({ name: 'reply_to', type: 'varchar', length: 320, nullable: true })
replyTo!: string | null;
@Column({ type: 'boolean', default: true })
enabled!: boolean;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,161 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4DocumentWorkflowFoundation1790000000000 implements MigrationInterface {
name = 'F4DocumentWorkflowFoundation1790000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE inspection_acts ALTER COLUMN code TYPE varchar(40)`);
await queryRunner.query(`ALTER TABLE inspection_reports ALTER COLUMN code TYPE varchar(40)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS urgency varchar(24) NOT NULL DEFAULT 'NON_URGENT'`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_days integer`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_day_type varchar(24)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_basis varchar(24)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_base_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_by uuid`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_sha256 char(64)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_by uuid`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_acts_deadline ON inspection_acts(deadline_at)`);
await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS is_recurrence boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS recurrence_of_finding_id uuid`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_findings_recurrence_of ON inspection_findings(recurrence_of_finding_id)`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE inspection_findings
ADD CONSTRAINT fk_inspection_findings_recurrence_of
FOREIGN KEY (recurrence_of_finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS executive_summary text`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS report_description text`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_if_identifier varchar(255)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_officialized_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_original_name varchar(255)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_stored_name varchar(255)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_mime_type varchar(120)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_size_bytes integer`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_sha256 char(64)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_reports_gedo_officialized_at ON inspection_reports(gedo_officialized_at)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inspection_deadline_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
urgent_days integer NOT NULL DEFAULT 5 CHECK (urgent_days > 0),
urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS',
non_urgent_days integer NOT NULL DEFAULT 10 CHECK (non_urgent_days > 0),
non_urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS',
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_inspection_deadline_policy_urgent_type CHECK (urgent_day_type IN ('BUSINESS','CALENDAR')),
CONSTRAINT ck_inspection_deadline_policy_non_urgent_type CHECK (non_urgent_day_type IN ('BUSINESS','CALENDAR'))
)
`);
await queryRunner.query(`
INSERT INTO inspection_deadline_policies (urgent_days, urgent_day_type, non_urgent_days, non_urgent_day_type)
SELECT 5, 'BUSINESS', 10, 'BUSINESS'
WHERE NOT EXISTS (SELECT 1 FROM inspection_deadline_policies)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inspection_business_calendar_days (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
date date NOT NULL,
is_business_day boolean NOT NULL DEFAULT false,
label varchar(200) NOT NULL,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_inspection_business_calendar_day UNIQUE(date)
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inspection_report_follow_ups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
report_id uuid NOT NULL,
type varchar(40) NOT NULL,
external_reference varchar(255),
occurred_at timestamptz NOT NULL,
description text,
original_name varchar(255),
stored_name varchar(255),
mime_type varchar(120),
size_bytes integer,
sha256 char(64),
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT fk_inspection_report_follow_ups_report FOREIGN KEY(report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE,
CONSTRAINT ck_inspection_report_follow_up_type CHECK (type IN ('COMPANY_NOTE','COMPANY_DOCUMENT','INTERNAL_NOTE','VERIFICATION','OTHER'))
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_report_follow_ups_report_created ON inspection_report_follow_ups(report_id, created_at)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS system_smtp_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
host varchar(255) NOT NULL,
port integer NOT NULL CHECK (port > 0 AND port <= 65535),
security_mode varchar(24) NOT NULL DEFAULT 'STARTTLS',
username varchar(255),
password_enc text,
from_name varchar(200) NOT NULL,
from_email varchar(320) NOT NULL,
reply_to varchar(320),
enabled boolean NOT NULL DEFAULT true,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_system_smtp_security_mode CHECK (security_mode IN ('NONE','STARTTLS','TLS'))
)
`);
await queryRunner.query(`
UPDATE inspection_acts
SET
deadline_days = COALESCE(deadline_days, 10),
deadline_day_type = COALESCE(deadline_day_type, 'BUSINESS'),
deadline_basis = COALESCE(deadline_basis, 'GEDO_DATE')
WHERE deadline_days IS NULL OR deadline_day_type IS NULL OR deadline_basis IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS system_smtp_settings`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_ups`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_business_calendar_days`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_deadline_policies`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_sha256`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_size_bytes`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_mime_type`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_stored_name`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_original_name`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_officialized_at`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_if_identifier`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS report_description`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS executive_summary`);
await queryRunner.query(`ALTER TABLE inspection_findings DROP CONSTRAINT IF EXISTS fk_inspection_findings_recurrence_of`);
await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS recurrence_of_finding_id`);
await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS is_recurrence`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_by`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_sha256`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_by`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_base_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_basis`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_day_type`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_days`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS urgency`);
}
}
@@ -0,0 +1,80 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4InventoryFunctionHistory1790000100000 implements MigrationInterface {
name = 'F4InventoryFunctionHistory1790000100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inventory_functions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(120) NOT NULL UNIQUE,
name varchar(240) NOT NULL,
description text,
is_active boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inventory_functions_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_inventory_functions_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_inventory_functions_active_sort
ON inventory_functions(is_active,sort_order,name)
`);
await queryRunner.query(`
CREATE TABLE inventory_function_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL,
function_id uuid NOT NULL,
valid_from timestamptz NOT NULL,
valid_until timestamptz,
change_reason text,
changed_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inventory_function_assignment_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE CASCADE,
CONSTRAINT fk_inventory_function_assignment_function FOREIGN KEY (function_id)
REFERENCES inventory_functions(id) ON DELETE RESTRICT,
CONSTRAINT fk_inventory_function_assignment_user FOREIGN KEY (changed_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_inventory_function_assignment_dates CHECK (
valid_until IS NULL OR valid_until > valid_from
)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_inventory_function_assignment_current
ON inventory_function_assignments(asset_id)
WHERE valid_until IS NULL
`);
await queryRunner.query(`
CREATE INDEX idx_inventory_function_assignment_history
ON inventory_function_assignments(asset_id,valid_from DESC,created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_inventory_function_assignment_function
ON inventory_function_assignments(function_id,valid_from DESC)
`);
await queryRunner.query(`
INSERT INTO inventory_functions(code,name,description,sort_order)
VALUES
('BOMBEO_MECANICO_AIB','Bombeo mecánico AIB',
'Función técnica de bombeo mecánico mediante aparato individual de bombeo (AIB).',10),
('BOMBEO_MECANICO_ROTAFLEX','Bombeo mecánico Rotaflex',
'Función técnica de bombeo mecánico mediante sistema Rotaflex.',20)
ON CONFLICT (code) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE IF EXISTS inventory_function_assignments');
await queryRunner.query('DROP TABLE IF EXISTS inventory_functions');
}
}
@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4CompanySignatureInvites1790000200000 implements MigrationInterface {
name = 'F4CompanySignatureInvites1790000200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inspection_act_company_signature_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
act_id uuid NOT NULL,
token_sha256 char(64) NOT NULL UNIQUE,
recipient_email varchar(320) NOT NULL,
recipient_name varchar(200),
recipient_document_type varchar(20),
recipient_document_number varchar(40),
recipient_position varchar(200),
status varchar(20) NOT NULL DEFAULT 'PENDING',
expires_at timestamptz NOT NULL,
sent_at timestamptz,
used_at timestamptz,
revoked_at timestamptz,
created_by uuid NOT NULL,
revoked_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_company_signature_invite_act FOREIGN KEY (act_id)
REFERENCES inspection_acts(id) ON DELETE CASCADE,
CONSTRAINT fk_company_signature_invite_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT fk_company_signature_invite_revoked_by FOREIGN KEY (revoked_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_company_signature_invite_status CHECK (
status IN ('PENDING','USED','REVOKED','EXPIRED')
)
)
`);
await queryRunner.query(`
CREATE INDEX idx_company_signature_invites_act_created
ON inspection_act_company_signature_invites(act_id,created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_company_signature_invites_pending_expiry
ON inspection_act_company_signature_invites(status,expires_at)
WHERE status='PENDING'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE IF EXISTS inspection_act_company_signature_invites');
}
}
@@ -0,0 +1,220 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const surveyPermissions = [
'surveys.read',
'surveys.manage',
'surveys.assign',
'surveys.execute',
'surveys.read_reports',
'surveys.capture',
'surveys.review',
] as const;
const rolePermissionValues = `
('admin', 'surveys.read'),
('admin', 'surveys.manage'),
('admin', 'surveys.assign'),
('admin', 'surveys.execute'),
('admin', 'surveys.read_reports'),
('admin', 'surveys.capture'),
('admin', 'surveys.review'),
('director', 'surveys.read'),
('director', 'surveys.read_reports'),
('director', 'surveys.review'),
('supervisor', 'surveys.read'),
('supervisor', 'surveys.manage'),
('supervisor', 'surveys.assign'),
('supervisor', 'surveys.execute'),
('supervisor', 'surveys.read_reports'),
('supervisor', 'surveys.capture'),
('supervisor', 'surveys.review'),
('inspector', 'surveys.read'),
('inspector', 'surveys.execute'),
('inspector', 'surveys.read_reports'),
('inspector', 'surveys.capture'),
('auditor', 'surveys.read'),
('auditor', 'surveys.read_reports')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class F4RemoveSurveySubsystem1790000300000 implements MigrationInterface {
name = 'F4RemoveSurveySubsystem1790000300000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE code = ANY($1::varchar[])
)
`, [surveyPermissions]);
await queryRunner.query(
`DELETE FROM permissions WHERE code = ANY($1::varchar[])`,
[surveyPermissions],
);
// Deliberadamente sin CASCADE: una dependencia externa debe detener la migración.
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_versions`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_media`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_reports`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_campaign_targets`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_campaigns`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS survey_campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(80) NOT NULL UNIQUE,
name varchar(200) NOT NULL,
description text,
status varchar(24) NOT NULL DEFAULT 'DRAFT',
planned_start_at timestamptz,
planned_end_at timestamptz,
scope_asset_id uuid REFERENCES assets(id) ON DELETE RESTRICT,
coordinator_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_survey_campaigns_status CHECK (
status IN ('DRAFT','PLANNED','IN_PROGRESS','COMPLETED','CANCELLED')
),
CONSTRAINT chk_survey_campaigns_dates CHECK (
planned_start_at IS NULL OR planned_end_at IS NULL OR planned_end_at >= planned_start_at
)
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_status ON survey_campaigns(status)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_scope_asset_id ON survey_campaigns(scope_asset_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_coordinator_user_id ON survey_campaigns(coordinator_user_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_planned_dates ON survey_campaigns(planned_start_at,planned_end_at)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS survey_campaign_targets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id uuid NOT NULL REFERENCES survey_campaigns(id) ON DELETE RESTRICT,
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT,
assigned_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
status varchar(24) NOT NULL DEFAULT 'PENDING',
due_at timestamptz,
instructions text,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_survey_campaign_targets_campaign_asset UNIQUE(campaign_id,asset_id),
CONSTRAINT chk_survey_campaign_targets_status CHECK (
status IN ('PENDING','IN_PROGRESS','SUBMITTED','COMPLETED','SKIPPED')
)
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_campaign_status ON survey_campaign_targets(campaign_id,status)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_asset_id ON survey_campaign_targets(asset_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_assigned_user_id ON survey_campaign_targets(assigned_user_id)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS survey_target_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
target_id uuid NOT NULL UNIQUE REFERENCES survey_campaign_targets(id) ON DELETE RESTRICT,
outcome varchar(32),
status varchar(24) NOT NULL DEFAULT 'DRAFT',
observed_at timestamptz,
latitude numeric(9,6),
longitude numeric(9,6),
accuracy_m numeric(12,3),
notes text,
asset_version_at_submission integer,
submitted_at timestamptz,
submitted_by uuid REFERENCES users(id) ON DELETE SET NULL,
reviewed_at timestamptz,
reviewed_by uuid REFERENCES users(id) ON DELETE SET NULL,
review_notes text,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_survey_target_reports_outcome CHECK (
outcome IS NULL OR outcome IN ('CONFIRMED','CHANGES_RECORDED','NOT_LOCATED')
),
CONSTRAINT chk_survey_target_reports_status CHECK (
status IN ('DRAFT','SUBMITTED','APPROVED','REJECTED')
),
CONSTRAINT chk_survey_target_reports_coordinates CHECK (
(latitude IS NULL AND longitude IS NULL)
OR (latitude IS NOT NULL AND longitude IS NOT NULL
AND latitude BETWEEN -90 AND 90 AND longitude BETWEEN -180 AND 180)
),
CONSTRAINT chk_survey_target_reports_accuracy CHECK (
accuracy_m IS NULL OR (accuracy_m >= 0 AND latitude IS NOT NULL AND longitude IS NOT NULL)
)
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_status ON survey_target_reports(status)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_submitted_by ON survey_target_reports(submitted_by)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_reviewed_by ON survey_target_reports(reviewed_by)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS survey_target_report_media (
report_id uuid NOT NULL REFERENCES survey_target_reports(id) ON DELETE RESTRICT,
media_id uuid NOT NULL REFERENCES asset_media(id) ON DELETE RESTRICT,
included boolean NOT NULL DEFAULT true,
added_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(report_id,media_id)
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_media_media_id ON survey_target_report_media(media_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_media_included ON survey_target_report_media(report_id,included)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS survey_target_report_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
report_id uuid NOT NULL REFERENCES survey_target_reports(id) ON DELETE RESTRICT,
version_number integer NOT NULL,
event varchar(24) NOT NULL,
snapshot jsonb NOT NULL,
actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
actor_username varchar(80),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_survey_target_report_versions_number UNIQUE(report_id,version_number),
CONSTRAINT chk_survey_target_report_versions_event CHECK (
event IN ('SUBMITTED','APPROVED','REJECTED')
)
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_versions_created_at ON survey_target_report_versions(created_at)`);
await queryRunner.query(`
INSERT INTO permissions(code,description) VALUES
('surveys.read','Consultar planificación de relevamientos'),
('surveys.manage','Crear y actualizar campañas de relevamiento'),
('surveys.assign','Asignar objetivos de relevamiento'),
('surveys.execute','Actualizar el avance de objetivos asignados'),
('surveys.read_reports','Consultar capturas y versiones de relevamientos'),
('surveys.capture','Capturar y enviar relevamientos de campo'),
('surveys.review','Aprobar o rechazar relevamientos enviados')
ON CONFLICT(code) DO UPDATE SET description=EXCLUDED.description
`);
await queryRunner.query(`
WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues})
INSERT INTO role_permissions(role_id,permission_id)
SELECT role.id,permission.id
FROM mapping
JOIN roles role ON role.code=mapping.role_code
JOIN permissions permission ON permission.code=mapping.permission_code
ON CONFLICT(role_id,permission_id) DO NOTHING
`);
const appRole = process.env.DB_APP_USER;
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
const applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`GRANT SELECT,INSERT,UPDATE ON TABLE survey_campaigns,survey_campaign_targets,survey_target_reports,survey_target_report_media TO ${applicationRole}`);
await queryRunner.query(`GRANT SELECT,INSERT ON TABLE survey_target_report_versions TO ${applicationRole}`);
await queryRunner.query(`REVOKE DELETE ON TABLE survey_campaigns,survey_campaign_targets,survey_target_reports,survey_target_report_media,survey_target_report_versions FROM ${applicationRole}`);
await queryRunner.query(`REVOKE UPDATE ON TABLE survey_target_report_versions FROM ${applicationRole}`);
}
}
@@ -0,0 +1,113 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const legacyDirectorPermissions = [
['inspection_reports.revise', 'Cargar versiones corregidas del informe para revisión directiva'],
['inspection_reports.review', 'Aprobar la versión vigente del informe'],
['inspection_reports.sign_final', 'Firmar electrónicamente el informe final aprobado'],
] as const;
/**
* Retira definitivamente el circuito activo legacy Director -> revisión -> aprobación -> firma.
*
* current_revision_number e inspection_report_revisions se conservan porque forman parte del
* versionado útil del INF. inspection_report_signatures también se conserva como archivo
* histórico: F4 quita sus endpoints/permisos pero no destruye evidencia documental ya emitida.
*/
export class F4RemoveDirectorReportLegacy1790000400000 implements MigrationInterface {
name = 'F4RemoveDirectorReportLegacy1790000400000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Las entregas al Director pertenecían al flujo retirado y no deben seguir
// apareciendo como pendientes/reintentables en F4.
await queryRunner.query(`
DELETE FROM inspection_document_deliveries
WHERE recipient_kind = 'DIRECTOR'
`);
// Retira permisos huérfanos del circuito anterior para que tampoco aparezcan
// como capacidades administrables en RBAC una vez eliminados sus endpoints.
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions
WHERE code IN (
'inspection_reports.revise',
'inspection_reports.review',
'inspection_reports.sign_final'
)
)
`);
await queryRunner.query(`
DELETE FROM permissions
WHERE code IN (
'inspection_reports.revise',
'inspection_reports.review',
'inspection_reports.sign_final'
)
`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_revision_id`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_by`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_at`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS review_note`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS signed_at`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS review_status`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// El rollback reconstruye el contrato técnico legacy, pero no recrea entregas Director
// ni valores de aprobación eliminados. El backup PRE-F4 sigue siendo la fuente de
// recuperación de esos datos históricos si alguna vez fuera necesario volver al circuito.
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD COLUMN IF NOT EXISTS review_status varchar(32) NOT NULL DEFAULT 'PENDING_REVIEW'
`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_revision_id uuid`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_by uuid`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS review_note varchar(1000)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS signed_at timestamptz`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE inspection_reports
ADD CONSTRAINT chk_inspection_reports_review_status
CHECK (review_status IN ('PENDING_REVIEW','APPROVED','SIGNED'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE inspection_reports
ADD CONSTRAINT fk_inspection_reports_approved_by
FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE inspection_reports
ADD CONSTRAINT fk_inspection_reports_approved_revision
FOREIGN KEY (approved_revision_id) REFERENCES inspection_report_revisions(id) ON DELETE RESTRICT;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
for (const [code, description] of legacyDirectorPermissions) {
await queryRunner.query(
`INSERT INTO permissions (code, description) VALUES ($1, $2) ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description`,
[code, description],
);
}
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (VALUES
('director', 'inspection_reports.revise'),
('director', 'inspection_reports.review'),
('director', 'inspection_reports.sign_final')
)
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM mapping
JOIN roles role ON role.code = mapping.role_code
JOIN permissions permission ON permission.code = mapping.permission_code
ON CONFLICT (role_id, permission_id) DO NOTHING
`);
}
}
@@ -0,0 +1,118 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* F4 elimina dos campos heredados de la planificación original de Inspecciones:
* - title: la Inspección se identifica por su código autogenerado.
* - planned_end_at: la planificación sólo define la fecha/hora prevista de inicio.
*
* La migración conserva una copia técnica de los valores eliminados para que un
* rollback restaure los datos exactos existentes antes del cambio. Inspecciones
* creadas después del up() reciben code como title y planned_end_at NULL al bajar.
*
* Se conserva un índice sobre planned_start_at porque sigue siendo un criterio de
* orden y filtro operativo. Ninguna migración histórica se modifica.
*/
export class F4RemoveInspectionLegacyFields1790000500000 implements MigrationInterface {
name = 'F4RemoveInspectionLegacyFields1790000500000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS f4_inspection_visit_legacy_fields_backup (
visit_id uuid PRIMARY KEY,
title varchar(200) NOT NULL,
planned_end_at timestamptz NULL,
backed_up_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await queryRunner.query(`
INSERT INTO f4_inspection_visit_legacy_fields_backup (
visit_id,
title,
planned_end_at,
backed_up_at
)
SELECT
id,
title,
planned_end_at,
CURRENT_TIMESTAMP
FROM inspection_visits
ON CONFLICT (visit_id) DO UPDATE SET
title = EXCLUDED.title,
planned_end_at = EXCLUDED.planned_end_at,
backed_up_at = EXCLUDED.backed_up_at
`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_visits_planned_dates`);
await queryRunner.query(`
ALTER TABLE inspection_visits
DROP CONSTRAINT IF EXISTS chk_inspection_visits_planned_dates
`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN IF EXISTS planned_end_at`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN IF EXISTS title`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_inspection_visits_planned_start_at
ON inspection_visits (planned_start_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_visits_planned_start_at`);
await queryRunner.query(`
ALTER TABLE inspection_visits
ADD COLUMN IF NOT EXISTS title varchar(200)
`);
await queryRunner.query(`
ALTER TABLE inspection_visits
ADD COLUMN IF NOT EXISTS planned_end_at timestamptz
`);
await queryRunner.query(`
UPDATE inspection_visits visit
SET
title = backup.title,
planned_end_at = backup.planned_end_at
FROM f4_inspection_visit_legacy_fields_backup backup
WHERE backup.visit_id = visit.id
`);
await queryRunner.query(`
UPDATE inspection_visits
SET title = code
WHERE title IS NULL
`);
await queryRunner.query(`
ALTER TABLE inspection_visits
ALTER COLUMN title SET NOT NULL
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_inspection_visits_planned_dates'
AND conrelid = 'inspection_visits'::regclass
) THEN
ALTER TABLE inspection_visits
ADD CONSTRAINT chk_inspection_visits_planned_dates CHECK (
planned_start_at IS NULL
OR planned_end_at IS NULL
OR planned_end_at >= planned_start_at
);
END IF;
END
$$
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_inspection_visits_planned_dates
ON inspection_visits (planned_start_at, planned_end_at)
`);
await queryRunner.query(`DROP TABLE IF EXISTS f4_inspection_visit_legacy_fields_backup`);
}
}
@@ -0,0 +1,274 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Alinea las restricciones físicas heredadas de D5 con el ciclo documental F4.
*
* F4 incorpora estados LOCKED/SEALED para Actas y WORKING/OFFICIALIZED para INF.
* También usa el código visible ACT-NNNNN-DD-MM-YY y entrega copia al INSPECTOR.
* Las migraciones históricas conservaban CHECKs, triggers y contratos de D5, por lo
* que el servicio podía compilar pero PostgreSQL rechazaba las transiciones nuevas.
*/
export class F4AlignDocumentLifecycleConstraints1790000600000 implements MigrationInterface {
name = 'F4AlignDocumentLifecycleConstraints1790000600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_reports
DROP CONSTRAINT IF EXISTS chk_inspection_reports_status
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD CONSTRAINT chk_inspection_reports_status CHECK (
status IN ('WORKING','OFFICIALIZED','FROZEN','CANCELLED')
)
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ALTER COLUMN status SET DEFAULT 'WORKING'
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_status
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_status CHECK (
status IN ('DRAFT','LOCKED','SEALED','READY','CLOSED','CANCELLED','RECTIFIED')
)
`);
// Conservamos los códigos históricos ACTA-* ya emitidos y habilitamos el
// formato F4 realmente generado por InspectionActsService.
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_code
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_code CHECK (
code ~ '^(ACTA-[0-9]{4}-[0-9]{6}|ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2})$'
)
`);
await queryRunner.query(`
ALTER TABLE inspection_act_versions
DROP CONSTRAINT IF EXISTS chk_inspection_act_versions_event
`);
await queryRunner.query(`
ALTER TABLE inspection_act_versions
ADD CONSTRAINT chk_inspection_act_versions_event CHECK (
event IN ('CREATED','UPDATED','LOCKED','SEALED','READY','REOPENED','CLOSED','CANCELLED')
)
`);
// El cierre físico F4 se representa como SEALED. Conservamos CLOSED como
// compatibilidad histórica y exigimos la misma integridad en ambos estados.
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_closure
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_closure CHECK (
status NOT IN ('SEALED','CLOSED')
OR (
closed_at IS NOT NULL
AND closed_by IS NOT NULL
AND closure_sha256 ~ '^[0-9a-f]{64}$'
)
)
`);
// F4 retira Director como destinatario activo y agrega copia al inspector.
// La migración 040 ya eliminó previamente las filas DIRECTOR existentes.
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
DROP CONSTRAINT IF EXISTS chk_inspection_document_deliveries_recipient_kind
`);
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
ADD CONSTRAINT chk_inspection_document_deliveries_recipient_kind CHECK (
recipient_kind IN ('COMPANY','OFFICE','INSPECTOR')
)
`);
// Las firmas F4 pertenecen al snapshot LOCKED. El trigger D5 exigía READY.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE current_status varchar(24);
DECLARE current_sha char(64);
BEGIN
SELECT act.status, closure.prepared_sha256
INTO current_status, current_sha
FROM inspection_acts act
INNER JOIN inspection_act_closures closure ON closure.act_id = act.id
WHERE act.id = NEW.act_id;
IF current_status <> 'LOCKED' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN
RAISE EXCEPTION 'La firma no corresponde a un acta bloqueada vigente';
END IF;
RETURN NEW;
END
$$
`);
// SEALED es tan inmutable como CLOSED/RECTIFIED en el contrato histórico.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF OLD.status IN ('SEALED','CLOSED','RECTIFIED') THEN
RAISE EXCEPTION 'El acta sellada es inmutable';
END IF;
RETURN NEW;
END
$$
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Primero restauramos la guarda D5. De otro modo el propio trigger F4 impediría
// traducir una fila SEALED a CLOSED durante el rollback.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF OLD.status IN ('CLOSED','RECTIFIED') THEN
RAISE EXCEPTION 'El acta cerrada es inmutable';
END IF;
RETURN NEW;
END
$$
`);
// Los códigos F4 se traducen antes de convertir SEALED a CLOSED, porque una vez
// restaurada la guarda D5 una fila CLOSED ya no puede modificarse.
await queryRunner.query(`
UPDATE inspection_acts
SET code='ACTA-' || act_year::text || '-' || LPAD(act_number::text, 6, '0')
WHERE code ~ '^ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}$'
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_code
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_code CHECK (
code ~ '^ACTA-[0-9]{4}-[0-9]{6}$'
)
`);
// El contrato D5 no conoce los estados F4. Reducimos las representaciones
// nuevas a sus equivalentes históricos antes de restaurar sus CHECKs.
await queryRunner.query(`
UPDATE inspection_reports
SET status='FROZEN'
WHERE status IN ('WORKING','OFFICIALIZED')
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ALTER COLUMN status SET DEFAULT 'FROZEN'
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
DROP CONSTRAINT IF EXISTS chk_inspection_reports_status
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD CONSTRAINT chk_inspection_reports_status CHECK (
status IN ('FROZEN','CANCELLED')
)
`);
await queryRunner.query(`
UPDATE inspection_acts
SET status=CASE
WHEN status='LOCKED' THEN 'READY'
WHEN status='SEALED' THEN 'CLOSED'
ELSE status
END
WHERE status IN ('LOCKED','SEALED')
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_status
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_status CHECK (
status IN ('DRAFT','READY','CLOSED','CANCELLED','RECTIFIED')
)
`);
await queryRunner.query(`
UPDATE inspection_act_versions
SET event=CASE
WHEN event='LOCKED' THEN 'READY'
WHEN event='SEALED' THEN 'CLOSED'
ELSE event
END
WHERE event IN ('LOCKED','SEALED')
`);
await queryRunner.query(`
ALTER TABLE inspection_act_versions
DROP CONSTRAINT IF EXISTS chk_inspection_act_versions_event
`);
await queryRunner.query(`
ALTER TABLE inspection_act_versions
ADD CONSTRAINT chk_inspection_act_versions_event CHECK (
event IN ('CREATED','UPDATED','READY','REOPENED','CLOSED','CANCELLED')
)
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_closure
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_closure CHECK (
status <> 'CLOSED'
OR (
closed_at IS NOT NULL
AND closed_by IS NOT NULL
AND closure_sha256 ~ '^[0-9a-f]{64}$'
)
)
`);
// Conservamos INSPECTOR en el CHECK de rollback para no destruir ni falsificar
// entregas F4 ya auditadas. D5 puede seguir operando con sus tres valores y el
// backup PRE-F4 sigue siendo la fuente de una restauración histórica exacta.
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
DROP CONSTRAINT IF EXISTS chk_inspection_document_deliveries_recipient_kind
`);
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
ADD CONSTRAINT chk_inspection_document_deliveries_recipient_kind CHECK (
recipient_kind IN ('COMPANY','OFFICE','DIRECTOR','INSPECTOR')
)
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE current_status varchar(24);
DECLARE current_sha char(64);
BEGIN
SELECT act.status, closure.prepared_sha256
INTO current_status, current_sha
FROM inspection_acts act
INNER JOIN inspection_act_closures closure ON closure.act_id = act.id
WHERE act.id = NEW.act_id;
IF current_status <> 'READY' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN
RAISE EXCEPTION 'La firma no corresponde a un acta preparada vigente';
END IF;
RETURN NEW;
END
$$
`);
}
}
@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4AlignAssetVersionFunctionChange1790000700000 implements MigrationInterface {
name = 'F4AlignAssetVersionFunctionChange1790000700000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE asset_versions
DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type
`);
await queryRunner.query(`
ALTER TABLE asset_versions
ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (
'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED', 'FUNCTION_CHANGED',
'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED',
'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED',
'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED',
'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'
))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE asset_versions
SET change_type='UPDATED'
WHERE change_type='FUNCTION_CHANGED'
`);
await queryRunner.query(`
ALTER TABLE asset_versions
DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type
`);
await queryRunner.query(`
ALTER TABLE asset_versions
ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (
'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED',
'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED',
'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED',
'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED',
'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'
))
`);
}
}
@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F5InventoryPhysicalInstance1790087100000 implements MigrationInterface {
name = 'F5InventoryPhysicalInstance1790087100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE assets
ADD COLUMN is_inventory_instance boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
CREATE INDEX idx_assets_inventory_instance_active
ON assets (is_inventory_instance, information_status)
WHERE is_inventory_instance = true
`);
await queryRunner.query(`
COMMENT ON COLUMN assets.is_inventory_instance IS
'True only for a concrete Instalacion/Subinstalacion instance. Empresa, Area and Yacimiento are structural/context masters.'
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION classify_new_asset_inventory_instance()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
type_code varchar;
BEGIN
SELECT lower(code)
INTO type_code
FROM asset_types
WHERE id = NEW.asset_type_id;
NEW.is_inventory_instance := COALESCE(type_code, '') IN ('instalacion', 'subinstalacion');
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_assets_classify_inventory_instance
BEFORE INSERT OR UPDATE OF asset_type_id ON assets
FOR EACH ROW EXECUTE FUNCTION classify_new_asset_inventory_instance()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_assets_classify_inventory_instance ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS classify_new_asset_inventory_instance()');
await queryRunner.query('DROP INDEX IF EXISTS idx_assets_inventory_instance_active');
await queryRunner.query('ALTER TABLE assets DROP COLUMN IF EXISTS is_inventory_instance');
}
}
@@ -0,0 +1,192 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type TypeRow = {
id: string;
code: string;
role: string;
active: boolean;
canBeRoot: boolean;
};
type RuleRow = {
childTypeId: string;
parentTypeId: string;
};
type CountRow = { total: number };
const CREATED_TYPES_TABLE = 'f5_canonical_hierarchy_created_types';
const CREATED_RULES_TABLE = 'f5_canonical_hierarchy_created_rules';
const CANONICAL_TYPES = [
{
code: 'yacimiento',
name: 'Yacimiento',
description: 'Yacimiento perteneciente a un Área.',
},
{
code: 'instalacion',
name: 'Instalación',
description: 'Instancia física de una Instalación dentro de un Yacimiento.',
},
{
code: 'subinstalacion',
name: 'Subinstalación',
description: 'Instancia física subordinada a una Instalación.',
},
] as const;
const CANONICAL_RULES = [
['yacimiento', 'area'],
['instalacion', 'yacimiento'],
['subinstalacion', 'instalacion'],
] as const;
export class F5CanonicalInventoryHierarchy1790087150000 implements MigrationInterface {
name = 'F5CanonicalInventoryHierarchy1790087150000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE ${CREATED_TYPES_TABLE} (
type_id uuid PRIMARY KEY,
code varchar(80) NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_f5_canonical_created_type FOREIGN KEY (type_id)
REFERENCES asset_types(id) ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE TABLE ${CREATED_RULES_TABLE} (
child_type_id uuid NOT NULL,
parent_type_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (child_type_id,parent_type_id),
CONSTRAINT fk_f5_canonical_created_rule_child FOREIGN KEY (child_type_id)
REFERENCES asset_types(id) ON DELETE CASCADE,
CONSTRAINT fk_f5_canonical_created_rule_parent FOREIGN KEY (parent_type_id)
REFERENCES asset_types(id) ON DELETE CASCADE
)
`);
const area = await this.requireType(queryRunner, 'area');
if (area.role !== 'AREA' || !area.active || !area.canBeRoot) {
throw new Error('F5 requires canonical active root type area with AREA operational role');
}
for (const definition of CANONICAL_TYPES) {
const inserted = (await queryRunner.query(`
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
SELECT $1::varchar,$2::varchar,$3::text,false,true,'GENERIC'
WHERE NOT EXISTS (
SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar)
)
RETURNING id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
`, [definition.code, definition.name, definition.description])) as TypeRow[];
if (inserted[0]?.id) {
await queryRunner.query(`
INSERT INTO ${CREATED_TYPES_TABLE}(type_id,code)
VALUES ($1::uuid,$2::varchar)
`, [inserted[0].id, definition.code]);
}
const type = await this.requireType(queryRunner, definition.code);
if (type.role !== 'GENERIC' || !type.active || type.canBeRoot) {
throw new Error(`F5 incompatible canonical type configuration: ${definition.code}`);
}
}
for (const [childCode, parentCode] of CANONICAL_RULES) {
const inserted = (await queryRunner.query(`
INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id)
SELECT child.id,parent.id
FROM asset_types child CROSS JOIN asset_types parent
WHERE lower(child.code)=lower($1::varchar)
AND lower(parent.code)=lower($2::varchar)
AND NOT EXISTS (
SELECT 1 FROM asset_type_parent_rules existing
WHERE existing.child_type_id=child.id AND existing.parent_type_id=parent.id
)
RETURNING child_type_id AS "childTypeId",parent_type_id AS "parentTypeId"
`, [childCode, parentCode])) as RuleRow[];
if (inserted[0]?.childTypeId && inserted[0]?.parentTypeId) {
await queryRunner.query(`
INSERT INTO ${CREATED_RULES_TABLE}(child_type_id,parent_type_id)
VALUES ($1::uuid,$2::uuid)
`, [inserted[0].childTypeId, inserted[0].parentTypeId]);
}
}
const [verified] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE (lower(child.code)='yacimiento' AND lower(parent.code)='area')
OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento')
OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion')
`)) as CountRow[];
if (Number(verified?.total ?? 0) !== 3) {
throw new Error(`F5 canonical hierarchy verification failed: rules=${Number(verified?.total ?? 0)}`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const [usedCreatedTypes] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN ${CREATED_TYPES_TABLE} owned ON owned.type_id=asset.asset_type_id
`)) as CountRow[];
if (Number(usedCreatedTypes?.total ?? 0) !== 0) {
throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type is already used by Inventory');
}
const [foreignRules] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM asset_type_parent_rules rule
WHERE (
rule.child_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE})
OR rule.parent_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE})
)
AND NOT EXISTS (
SELECT 1 FROM ${CREATED_RULES_TABLE} owned
WHERE owned.child_type_id=rule.child_type_id
AND owned.parent_type_id=rule.parent_type_id
)
`)) as CountRow[];
if (Number(foreignRules?.total ?? 0) !== 0) {
throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type gained external parent rules');
}
await queryRunner.query(`
DELETE FROM asset_type_parent_rules rule
USING ${CREATED_RULES_TABLE} owned
WHERE rule.child_type_id=owned.child_type_id
AND rule.parent_type_id=owned.parent_type_id
`);
await queryRunner.query(`
DELETE FROM asset_types type
USING ${CREATED_TYPES_TABLE} owned
WHERE type.id=owned.type_id
`);
await queryRunner.query(`DROP TABLE ${CREATED_RULES_TABLE}`);
await queryRunner.query(`DROP TABLE ${CREATED_TYPES_TABLE}`);
}
private async requireType(queryRunner: QueryRunner, code: string): Promise<TypeRow> {
const rows = (await queryRunner.query(`
SELECT id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
FROM asset_types
WHERE lower(code)=lower($1::varchar)
ORDER BY created_at
`, [code])) as TypeRow[];
if (rows.length !== 1) {
throw new Error(`F5 requires exactly one canonical asset type ${code}; found ${rows.length}`);
}
return rows[0];
}
}
@@ -0,0 +1,602 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import { loadF5InventoryAuthoritativeSource } from '../../reference-data/f5-authoritative-inventory-source';
type IdRow = { id: string };
type CountRow = { total: number };
const TERRITORY_DOCUMENT_NUMBER = 'DH-F5-TERRITORY';
const TERRITORY_SOURCE_NAME = 'Tablas de yacimiento y areas.xlsx';
const BACKUP_TABLE = 'f5_territory_relation_backups';
function key(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function code(prefix: string, value: string, length = 12): string {
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`;
}
function uniqueBy<T>(values: T[], identity: (value: T) => string): T[] {
const seen = new Set<string>();
const output: T[] = [];
for (const value of values) {
const id = identity(value);
if (seen.has(id)) continue;
seen.add(id);
output.push(value);
}
return output;
}
export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface {
name = 'F5AuthoritativeTerritory1790087200000';
public async up(queryRunner: QueryRunner): Promise<void> {
const source = loadF5InventoryAuthoritativeSource();
if (
source.areaSource.file !== TERRITORY_SOURCE_NAME
|| source.areaSource.sheet !== 'cr26e_tabla1'
|| source.areaSource.sha256 !== '8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78'
|| source.areaSource.rows.length !== 230
) {
throw new Error('F5 territory source contract mismatch');
}
const rows = source.areaSource.rows;
const areaRows = uniqueBy(rows, (row) => key(row.area));
const pairRows = uniqueBy(rows, (row) => `${key(row.area)}|${key(row.yacimiento)}`);
if (areaRows.length !== 64 || pairRows.length !== 230) {
throw new Error(`F5 territory cardinality mismatch: areas=${areaRows.length}, pairs=${pairRows.length}`);
}
// Every Area must have one unambiguous source context. The workbook is the
// only authority for this preload; conflicting rows must abort the migration.
for (const areaRow of areaRows) {
const sameArea = rows.filter((row) => key(row.area)===key(areaRow.area));
const dimensions = [
new Set(sameArea.map((row) => key(row.departamento))),
new Set(sameArea.map((row) => key(row.tipoConcesion))),
new Set(sameArea.map((row) => key(row.empresaOperadora))),
];
if (dimensions.some((values) => values.size !== 1)) {
throw new Error(`F5 territory source has conflicting Area context: ${areaRow.area}`);
}
}
await this.assertCanonicalTypesAndRules(queryRunner);
await this.installHierarchyGuard(queryRunner);
await this.ensureBackupTable(queryRunner);
const preExistingDocument = await this.optionalId(
queryRunner,
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
[TERRITORY_DOCUMENT_NUMBER],
);
if (preExistingDocument) {
throw new Error('F5 territory source document already exists before migration');
}
const insertedDocument = (await queryRunner.query(`
INSERT INTO source_documents (
document_type, document_number, title, issuer, external_reference, notes
) VALUES ('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4)
RETURNING id
`, [
TERRITORY_DOCUMENT_NUMBER,
TERRITORY_SOURCE_NAME,
`sha256:${source.areaSource.sha256}`,
`F5 · fuente territorial autorizada · hoja ${source.areaSource.sheet} · ${rows.length} filas`,
])) as IdRow[];
const sourceDocumentId = insertedDocument[0]?.id;
if (!sourceDocumentId) throw new Error('F5 could not create territory source document');
const companyTypeId = await this.id(
queryRunner,
`SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`,
[],
'COMPANY asset type',
);
const areaTypeId = await this.id(
queryRunner,
`SELECT id FROM asset_types WHERE operational_role='AREA' AND is_active=true ORDER BY (lower(code)='area') DESC,created_at LIMIT 1`,
[],
'AREA asset type',
);
const fieldTypeId = await this.id(
queryRunner,
`SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`,
[],
'Yacimiento asset type',
);
const companyNames = [...new Set(rows.map((row) => row.empresaOperadora.trim()))]
.filter((name) => name && key(name) !== key('Sin Empresa Operadora'))
.sort((a, b) => a.localeCompare(b, 'es'));
const companyIds = new Map<string, string>();
for (const companyName of companyNames) {
const companyId = await this.ensureRootAsset(queryRunner, {
typeId: companyTypeId,
role: 'COMPANY',
code: code('F5-ORG', key(companyName)),
name: companyName,
sourceDocumentId,
sourceReference: `F5:TERRITORY:COMPANY:${code('SRC', key(companyName), 10)}`,
});
companyIds.set(key(companyName), companyId);
await queryRunner.query(`
INSERT INTO organization_profiles (asset_id,organization_kind,legal_name)
VALUES ($1::uuid,$2::organization_kind,$3)
ON CONFLICT (asset_id) DO NOTHING
`, [companyId, companyName.trim().toUpperCase().startsWith('UTE (') ? 'UTE' : 'COMPANY', companyName]);
}
const departmentIds = new Map<string, string>();
for (const departmentName of [...new Set(rows.map((row) => row.departamento.trim()))].sort((a,b)=>a.localeCompare(b,'es'))) {
const normalized = key(departmentName);
let departmentId = await this.optionalId(queryRunner, `
SELECT id FROM administrative_departments
WHERE province_code='MENDOZA' AND normalized_name=$1 AND is_active=true
LIMIT 1
`, [normalized]);
if (!departmentId) {
const inserted = (await queryRunner.query(`
INSERT INTO administrative_departments (
province_code,code,name,normalized_name,is_active,source_document_id
) VALUES ('MENDOZA',$1,$2,$3,true,$4::uuid)
RETURNING id
`, [code('F5-DEP', normalized, 10), departmentName, normalized, sourceDocumentId])) as IdRow[];
departmentId=inserted[0]?.id ?? null;
}
if (!departmentId) throw new Error(`F5 could not seed department ${departmentName}`);
departmentIds.set(normalized, departmentId);
}
const areaIds = new Map<string, string>();
for (const areaRow of areaRows) {
const areaId = await this.ensureRootAsset(queryRunner, {
typeId: areaTypeId,
role: 'AREA',
code: code('F5-AREA', key(areaRow.area)),
name: areaRow.area,
sourceDocumentId,
sourceReference: `F5:TERRITORY:AREA:${code('SRC', key(areaRow.area), 10)}`,
});
areaIds.set(key(areaRow.area), areaId);
const departmentId = departmentIds.get(key(areaRow.departamento));
if (!departmentId) throw new Error(`F5 missing department ${areaRow.departamento}`);
await this.backupAndCloseDepartmentRelations(queryRunner,areaId,departmentId);
await queryRunner.query(`
INSERT INTO area_department_relations (
area_id,department_id,valid_from,source_document_id,notes
)
SELECT $1::uuid,$2::uuid,CURRENT_DATE,$3::uuid,$4
WHERE NOT EXISTS (
SELECT 1 FROM area_department_relations
WHERE area_id=$1::uuid AND department_id=$2::uuid AND valid_until IS NULL
)
`, [
areaId,
departmentId,
sourceDocumentId,
`F5 · ${TERRITORY_SOURCE_NAME} · ${source.areaSource.sheet}`,
]);
const operatorId = companyIds.get(key(areaRow.empresaOperadora)) ?? null;
await this.backupAndCloseOperatorRelations(queryRunner,areaId,operatorId);
if (operatorId) {
await queryRunner.query(`
INSERT INTO area_company_relations (
area_id,company_id,relation_role,source_document_id,valid_from,start_reason
)
SELECT $1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4
WHERE NOT EXISTS (
SELECT 1 FROM area_company_relations
WHERE area_id=$1::uuid AND company_id=$2::uuid
AND relation_role='OPERATOR' AND valid_until IS NULL
)
`, [
areaId,
operatorId,
sourceDocumentId,
`F5 · operadora vigente según ${TERRITORY_SOURCE_NAME}`,
]);
}
const rightType = areaRow.tipoConcesion === 'Exploración'
? 'EXPLORATION_PERMIT'
: areaRow.tipoConcesion === 'Explotación'
? 'EXPLOITATION_CONCESSION'
: 'OTHER';
const rightName = `${areaRow.tipoConcesion} · ${areaRow.area}`;
await queryRunner.query(`
INSERT INTO area_legal_rights (
area_id,right_type,name,status,source_document_id,notes
)
SELECT $1::uuid,$2::area_legal_right_type,$3::varchar,'ACTIVE',$4::uuid,$5
WHERE NOT EXISTS (
SELECT 1 FROM area_legal_rights
WHERE area_id=$1::uuid
AND right_type=$2::area_legal_right_type
AND lower(btrim(name))=lower(btrim($3::varchar))
AND status IN ('ACTIVE','PENDING')
)
`, [
areaId,
rightType,
rightName,
sourceDocumentId,
`F5 · tipo de concesión tomado literalmente de ${TERRITORY_SOURCE_NAME}`,
]);
}
for (const row of pairRows) {
const areaId = areaIds.get(key(row.area));
if (!areaId) throw new Error(`F5 missing area ${row.area}`);
const sourceReference = `F5:TERRITORY:YAC:${code('SRC', `${key(row.area)}|${key(row.yacimiento)}`, 12)}`;
let yacimientoId = await this.optionalId(queryRunner, `
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)='yacimiento'
AND asset.parent_id=$1::uuid
AND asset.information_status<>'INACTIVE'
AND lower(btrim(asset.name))=lower(btrim($2))
ORDER BY asset.created_at
LIMIT 1
`, [areaId, row.yacimiento]);
if (!yacimientoId) {
const inserted = (await queryRunner.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,
code,name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,is_inventory_instance
) VALUES (
$1::uuid,$2::uuid,NULL,NULL,$3,$4,$5,'VALIDATED','UNKNOWN',
'PROVIDED_DOCUMENT',$6,$7,$8,false
) RETURNING id
`, [
fieldTypeId,
areaId,
code('F5-YAC', `${key(row.area)}|${key(row.yacimiento)}`),
row.yacimiento,
`Yacimiento del Área ${row.area}`,
TERRITORY_SOURCE_NAME,
sourceReference,
`${source.areaSource.sheet} · fila ${row.sourceRow}`,
])) as IdRow[];
yacimientoId = inserted[0]?.id ?? null;
}
if (!yacimientoId) throw new Error(`F5 could not seed yacimiento ${row.area} / ${row.yacimiento}`);
await this.linkSource(queryRunner, yacimientoId, sourceDocumentId, `Hoja ${source.areaSource.sheet} · fila ${row.sourceRow}`);
}
const [verification] = (await queryRunner.query(`
SELECT
COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA')::integer AS areas,
COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')::integer AS yacimientos
FROM asset_source_documents link
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE link.document_id=$1::uuid
`, [sourceDocumentId])) as Array<{ areas: number; yacimientos: number }>;
if (Number(verification?.areas ?? 0) !== 64 || Number(verification?.yacimientos ?? 0) !== 230) {
throw new Error(`F5 territory preload verification failed: ${JSON.stringify(verification ?? {})}`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const sourceDocumentId = await this.optionalId(
queryRunner,
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
[TERRITORY_DOCUMENT_NUMBER],
);
if (!sourceDocumentId) {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()');
await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`);
return;
}
await this.assertRelationBackupsUnchanged(queryRunner);
await this.assertCreatedMastersUnused(queryRunner,sourceDocumentId);
await queryRunner.query(`DELETE FROM area_legal_rights WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
// Remove relations created by F5 first so restoring the previously-active
// relation cannot violate active-relation uniqueness constraints.
await queryRunner.query(`DELETE FROM area_company_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query(`DELETE FROM area_department_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
await this.restoreRelationBackups(queryRunner);
await queryRunner.query(`DELETE FROM asset_source_documents WHERE document_id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:YAC:%'`);
await queryRunner.query(`DELETE FROM organization_profiles profile USING assets asset WHERE profile.asset_id=asset.id AND asset.source_reference LIKE 'F5:TERRITORY:COMPANY:%'`);
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:COMPANY:%'`);
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:AREA:%'`);
await queryRunner.query(`DELETE FROM administrative_departments WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query(`DELETE FROM source_documents WHERE id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()');
await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`);
}
private async assertCanonicalTypesAndRules(queryRunner: QueryRunner): Promise<void> {
const [roles] = (await queryRunner.query(`
SELECT
COUNT(*) FILTER (WHERE operational_role='AREA' AND is_active=true)::integer AS areas,
COUNT(*) FILTER (WHERE operational_role='COMPANY' AND is_active=true)::integer AS companies
FROM asset_types
`)) as Array<{areas:number; companies:number}>;
if (Number(roles?.areas ?? 0)<1 || Number(roles?.companies ?? 0)<1) {
throw new Error('F5 requires active AREA and COMPANY master types');
}
for (const [typeCode,typeName,description] of [
['yacimiento','Yacimiento','Yacimiento perteneciente a un Área.'],
['instalacion','Instalación','Instancia física de una Instalación dentro de un Yacimiento.'],
['subinstalacion','Subinstalación','Instancia física subordinada a una Instalación.'],
] as const) {
await queryRunner.query(`
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
SELECT $1::varchar,$2,$3,false,true,'GENERIC'
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar))
`,[typeCode,typeName,description]);
const [type] = (await queryRunner.query(`
SELECT operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
FROM asset_types WHERE lower(code)=lower($1) LIMIT 1
`,[typeCode])) as Array<{role:string;active:boolean;canBeRoot:boolean}>;
if (!type || type.role!=='GENERIC' || !type.active || type.canBeRoot) {
throw new Error(`F5 incompatible master type configuration: ${typeCode}`);
}
}
const [ruleCount] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE (lower(child.code)='yacimiento' AND lower(parent.code)='area')
OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento')
OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion')
`)) as CountRow[];
if (Number(ruleCount?.total ?? 0)!==3) {
throw new Error('F5 requires canonical parent rules Area → Yacimiento → Instalación → Subinstalación');
}
}
private async installHierarchyGuard(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_f5_canonical_asset_hierarchy()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE child_code text; parent_code text;
BEGIN
SELECT lower(code) INTO child_code FROM asset_types WHERE id=NEW.asset_type_id;
IF child_code IN ('empresa','organizacion','area') THEN
IF NEW.parent_id IS NOT NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Área son maestros raíz independientes';
END IF;
RETURN NEW;
END IF;
IF child_code NOT IN ('yacimiento','instalacion','subinstalacion') THEN RETURN NEW; END IF;
IF NEW.parent_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento, Instalación y Subinstalación requieren padre';
END IF;
SELECT lower(type.code) INTO parent_code
FROM assets parent JOIN asset_types type ON type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id;
IF (child_code='yacimiento' AND parent_code<>'area')
OR (child_code='instalacion' AND parent_code<>'yacimiento')
OR (child_code='subinstalacion' AND parent_code<>'instalacion') THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Jerarquía F5 inválida: Área → Yacimiento → Instalación → Subinstalación';
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
await queryRunner.query(`
CREATE TRIGGER trg_f5_canonical_asset_hierarchy
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id ON assets
FOR EACH ROW EXECUTE FUNCTION enforce_f5_canonical_asset_hierarchy()
`);
}
private async ensureBackupTable(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE ${BACKUP_TABLE} (
relation_kind varchar(32) NOT NULL,
relation_id uuid NOT NULL,
previous_values jsonb NOT NULL,
applied_values jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (relation_kind,relation_id),
CONSTRAINT chk_f5_territory_backup_kind CHECK (relation_kind IN ('AREA_COMPANY','AREA_DEPARTMENT'))
)
`);
}
private async backupAndCloseDepartmentRelations(queryRunner: QueryRunner,areaId:string,departmentId:string):Promise<void> {
const marker='F5: reemplazada por fuente territorial autorizada';
await queryRunner.query(`
INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values)
SELECT 'AREA_DEPARTMENT',relation.id,
jsonb_build_object('validUntil',relation.valid_until,'notes',relation.notes),
jsonb_build_object('validUntil',CURRENT_DATE,'notes',concat_ws(E'\n',relation.notes,$3::text))
FROM area_department_relations relation
WHERE relation.area_id=$1::uuid
AND relation.valid_until IS NULL
AND relation.department_id<>$2::uuid
ON CONFLICT DO NOTHING
`,[areaId,departmentId,marker]);
await queryRunner.query(`
UPDATE area_department_relations relation
SET valid_until=(backup.applied_values->>'validUntil')::date,
notes=backup.applied_values->>'notes',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_DEPARTMENT'
AND backup.relation_id=relation.id
AND relation.area_id=$1::uuid
AND relation.valid_until IS NULL
`,[areaId]);
}
private async backupAndCloseOperatorRelations(queryRunner: QueryRunner,areaId:string,operatorId:string|null):Promise<void> {
const marker='F5: reemplazada por fuente territorial autorizada';
await queryRunner.query(`
INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values)
SELECT 'AREA_COMPANY',relation.id,
jsonb_build_object('validUntil',relation.valid_until,'endReason',relation.end_reason),
jsonb_build_object('validUntil',CURRENT_TIMESTAMP,'endReason',$3::text)
FROM area_company_relations relation
WHERE relation.area_id=$1::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND ($2::uuid IS NULL OR relation.company_id<>$2::uuid)
ON CONFLICT DO NOTHING
`,[areaId,operatorId,marker]);
await queryRunner.query(`
UPDATE area_company_relations relation
SET valid_until=(backup.applied_values->>'validUntil')::timestamptz,
end_reason=backup.applied_values->>'endReason',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_COMPANY'
AND backup.relation_id=relation.id
AND relation.area_id=$1::uuid
AND relation.valid_until IS NULL
`,[areaId]);
}
private async assertRelationBackupsUnchanged(queryRunner: QueryRunner):Promise<void> {
const [changed] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM ${BACKUP_TABLE} backup
LEFT JOIN area_company_relations company_relation
ON backup.relation_kind='AREA_COMPANY' AND company_relation.id=backup.relation_id
LEFT JOIN area_department_relations department_relation
ON backup.relation_kind='AREA_DEPARTMENT' AND department_relation.id=backup.relation_id
WHERE (
backup.relation_kind='AREA_COMPANY'
AND (
company_relation.id IS NULL
OR company_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::timestamptz
OR company_relation.end_reason IS DISTINCT FROM backup.applied_values->>'endReason'
)
) OR (
backup.relation_kind='AREA_DEPARTMENT'
AND (
department_relation.id IS NULL
OR department_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::date
OR department_relation.notes IS DISTINCT FROM backup.applied_values->>'notes'
)
)
`)) as CountRow[];
if (Number(changed?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 territory: a relation closed by the preload was modified afterwards');
}
}
private async restoreRelationBackups(queryRunner: QueryRunner):Promise<void> {
await queryRunner.query(`
UPDATE area_company_relations relation
SET valid_until=(backup.previous_values->>'validUntil')::timestamptz,
end_reason=backup.previous_values->>'endReason',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_COMPANY' AND backup.relation_id=relation.id
`);
await queryRunner.query(`
UPDATE area_department_relations relation
SET valid_until=(backup.previous_values->>'validUntil')::date,
notes=backup.previous_values->>'notes',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_DEPARTMENT' AND backup.relation_id=relation.id
`);
}
private async assertCreatedMastersUnused(queryRunner:QueryRunner,sourceDocumentId:string):Promise<void> {
const [used] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
WHERE asset.source_reference LIKE 'F5:TERRITORY:%'
AND (
EXISTS (SELECT 1 FROM assets child WHERE child.parent_id=asset.id AND child.source_reference NOT LIKE 'F5:TERRITORY:%')
OR EXISTS (SELECT 1 FROM inspection_visits visit WHERE visit.operational_area_id=asset.id OR visit.operator_company_id=asset.id)
OR EXISTS (SELECT 1 FROM inspection_visit_assets link WHERE link.asset_id=asset.id)
OR EXISTS (SELECT 1 FROM inspection_findings finding WHERE finding.asset_id=asset.id)
)
`)) as CountRow[];
if (Number(used?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 territory: F5-created master data is already used by operational records');
}
void sourceDocumentId;
}
private async ensureRootAsset(
queryRunner: QueryRunner,
input: {
typeId: string;
role: 'AREA' | 'COMPANY';
code: string;
name: string;
sourceDocumentId: string;
sourceReference: string;
},
): Promise<string> {
let assetId = await this.optionalId(queryRunner, `
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role=$1::asset_type_operational_role
AND asset.information_status<>'INACTIVE'
AND lower(btrim(asset.name))=lower(btrim($2))
ORDER BY asset.created_at
LIMIT 1
`,[input.role,input.name]);
if (!assetId) {
const inserted = (await queryRunner.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,
code,name,information_status,operational_status,data_origin,
source_name,source_reference,source_notes,is_inventory_instance
) VALUES ($1::uuid,NULL,NULL,NULL,$2,$3,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$4,$5,$6,false)
RETURNING id
`,[
input.typeId,input.code,input.name,TERRITORY_SOURCE_NAME,input.sourceReference,
'F5 · fuente territorial autorizada',
])) as IdRow[];
assetId=inserted[0]?.id ?? null;
}
if (!assetId) throw new Error(`F5 could not seed ${input.role} ${input.name}`);
await this.linkSource(queryRunner,assetId,input.sourceDocumentId,'F5 · fuente territorial autorizada');
return assetId;
}
private async linkSource(queryRunner:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void> {
await queryRunner.query(`
INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes)
VALUES ($1::uuid,$2::uuid,'SOURCE',$3)
ON CONFLICT (asset_id,document_id,relation_type) DO UPDATE SET notes=EXCLUDED.notes,updated_at=CURRENT_TIMESTAMP
`,[assetId,documentId,notes]);
}
private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise<string> {
const value=await this.optionalId(queryRunner,sql,params);
if (!value) throw new Error(`F5 could not resolve ${label}`);
return value;
}
private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise<string|null> {
const result=(await queryRunner.query(sql,params)) as IdRow[];
return result[0]?.id ?? null;
}
}
@@ -0,0 +1,182 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* F5 makes the physical hierarchy Area-owned. Empresa is never a parent nor a
* required property of Yacimiento/Instalación/Subinstalación. The current and
* historical operator/concession truth lives in area_company_relations and is
* frozen separately by each Inspección/Acta.
*
* operator_company_id is retained only as a backwards-compatible creation/
* historical snapshot. Runtime ownership and search MUST NOT depend on it.
*/
export class F5OperationalContextCompatibility1790087250000 implements MigrationInterface {
name = 'F5OperationalContextCompatibility1790087250000';
public async up(queryRunner: QueryRunner): Promise<void> {
await this.installAreaOwnedGuard(queryRunner);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await this.installLegacyPairedGuard(queryRunner);
}
private async installAreaOwnedGuard(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_asset_operational_context()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
asset_role asset_type_operational_role;
area_role asset_type_operational_role;
company_role asset_type_operational_role;
active_relation_id uuid;
BEGIN
SELECT operational_role INTO asset_role
FROM asset_types WHERE id=NEW.asset_type_id;
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN
IF NEW.operational_area_id IS NOT NULL OR NEW.operator_company_id IS NOT NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='Área y Empresa no reciben contexto operativo de Inventario';
END IF;
RETURN NEW;
END IF;
IF NEW.operator_company_id IS NOT NULL AND NEW.operational_area_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='Un snapshot de Empresa requiere un Área física';
END IF;
-- Once written, an old/current company snapshot cannot be repointed to
-- simulate physical ownership. Company changes happen in the temporal
-- Area↔Empresa relation instead.
IF TG_OP='UPDATE'
AND NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='La Empresa se cambia en la relación temporal del Área, no en el Inventario';
END IF;
IF NEW.operational_area_id IS NULL THEN
RETURN NEW;
END IF;
SELECT type.operational_role INTO area_role
FROM assets area
JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.id=NEW.operational_area_id
AND area.information_status<>'INACTIVE'
AND type.is_active=true;
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='operational area must be an active AREA asset';
END IF;
IF NEW.parent_id IS NULL OR NOT EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=NEW.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent
JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1
) THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='operational area must be an ancestor in the physical hierarchy';
END IF;
-- A company value is allowed only as the context snapshot that was valid
-- at creation time. It is never used to decide future membership.
IF NEW.operator_company_id IS NOT NULL THEN
SELECT type.operational_role INTO company_role
FROM assets company
JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id
AND company.information_status<>'INACTIVE'
AND type.is_active=true;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='operator snapshot must reference an active COMPANY-role asset';
END IF;
IF TG_OP='INSERT' THEN
SELECT relation.id INTO active_relation_id
FROM area_company_relations relation
WHERE relation.area_id=NEW.operational_area_id
AND relation.company_id=NEW.operator_company_id
AND relation.relation_role='OPERATOR'::area_organization_role
AND relation.valid_until IS NULL
FOR KEY SHARE;
IF active_relation_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='creation operator snapshot must be active for the selected Area';
END IF;
END IF;
END IF;
RETURN NEW;
END $$;
`);
}
/** Restores the production F4-era paired Area+Empresa guard on rollback. */
private async installLegacyPairedGuard(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_asset_operational_context()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
asset_role asset_type_operational_role;
area_role asset_type_operational_role;
company_role asset_type_operational_role;
active_relation_id uuid;
BEGIN
IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN
RETURN NEW;
END IF;
IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='operational area and organization must be assigned together';
END IF;
SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area and organization assets cannot receive an operational assignment';
END IF;
SELECT type.operational_role INTO area_role
FROM assets area JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.id=NEW.operational_area_id AND area.information_status<>'INACTIVE' AND type.is_active=true;
SELECT type.operational_role INTO company_role
FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset';
END IF;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operator organization must be an active COMPANY-role asset';
END IF;
SELECT relation.id INTO active_relation_id
FROM area_company_relations relation
WHERE relation.area_id=NEW.operational_area_id
AND relation.company_id=NEW.operator_company_id
AND relation.relation_role='OPERATOR'::area_organization_role
AND relation.valid_until IS NULL
FOR KEY SHARE;
IF active_relation_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization do not have an active OPERATOR relation';
END IF;
IF NEW.parent_id IS NULL OR NOT EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=NEW.parent_id
UNION ALL
SELECT parent.id,parent.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1
) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy';
END IF;
RETURN NEW;
END $$;
`);
}
}
@@ -0,0 +1,606 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import {
loadF5InventoryAuthoritativeSource,
type F5InstallationCatalogRow,
type F5SubinstallationCatalogRow,
} from '../../reference-data/f5-authoritative-inventory-source';
type IdRow = { id: string };
type CountRow = { total: number };
const CATALOG_DOCUMENT_NUMBER = 'DH-F5-INVENTORY-CATALOG';
const CATALOG_CATEGORY_CODE = 'F5MODEL';
const CATALOG_SOURCE_NAME = 'final_modelov2.xlsx';
const F5_AUTO_REASON = 'F5 familia técnica: catálogo contextual automático';
const F5_SOURCE_FAMILY_COUNT = 123;
function findingKey(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function hashCode(prefix: string, value: string, length = 12): string {
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`;
}
function installationCode(name: string): string {
return hashCode('F5-I', findingKey(name));
}
function subinstallationCode(installation: string, name: string): string {
return hashCode('F5-S', `${findingKey(installation)}|${findingKey(name)}`);
}
function subOtherCode(parentCode: string): string {
return hashCode('F5-S-OTRO', parentCode);
}
export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationInterface {
name = 'F5AuthoritativeInventoryCatalog1790087300000';
public async up(queryRunner: QueryRunner): Promise<void> {
const source = loadF5InventoryAuthoritativeSource();
if (
source.catalogSource.file !== CATALOG_SOURCE_NAME
|| source.catalogSource.sheet !== 'Hoja1'
|| source.catalogSource.sha256 !== 'c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155'
|| source.catalogSource.installations.length !== 14
|| source.catalogSource.subinstallations.length !== 109
) {
throw new Error('F5 inventory catalog source contract mismatch');
}
if (source.catalogSource.universalFindings.length !== 3) {
throw new Error(`F5 universal finding contract mismatch: ${source.catalogSource.universalFindings.length}`);
}
const universalKeys = new Set(source.catalogSource.universalFindings.map(findingKey));
for (const required of [
'ORDEN Y LIMPIEZA',
'CARTELERIA PREVENTIVA / INFORMATIVA',
'EXTINTORES',
]) {
if (!universalKeys.has(findingKey(required))) {
throw new Error(`F5 missing authoritative universal finding: ${required}`);
}
}
await queryRunner.query(`
INSERT INTO source_documents (
document_type,document_number,title,issuer,external_reference,notes
)
VALUES ('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4)
ON CONFLICT (document_number,issuer) WHERE document_number IS NOT NULL AND issuer IS NOT NULL
DO UPDATE SET
title=EXCLUDED.title,
external_reference=EXCLUDED.external_reference,
notes=EXCLUDED.notes,
updated_at=CURRENT_TIMESTAMP
`, [
CATALOG_DOCUMENT_NUMBER,
CATALOG_SOURCE_NAME,
`sha256:${source.catalogSource.sha256}`,
`F5 · catálogo técnico autorizado · hoja ${source.catalogSource.sheet} · 14 Instalaciones · 109 Subinstalaciones`,
]);
// Only the known historical spreadsheet catalog is superseded. Families
// created manually by DH (including source_reference NULL) remain untouched.
await queryRunner.query(`
UPDATE inventory_families
SET is_active=false,updated_at=CURRENT_TIMESTAMP
WHERE source_reference LIKE 'APLICACION APP%'
OR source_reference LIKE 'SYSTEM:F3.1:%'
`);
await queryRunner.query(`
UPDATE finding_categories
SET is_active=false,updated_at=CURRENT_TIMESTAMP
WHERE lower(code) IN ('app26','app26r2')
`);
const installationIds = new Map<string,string>();
for (const installation of source.catalogSource.installations) {
const familyId = await this.upsertFamily(
queryRunner,
installationCode(installation.name),
installation.name,
'INSTALLATION',
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${installation.sourceStartRow}-${installation.sourceEndRow}`,
);
installationIds.set(findingKey(installation.name),familyId);
}
for (const subinstallation of source.catalogSource.subinstallations) {
const parentId = installationIds.get(findingKey(subinstallation.installation));
if (!parentId) throw new Error(`F5 missing installation family ${subinstallation.installation}`);
const childId = await this.upsertFamily(
queryRunner,
subinstallationCode(subinstallation.installation,subinstallation.name),
subinstallation.name,
'SUBINSTALLATION',
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${subinstallation.sourceStartRow}-${subinstallation.sourceEndRow}${subinstallation.reference ? `|reference:${subinstallation.reference}` : ''}`,
);
await this.parentRule(queryRunner,childId,parentId);
}
const installationOtherId = await this.upsertFamily(
queryRunner,
'F5-I-OTRO',
'Otro / no catalogado',
'INSTALLATION',
'F5:SYSTEM:OTHER:INSTALLATION',
);
for (const [installationKey,parentId] of installationIds) {
const parent = source.catalogSource.installations.find((item) => findingKey(item.name)===installationKey);
if (!parent) continue;
const childId = await this.upsertFamily(
queryRunner,
subOtherCode(installationCode(parent.name)),
'Otro / no catalogado',
'SUBINSTALLATION',
`F5:SYSTEM:OTHER:SUBINSTALLATION:${installationCode(parent.name)}`,
);
await this.parentRule(queryRunner,childId,parentId);
}
const rootOtherChild = await this.upsertFamily(
queryRunner,
subOtherCode('F5-I-OTRO'),
'Otro / no catalogado',
'SUBINSTALLATION',
'F5:SYSTEM:OTHER:SUBINSTALLATION:F5-I-OTRO',
);
await this.parentRule(queryRunner,rootOtherChild,installationOtherId);
await queryRunner.query(`
INSERT INTO finding_categories(code,name,sort_order,is_active)
SELECT $1::varchar,'DH · Modelo de Inventarios F5',270,true
WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)=lower($1::varchar))
`, [CATALOG_CATEGORY_CODE]);
await queryRunner.query(`
UPDATE finding_categories
SET name='DH · Modelo de Inventarios F5',sort_order=270,is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)=lower($1::varchar)
`,[CATALOG_CATEGORY_CODE]);
const categoryId = await this.id(
queryRunner,
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[CATALOG_CATEGORY_CODE],
'F5 finding category',
);
const titleByKey = new Map<string,string>();
const register = (title: string): void => {
const clean = title.trim();
if (!clean || /^idem\b/i.test(clean) || findingKey(clean)==='hallazgos') return;
const itemKey = findingKey(clean);
if (!titleByKey.has(itemKey)) titleByKey.set(itemKey,clean);
};
for (const title of source.catalogSource.universalFindings) register(title);
for (const family of source.catalogSource.installations) for (const title of family.findings) register(title);
for (const family of source.catalogSource.subinstallations) for (const title of family.findings) register(title);
if (titleByKey.size !== 177) {
throw new Error(`F5 finding normalization contract mismatch: ${titleByKey.size}`);
}
const itemIdByKey = new Map<string,string>();
const orderedTitles = [...titleByKey.entries()].sort((a,b)=>a[1].localeCompare(b[1],'es'));
let sourceNumber=1;
for (const [itemKey,title] of orderedTitles) {
const itemCode = hashCode('F5-H',itemKey);
let itemId = await this.optionalId(
queryRunner,
`SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[itemCode],
);
if (!itemId) {
const itemRows = (await queryRunner.query(`
INSERT INTO finding_catalog_items (
category_id,code,source_number,title,import_note,revision,is_active
) VALUES ($1::uuid,$2,$3,$4,$5,1,true)
RETURNING id
`,[
categoryId,itemCode,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
])) as IdRow[];
itemId=itemRows[0]?.id ?? null;
} else {
await queryRunner.query(`
UPDATE finding_catalog_items
SET category_id=$2::uuid,source_number=$3,title=$4,import_note=$5,
is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`,[
itemId,categoryId,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
]);
}
if (!itemId) throw new Error(`F5 could not create finding ${title}`);
itemIdByKey.set(itemKey,itemId);
await queryRunner.query(`
INSERT INTO finding_catalog_item_versions(item_id,revision,snapshot,actor_username)
SELECT item.id,item.revision,
jsonb_build_object(
'id',item.id,'categoryId',category.id,'categoryCode',category.code,
'categoryName',category.name,'code',item.code,'sourceNumber',item.source_number,
'title',item.title,'legalBasis',item.legal_basis,'glossary',item.glossary,
'importNote',item.import_note,'revision',item.revision,'isActive',item.is_active
),'migration:F5'
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=$1::uuid
AND NOT EXISTS (
SELECT 1 FROM finding_catalog_item_versions version
WHERE version.item_id=item.id AND version.revision=item.revision
)
`,[itemId]);
sourceNumber+=1;
}
// Add F5 mappings only. Never delete mappings created by office users or by
// historical migrations; inactive historical families simply stop being offered.
for (const family of source.catalogSource.installations) {
await this.mapFindings(
queryRunner,
installationCode(family.name),
family,
source.catalogSource.universalFindings,
itemIdByKey,
);
}
for (const family of source.catalogSource.subinstallations) {
await this.mapFindings(
queryRunner,
subinstallationCode(family.installation,family.name),
family,
source.catalogSource.universalFindings,
itemIdByKey,
);
}
await this.installFamilySyncFunctions(queryRunner);
// Keep pre-existing profile administration untouched. Yacimiento needs a
// profile only to expose OTROS because the source does not provide a family.
await queryRunner.query(`
INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason)
SELECT id,'F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
FROM asset_types WHERE lower(code)='yacimiento'
ON CONFLICT (asset_type_id) DO NOTHING
`);
const [counts] = (await queryRunner.query(`
SELECT
COUNT(*) FILTER (WHERE level='INSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS installations,
COUNT(*) FILTER (WHERE level='SUBINSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS subinstallations
FROM inventory_families WHERE is_active=true
`)) as Array<{ installations:number; subinstallations:number }>;
if (Number(counts?.installations ?? 0)!==14 || Number(counts?.subinstallations ?? 0)!==109) {
throw new Error(`F5 family preload verification failed: ${JSON.stringify(counts ?? {})}`);
}
const [itemCount] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items
WHERE category_id=$1::uuid AND is_active=true
`,[categoryId])) as CountRow[];
if (Number(itemCount?.total ?? 0)!==177) {
throw new Error(`F5 finding preload verification failed: ${itemCount?.total ?? 0}`);
}
// Verify every universal finding is independently attached to every one of
// the 14 + 109 source families. This intentionally avoids optional DB text
// extensions such as unaccent.
for (const universalTitle of source.catalogSource.universalFindings) {
const universalItemId = itemIdByKey.get(findingKey(universalTitle));
if (!universalItemId) throw new Error(`F5 missing universal catalog item ${universalTitle}`);
const [mappedCount] = (await queryRunner.query(`
SELECT COUNT(DISTINCT mapping.inventory_family_id)::integer AS total
FROM finding_catalog_item_inventory_families mapping
JOIN inventory_families family ON family.id=mapping.inventory_family_id
WHERE mapping.catalog_item_id=$1::uuid
AND family.is_active=true
AND family.source_reference LIKE $2
`,[universalItemId,`F5:${CATALOG_SOURCE_NAME}%`])) as CountRow[];
if (Number(mappedCount?.total ?? 0)!==F5_SOURCE_FAMILY_COUNT) {
throw new Error(`F5 universal mapping verification failed for ${universalTitle}: ${mappedCount?.total ?? 0}`);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const categoryId = await this.optionalId(
queryRunner,
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[CATALOG_CATEGORY_CODE],
);
if (categoryId) {
const [usedFinding] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM inspection_findings finding
JOIN finding_catalog_items item ON item.id=finding.catalog_item_id
WHERE item.category_id=$1::uuid
`,[categoryId])) as CountRow[];
if (Number(usedFinding?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 catalog: inspection findings already reference F5 catalog items');
}
}
const [usedFamily] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE family.source_reference LIKE 'F5:%'
`)) as CountRow[];
if (Number(usedFamily?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 catalog: inventory instances already reference F5 families');
}
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason=$1::text
`,[F5_AUTO_REASON]);
if (categoryId) {
await queryRunner.query(`
DELETE FROM finding_catalog_item_inventory_families mapping
USING finding_catalog_items item
WHERE item.id=mapping.catalog_item_id AND item.category_id=$1::uuid
`,[categoryId]);
await queryRunner.query(`
DELETE FROM finding_catalog_item_versions version
USING finding_catalog_items item
WHERE item.id=version.item_id AND item.category_id=$1::uuid
`,[categoryId]);
await queryRunner.query(`DELETE FROM finding_catalog_items WHERE category_id=$1::uuid`,[categoryId]);
await queryRunner.query(`DELETE FROM finding_categories WHERE id=$1::uuid`,[categoryId]);
}
await queryRunner.query(`
DELETE FROM inventory_family_parent_rules rule
USING inventory_families child
WHERE child.id=rule.child_family_id AND child.source_reference LIKE 'F5:%'
`);
await queryRunner.query(`DELETE FROM inventory_families WHERE source_reference LIKE 'F5:%'`);
await queryRunner.query(`
UPDATE inventory_families
SET is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE source_reference LIKE 'APLICACION APP%'
OR source_reference LIKE 'SYSTEM:F3.1:%'
`);
await queryRunner.query(`
UPDATE finding_categories SET is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='app26r2'
`);
await queryRunner.query(`
DELETE FROM finding_catalog_asset_type_profiles profile
USING asset_types type
WHERE profile.asset_type_id=type.id
AND lower(type.code)='yacimiento'
AND profile.reason='F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
`);
await queryRunner.query(`
DELETE FROM source_documents
WHERE document_number=$1::varchar AND issuer='Dirección de Hidrocarburos'
`,[CATALOG_DOCUMENT_NUMBER]);
await this.restoreF31FamilySyncFunctions(queryRunner);
// Rebuild only automatic historical overrides. Manual overrides have never
// been touched by this migration.
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F3.1 familia técnica:%'
`);
await queryRunner.query(`
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,mapping.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
JOIN finding_catalog_item_inventory_families mapping
ON mapping.inventory_family_id=asset.inventory_family_id
WHERE asset.inventory_family_id IS NOT NULL
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,
reason='F3.1 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP
`);
}
private async upsertFamily(
queryRunner: QueryRunner,
familyCode: string,
name: string,
level: 'INSTALLATION'|'SUBINSTALLATION',
sourceReference: string,
): Promise<string> {
await queryRunner.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,'[]'::jsonb,$4,true)
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,level=EXCLUDED.level,legacy_type_code=NULL,
information_labels='[]'::jsonb,source_reference=EXCLUDED.source_reference,
is_active=true,updated_at=CURRENT_TIMESTAMP
`,[familyCode,name,level,sourceReference]);
return this.id(
queryRunner,
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
[familyCode],
`inventory family ${familyCode}`,
);
}
private async parentRule(queryRunner: QueryRunner,childId:string,parentId:string):Promise<void> {
await queryRunner.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
ON CONFLICT (child_family_id) DO UPDATE SET parent_family_id=EXCLUDED.parent_family_id
`,[childId,parentId]);
}
private async mapFindings(
queryRunner: QueryRunner,
familyCode: string,
family: F5InstallationCatalogRow|F5SubinstallationCatalogRow,
universalFindings: string[],
itemIdByKey: Map<string,string>,
): Promise<void> {
const familyId = await this.id(
queryRunner,
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
[familyCode],
`family ${familyCode}`,
);
const mapped = new Set<string>();
for (const rawTitle of [...family.findings,...universalFindings]) {
const itemKey=findingKey(rawTitle);
if (!itemKey || itemKey==='hallazgos' || mapped.has(itemKey)) continue;
mapped.add(itemKey);
const itemId=itemIdByKey.get(itemKey);
if (!itemId) throw new Error(`F5 missing finding item ${rawTitle}`);
await queryRunner.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
VALUES ($1::uuid,$2::uuid)
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`,[itemId,familyId]);
}
}
private async installFamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides
WHERE asset_id=NEW.id AND reason LIKE 'F% familia técnica:%';
IF NEW.inventory_family_id IS NOT NULL THEN
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT NEW.id,mapping.catalog_item_id,true,
'F5 familia técnica: catálogo contextual automático',
NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F5 familia técnica: catálogo contextual automático',
updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN
DELETE FROM finding_catalog_asset_overrides override_record
USING assets asset
WHERE override_record.asset_id=asset.id
AND asset.inventory_family_id=OLD.inventory_family_id
AND override_record.catalog_item_id=OLD.catalog_item_id
AND override_record.reason LIKE 'F% familia técnica:%';
RETURN OLD;
END IF;
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,NEW.catalog_item_id,true,
'F5 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
WHERE asset.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F5 familia técnica: catálogo contextual automático',updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F% familia técnica:%'
`);
await queryRunner.query(`
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,mapping.catalog_item_id,true,$1::text,
asset.created_by,asset.updated_by
FROM assets asset
JOIN finding_catalog_item_inventory_families mapping
ON mapping.inventory_family_id=asset.inventory_family_id
WHERE asset.inventory_family_id IS NOT NULL
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason=$1::text,updated_at=CURRENT_TIMESTAMP
`,[F5_AUTO_REASON]);
}
private async restoreF31FamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides
WHERE asset_id=NEW.id AND reason LIKE 'F3.1 familia técnica:%';
IF NEW.inventory_family_id IS NOT NULL THEN
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT NEW.id,mapping.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F3.1 familia técnica: catálogo contextual automático',
updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN
DELETE FROM finding_catalog_asset_overrides override_record
USING assets asset
WHERE override_record.asset_id=asset.id
AND asset.inventory_family_id=OLD.inventory_family_id
AND override_record.catalog_item_id=OLD.catalog_item_id
AND override_record.reason LIKE 'F3.1 familia técnica:%';
RETURN OLD;
END IF;
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,NEW.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
WHERE asset.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F3.1 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$;
`);
}
private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise<string> {
const value=await this.optionalId(queryRunner,sql,params);
if (!value) throw new Error(`F5 could not resolve ${label}`);
return value;
}
private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise<string|null> {
const rows=(await queryRunner.query(sql,params)) as IdRow[];
return rows[0]?.id ?? null;
}
}
@@ -4,6 +4,7 @@ import {
ArrayMinSize, ArrayMinSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEnum,
IsISO8601, IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
@@ -11,11 +12,15 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class CreateInspectionActDto { export class CreateInspectionActDto {
@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,6 +4,7 @@ import {
ArrayMinSize, ArrayMinSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEnum,
IsISO8601, IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
@@ -11,12 +12,17 @@ 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()
@@ -14,6 +14,9 @@ import {
DocumentSequenceType, DocumentSequenceType,
InspectionAct, InspectionAct,
InspectionActStatus, InspectionActStatus,
InspectionActUrgency,
InspectionDeadlineBasis,
InspectionDeadlineDayType,
InspectionActVersionEvent, InspectionActVersionEvent,
InspectionVisit, InspectionVisit,
InspectionVisitStatus, InspectionVisitStatus,
@@ -41,7 +44,6 @@ interface ActAsset {
interface ActVisitSummary { interface ActVisitSummary {
id: string; id: string;
code: string; code: string;
title: string;
status: InspectionVisitStatus; status: InspectionVisitStatus;
actualStartedAt: Date | null; actualStartedAt: Date | null;
} }
@@ -72,6 +74,17 @@ export interface InspectionActListItem {
title: string; title: string;
summary: string; summary: string;
observations: string | null; observations: string | null;
urgency: InspectionActUrgency;
deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null;
deadlineBaseAt: Date | null;
deadlineAt: Date | null;
lockedAt: Date | null;
lockedBy: string | null;
lockedSha256: string | null;
sealedAt: Date | null;
sealedBy: string | null;
currentVersion: number; currentVersion: number;
cancellationReason: string | null; cancellationReason: string | null;
closedAt: Date | null; closedAt: Date | null;
@@ -137,7 +150,6 @@ export class InspectionActsService {
act.code ILIKE ${search} act.code ILIKE ${search}
OR act.title ILIKE ${search} OR act.title ILIKE ${search}
OR visit.code ILIKE ${search} OR visit.code ILIKE ${search}
OR visit.title ILIKE ${search}
OR EXISTS ( OR EXISTS (
SELECT 1 SELECT 1
FROM inspection_act_assets search_link FROM inspection_act_assets search_link
@@ -313,13 +325,16 @@ export class InspectionActsService {
const occurredAt = new Date(dto.occurredAt); const occurredAt = new Date(dto.occurredAt);
const actYear = await this.yearAtProjectTimezone(manager, occurredAt); const actYear = await this.yearAtProjectTimezone(manager, occurredAt);
const actNumber = await this.allocateNumber(manager, actYear); const actNumber = await this.allocateNumber(manager, actYear);
if (actNumber > 999999) { if (actNumber > 99999) {
throw new ConflictException({ throw new ConflictException({
code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED', code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED',
message: 'La numeración anual de actas agotó su rango disponible', message: 'La numeración anual de actas agotó su rango disponible',
}); });
} }
const code = `ACTA-${actYear}-${String(actNumber).padStart(6, '0')}`; const [dateRow] = (await manager.query(`
SELECT TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS date_part
`, [occurredAt])) as Array<{ date_part: string }>;
const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`;
const act = manager.getRepository(InspectionAct).create({ const act = manager.getRepository(InspectionAct).create({
visitId, visitId,
actYear, actYear,
@@ -330,6 +345,17 @@ 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,
deadlineDays: null,
deadlineDayType: null,
deadlineBasis: null,
deadlineBaseAt: null,
deadlineAt: null,
lockedAt: null,
lockedBy: null,
lockedSha256: null,
sealedAt: null,
sealedBy: null,
currentVersion: 0, currentVersion: 0,
cancellationReason: null, cancellationReason: null,
createdBy: principal.userId, createdBy: principal.userId,
@@ -384,6 +410,7 @@ 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;
@@ -462,7 +489,6 @@ export class InspectionActsService {
JSONB_BUILD_OBJECT( JSONB_BUILD_OBJECT(
'id', visit.id, 'id', visit.id,
'code', visit.code, 'code', visit.code,
'title', visit.title,
'status', visit.status, 'status', visit.status,
'actualStartedAt', visit.actual_started_at 'actualStartedAt', visit.actual_started_at
) AS visit, ) AS visit,
@@ -474,6 +500,17 @@ export class InspectionActsService {
act.title, act.title,
act.summary, act.summary,
act.observations, act.observations,
act.urgency,
act.deadline_days AS "deadlineDays",
act.deadline_day_type AS "deadlineDayType",
act.deadline_basis AS "deadlineBasis",
act.deadline_base_at AS "deadlineBaseAt",
act.deadline_at AS "deadlineAt",
act.locked_at AS "lockedAt",
act.locked_by AS "lockedBy",
act.locked_sha256 AS "lockedSha256",
act.sealed_at AS "sealedAt",
act.sealed_by AS "sealedBy",
act.current_version AS "currentVersion", act.current_version AS "currentVersion",
act.cancellation_reason AS "cancellationReason", act.cancellation_reason AS "cancellationReason",
act.closed_at AS "closedAt", act.closed_at AS "closedAt",
@@ -645,7 +682,7 @@ export class InspectionActsService {
if (rows.length > 0) { if (rows.length > 0) {
throw new ConflictException({ throw new ConflictException({
code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS', code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS',
message: 'La inspección ya tiene un acta en borrador; preparala o cancelala antes de crear otra', message: 'La inspección ya tiene un acta en borrador; finalizala o cancelala antes de crear otra',
}); });
} }
} }
@@ -665,7 +702,7 @@ export class InspectionActsService {
if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) { if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) {
throw new BadRequestException({ throw new BadRequestException({
code: 'INSPECTION_ACT_ASSET_INVALID', code: 'INSPECTION_ACT_ASSET_INVALID',
message: 'Cada activo del acta debe estar incluido en la visita', message: 'Cada inventario del acta debe estar incluido en la inspección',
}); });
} }
} }
@@ -758,6 +795,15 @@ export class InspectionActsService {
'title', act.title, 'title', act.title,
'summary', act.summary, 'summary', act.summary,
'observations', act.observations, 'observations', act.observations,
'urgency', act.urgency,
'deadlineDays', act.deadline_days,
'deadlineDayType', act.deadline_day_type,
'deadlineBasis', act.deadline_basis,
'deadlineBaseAt', act.deadline_base_at,
'deadlineAt', act.deadline_at,
'lockedAt', act.locked_at,
'lockedSha256', act.locked_sha256,
'sealedAt', act.sealed_at,
'currentVersion', act.current_version, 'currentVersion', act.current_version,
'cancellationReason', act.cancellation_reason, 'cancellationReason', act.cancellation_reason,
'createdBy', act.created_by, 'createdBy', act.created_by,
@@ -765,7 +811,6 @@ export class InspectionActsService {
'visit', JSONB_BUILD_OBJECT( 'visit', JSONB_BUILD_OBJECT(
'id', visit.id, 'id', visit.id,
'code', visit.code, 'code', visit.code,
'title', visit.title,
'status', visit.status, 'status', visit.status,
'scopeAssetId', visit.scope_asset_id, 'scopeAssetId', visit.scope_asset_id,
'actualStartedAt', visit.actual_started_at 'actualStartedAt', visit.actual_started_at
@@ -804,6 +849,15 @@ export class InspectionActsService {
title: act.title, title: act.title,
summary: act.summary, summary: act.summary,
observations: act.observations, observations: act.observations,
urgency: act.urgency,
deadlineDays: act.deadlineDays,
deadlineDayType: act.deadlineDayType,
deadlineBasis: act.deadlineBasis,
deadlineBaseAt: act.deadlineBaseAt,
deadlineAt: act.deadlineAt,
lockedAt: act.lockedAt,
lockedSha256: act.lockedSha256,
sealedAt: act.sealedAt,
currentVersion: act.currentVersion, currentVersion: act.currentVersion,
cancellationReason: act.cancellationReason, cancellationReason: act.cancellationReason,
closedAt: act.closedAt, closedAt: act.closedAt,
@@ -0,0 +1,77 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Req,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { SkipCsrf } from '../auth/decorators/skip-csrf.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CompanySignatureInviteService } from './company-signature-invite.service';
import {
CreateCompanySignatureInviteDto,
PublicCompanyRefusalDto,
PublicCompanySignatureDto,
} from './dto/company-signature-invite.dto';
import {
MAX_INSPECTION_SIGNATURE_BYTES,
type UploadedInspectionSignatureFile,
} from './inspection-signature-file';
@Controller('inspection-acts/:actId/company-signature-invitations')
export class CompanySignatureInviteController {
constructor(private readonly invites: CompanySignatureInviteService) {}
@Post()
@RequirePermissions('inspection_closure.sign')
create(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: CreateCompanySignatureInviteDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.invites.create(actId, dto, principal, request);
}
}
@Public()
@SkipCsrf()
@Controller('public/company-signatures')
export class PublicCompanySignatureController {
constructor(private readonly invites: CompanySignatureInviteService) {}
@Get(':token')
view(@Param('token') token: string) {
return this.invites.view(token);
}
@Post(':token/sign')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 },
}))
sign(
@Param('token') token: string,
@Body() dto: PublicCompanySignatureDto,
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
@Req() request: RequestWithContext,
) {
return this.invites.sign(token, dto, file, request);
}
@Post(':token/refuse')
refuse(
@Param('token') token: string,
@Body() dto: PublicCompanyRefusalDto,
@Req() request: RequestWithContext,
) {
return this.invites.refuse(token, dto, request);
}
}
@@ -0,0 +1,624 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { mkdir, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import {
ConflictException,
GoneException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditSource,
InspectionActSignatureSource,
InspectionActSignatureStatus,
InspectionActSignerType,
InspectionCompanySignatureManifestation,
} from '../database/entities';
import { SmtpDeliveryService } from '../inspection-reports/smtp-delivery.service';
import { sha256CanonicalJson } from './canonical-json';
import type {
CreateCompanySignatureInviteDto,
PublicCompanyRefusalDto,
PublicCompanySignatureDto,
} from './dto/company-signature-invite.dto';
import {
inspectInspectionSignatureFile,
type UploadedInspectionSignatureFile,
} from './inspection-signature-file';
const REMOTE_CONSENT_VERSION = 'F4-1';
const REMOTE_COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del Acta y que esta firma se incorpora como constancia de recepción, sin implicar aceptación de los Hallazgos.';
interface InviteContext {
id: string;
actId: string;
tokenSha256: string;
recipientEmail: string;
recipientName: string | null;
recipientDocumentType: string | null;
recipientDocumentNumber: string | null;
recipientPosition: string | null;
status: 'PENDING' | 'USED' | 'REVOKED' | 'EXPIRED';
expiresAt: Date;
sentAt: Date | null;
usedAt: Date | null;
createdBy: string;
actCode: string;
actStatus: string;
lockedSha256: string | null;
lockedAt: Date | null;
inspectionCode: string;
lockedSnapshot: Record<string, unknown> | null;
}
@Injectable()
export class CompanySignatureInviteService {
private readonly signatureRoot: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly smtp: SmtpDeliveryService,
private readonly config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_SIGNATURE_ROOT')
?? '/app/storage/asset-media/inspection-signatures';
if (!isAbsolute(configured)) throw new Error('INSPECTION_SIGNATURE_ROOT must be an absolute path');
this.signatureRoot = resolve(configured);
if (this.signatureRoot === parse(this.signatureRoot).root) {
throw new Error('INSPECTION_SIGNATURE_ROOT cannot be the filesystem root');
}
}
async create(
actId: string,
dto: CreateCompanySignatureInviteDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const token = randomBytes(32).toString('base64url');
const tokenSha256 = this.hashToken(token);
const expiresAt = new Date(Date.now() + (dto.expiresInDays ?? 7) * 24 * 60 * 60 * 1000);
const created = await this.dataSource.transaction(async (manager) => {
await this.assertActorAssigned(manager, actId, principal);
const [context] = await manager.query(`
SELECT
act.id,
act.code,
act.status,
act.locked_sha256 AS "lockedSha256",
responsible.full_name AS "fullName",
responsible.document_type AS "documentType",
responsible.document_number AS "documentNumber",
responsible.position,
responsible.email AS "responsibleEmail",
visit.code AS "inspectionCode",
(
SELECT profile.notification_email
FROM inspection_act_assets link
JOIN assets inventory ON inventory.id=link.asset_id
JOIN asset_types inventory_type ON inventory_type.id=inventory.asset_type_id
JOIN assets company ON company.id=COALESCE(
inventory.operator_company_id,
CASE WHEN inventory_type.operational_role='COMPANY' THEN inventory.id END
)
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE link.act_id=act.id AND link.included=true
AND profile.notification_email IS NOT NULL
ORDER BY company.id
LIMIT 1
) AS "companyEmail"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
LEFT JOIN inspection_act_responsibles responsible ON responsible.act_id=act.id
WHERE act.id=$1
FOR UPDATE OF act
`, [actId]) as Array<{
id: string;
code: string;
status: string;
lockedSha256: string | null;
fullName: string | null;
documentType: string | null;
documentNumber: string | null;
position: string | null;
responsibleEmail: string | null;
inspectionCode: string;
companyEmail: string | null;
}>;
if (!context) {
throw new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta no encontrada' });
}
if (context.status !== 'LOCKED' || !context.lockedSha256) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_INVITE_ACT_NOT_LOCKED',
message: 'La invitación de firma sólo puede emitirse para un Acta BLOQUEADA',
});
}
if (await this.hasCompanyOutcome(manager, actId)) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED',
message: 'La manifestación de la empresa ya fue registrada',
});
}
const recipientEmail = dto.recipientEmail
?? context.responsibleEmail?.toLowerCase()
?? context.companyEmail?.toLowerCase()
?? null;
if (!recipientEmail) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_EMAIL_REQUIRED',
message: 'No hay email de responsable ni email institucional de empresa; indicá un destinatario',
});
}
await manager.query(`
UPDATE inspection_act_company_signature_invites
SET status='REVOKED',revoked_at=CURRENT_TIMESTAMP,revoked_by=$2,updated_at=CURRENT_TIMESTAMP
WHERE act_id=$1 AND status='PENDING'
`, [actId, principal.userId]);
const [invite] = await manager.query(`
INSERT INTO inspection_act_company_signature_invites(
act_id,token_sha256,recipient_email,recipient_name,recipient_document_type,
recipient_document_number,recipient_position,expires_at,created_by
) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id,act_id AS "actId",recipient_email AS "recipientEmail",
expires_at AS "expiresAt",created_at AS "createdAt"
`, [
actId,
tokenSha256,
recipientEmail,
context.fullName,
context.documentType,
context.documentNumber,
context.position,
expiresAt,
principal.userId,
]) as Array<{
id: string;
actId: string;
recipientEmail: string;
expiresAt: Date;
createdAt: Date;
}>;
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_ACT_COMPANY_SIGNATURE_INVITE_CREATED',
entityType: 'inspection_act_company_signature_invite',
entityId: invite.id,
afterData: {
actId,
actCode: context.code,
recipientEmail,
expiresAt,
},
metadata: { tokenStoredAsHashOnly: true },
}, manager);
return { ...invite, actCode: context.code, inspectionCode: context.inspectionCode };
});
const publicBase = this.config.get<string>('COMPANY_SIGNATURE_PUBLIC_BASE_URL')?.trim().replace(/\/$/, '') ?? null;
const publicUrl = publicBase ? `${publicBase}?token=${encodeURIComponent(token)}` : null;
let emailSent = false;
let deliveryError: string | null = null;
if (!publicUrl) {
deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada';
} else if (!(await this.smtp.configured())) {
deliveryError = 'SMTP no configurado';
} else {
const body = [
`Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`,
`Inspección: ${created.inspectionCode}.`,
'',
'El enlace permite firmar en conformidad, firmar en disidencia o registrar una negativa a firmar.',
'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.',
'',
`Enlace seguro: ${publicUrl}`,
`Válido hasta: ${new Date(created.expiresAt).toLocaleString('es-AR')}`,
].join('\n');
try {
await this.smtp.send({
to: created.recipientEmail,
subject: `DH Inspección · Firma de ${created.actCode}`,
text: body,
attachment: {
filename: `${created.actCode}-instrucciones.txt`,
mimeType: 'text/plain',
content: Buffer.from(body, 'utf8'),
},
});
emailSent = true;
await this.dataSource.query(`
UPDATE inspection_act_company_signature_invites
SET sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [created.id]);
} catch (error) {
deliveryError = error instanceof Error ? error.message.slice(0, 500) : 'No se pudo enviar el email';
}
}
return {
id: created.id,
actId: created.actId,
actCode: created.actCode,
recipientEmail: created.recipientEmail,
expiresAt: created.expiresAt,
emailSent,
deliveryError,
publicUrl,
};
}
async view(token: string) {
const invite = await this.resolveToken(this.dataSource.manager, token, false);
const locked = invite.lockedSnapshot ?? {};
const act = this.record(locked.act);
const inventories = this.records(locked.inventories);
const findings = this.records(locked.findings).map((finding) => ({
id: finding.id,
code: finding.code,
title: finding.title,
description: finding.description,
legalBasis: finding.legalBasis ?? null,
severity: finding.severity ?? null,
isRecurrence: finding.isRecurrence === true,
recurrenceOfFindingId: finding.recurrenceOfFindingId ?? null,
}));
return {
invitation: {
id: invite.id,
recipientEmail: invite.recipientEmail,
expiresAt: invite.expiresAt,
},
act: {
code: invite.actCode,
inspectionCode: invite.inspectionCode,
lockedAt: invite.lockedAt,
lockedSha256: invite.lockedSha256,
urgency: act.urgency ?? null,
summary: act.summary ?? null,
observations: act.observations ?? null,
},
responsibleDefaults: {
fullName: invite.recipientName,
documentType: invite.recipientDocumentType,
documentNumber: invite.recipientDocumentNumber,
position: invite.recipientPosition,
},
inventories: inventories.map((inventory) => ({
id: inventory.id,
code: inventory.code,
name: inventory.name,
typeName: inventory.typeName ?? inventory.typeCode ?? null,
})),
findings,
consent: REMOTE_COMPANY_CONSENT,
allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'],
};
}
async sign(
token: string,
dto: PublicCompanySignatureDto,
file: UploadedInspectionSignatureFile | undefined,
request: RequestWithContext,
) {
if (!dto.consentAccepted) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_CONSENT_REQUIRED',
message: 'Debe aceptarse la constancia antes de firmar',
});
}
const inspected = inspectInspectionSignatureFile(file);
const id = randomUUID();
const storedName = `${id}.png`;
const filePath = resolve(this.signatureRoot, storedName);
const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex');
await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 });
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
try {
const result = await this.dataSource.transaction(async (manager) => {
const invite = await this.resolveToken(manager, token, true);
await this.assertNoCompanyOutcome(manager, invite.actId);
const signedAt = new Date();
const manifestation = dto.manifestation === 'DISSENT'
? InspectionCompanySignatureManifestation.DISSENT
: InspectionCompanySignatureManifestation.CONFORMITY;
const statement = manifestation === InspectionCompanySignatureManifestation.DISSENT
? dto.statement?.trim() ?? null
: null;
const payload = {
invitationId: invite.id,
actId: invite.actId,
lockedSha256: invite.lockedSha256,
signerType: InspectionActSignerType.COMPANY_RESPONSIBLE,
signerName: dto.fullName,
documentType: dto.documentType,
documentNumber: dto.documentNumber,
position: dto.position,
status: InspectionActSignatureStatus.SIGNED,
companyManifestation: manifestation,
companyStatement: statement,
imageSha256,
consentText: REMOTE_COMPANY_CONSENT,
consentVersion: REMOTE_CONSENT_VERSION,
signedAt: signedAt.toISOString(),
source: InspectionActSignatureSource.WEB,
};
const signaturePayloadSha256 = sha256CanonicalJson(payload);
await manager.query(`
INSERT INTO inspection_act_signatures(
id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,
position,status,company_manifestation,company_statement,original_name,stored_name,
mime_type,size_bytes,image_sha256,consent_text,consent_version,consent_accepted_at,
signed_at,device_label,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at
) VALUES(
$1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'SIGNED',$7,$8,$9,$10,$11,$12,$13,
$14,$15,$16,$16,'Firma remota por enlace seguro','WEB',$17,$18,$19,$16
)
`, [
id,
invite.actId,
dto.fullName,
dto.documentType,
dto.documentNumber,
dto.position,
manifestation,
statement,
inspected.originalName,
storedName,
inspected.mimeType,
file!.buffer.length,
imageSha256,
REMOTE_COMPANY_CONSENT,
REMOTE_CONSENT_VERSION,
signedAt,
invite.lockedSha256,
signaturePayloadSha256,
invite.createdBy,
]);
await manager.query(`
UPDATE inspection_act_company_signature_invites
SET status='USED',used_at=$2,updated_at=$2
WHERE id=$1
`, [invite.id, signedAt]);
await this.audit.record({
action: 'INSPECTION_ACT_COMPANY_REMOTE_SIGNATURE_RECORDED',
entityType: 'inspection_act_signature',
entityId: id,
source: AuditSource.WEB,
actorUserId: null,
actorUsername: null,
requestId: request.requestId,
ip: request.ip ?? null,
userAgent: request.get('user-agent') ?? null,
afterData: payload,
metadata: {
invitationId: invite.id,
recipientEmail: invite.recipientEmail,
immutable: true,
signaturePayloadSha256,
},
}, manager);
return { actCode: invite.actCode, signatureId: id, manifestation };
});
return { ok: true, ...result };
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
async refuse(
token: string,
dto: PublicCompanyRefusalDto,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const invite = await this.resolveToken(manager, token, true);
await this.assertNoCompanyOutcome(manager, invite.actId);
const createdAt = new Date();
const id = randomUUID();
const payload = {
invitationId: invite.id,
actId: invite.actId,
lockedSha256: invite.lockedSha256,
signerType: InspectionActSignerType.COMPANY_RESPONSIBLE,
signerName: dto.fullName,
documentType: dto.documentType,
documentNumber: dto.documentNumber,
position: dto.position,
status: InspectionActSignatureStatus.REFUSED,
reason: dto.reason,
source: InspectionActSignatureSource.WEB,
createdAt: createdAt.toISOString(),
};
const signaturePayloadSha256 = sha256CanonicalJson(payload);
await manager.query(`
INSERT INTO inspection_act_signatures(
id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,
position,status,reason,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at
) VALUES($1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'REFUSED',$7,'WEB',$8,$9,$10,$11)
`, [
id,
invite.actId,
dto.fullName,
dto.documentType,
dto.documentNumber,
dto.position,
dto.reason,
invite.lockedSha256,
signaturePayloadSha256,
invite.createdBy,
createdAt,
]);
await manager.query(`
UPDATE inspection_act_company_signature_invites
SET status='USED',used_at=$2,updated_at=$2
WHERE id=$1
`, [invite.id, createdAt]);
await this.audit.record({
action: 'INSPECTION_ACT_COMPANY_REMOTE_REFUSAL_RECORDED',
entityType: 'inspection_act_signature',
entityId: id,
source: AuditSource.WEB,
actorUserId: null,
actorUsername: null,
requestId: request.requestId,
ip: request.ip ?? null,
userAgent: request.get('user-agent') ?? null,
afterData: payload,
metadata: {
invitationId: invite.id,
recipientEmail: invite.recipientEmail,
immutable: true,
signaturePayloadSha256,
},
}, manager);
return { ok: true, actCode: invite.actCode, refusalId: id };
});
}
private async resolveToken(
manager: EntityManager,
token: string,
lock: boolean,
): Promise<InviteContext> {
if (!/^[A-Za-z0-9_-]{40,120}$/.test(token)) throw this.invalidInvite();
const tokenSha256 = this.hashToken(token);
const [invite] = await manager.query(`
SELECT
invite.id,
invite.act_id AS "actId",
invite.token_sha256 AS "tokenSha256",
invite.recipient_email AS "recipientEmail",
invite.recipient_name AS "recipientName",
invite.recipient_document_type AS "recipientDocumentType",
invite.recipient_document_number AS "recipientDocumentNumber",
invite.recipient_position AS "recipientPosition",
invite.status,
invite.expires_at AS "expiresAt",
invite.sent_at AS "sentAt",
invite.used_at AS "usedAt",
invite.created_by AS "createdBy",
act.code AS "actCode",
act.status AS "actStatus",
act.locked_sha256 AS "lockedSha256",
act.locked_at AS "lockedAt",
visit.code AS "inspectionCode",
closure.prepared_snapshot AS "lockedSnapshot"
FROM inspection_act_company_signature_invites invite
JOIN inspection_acts act ON act.id=invite.act_id
JOIN inspection_visits visit ON visit.id=act.visit_id
JOIN inspection_act_closures closure ON closure.act_id=act.id
WHERE invite.token_sha256=$1
${lock ? 'FOR UPDATE OF invite,act' : ''}
`, [tokenSha256]) as InviteContext[];
if (!invite) throw this.invalidInvite();
if (invite.status !== 'PENDING') {
throw new GoneException({
code: 'COMPANY_SIGNATURE_INVITE_NOT_ACTIVE',
message: invite.status === 'USED'
? 'Este enlace ya fue utilizado'
: 'Este enlace ya no está vigente',
});
}
if (new Date(invite.expiresAt).getTime() <= Date.now()) {
await manager.query(`
UPDATE inspection_act_company_signature_invites
SET status='EXPIRED',updated_at=CURRENT_TIMESTAMP
WHERE id=$1 AND status='PENDING'
`, [invite.id]);
throw new GoneException({
code: 'COMPANY_SIGNATURE_INVITE_EXPIRED',
message: 'El enlace de firma venció. Solicitá una nueva invitación.',
});
}
if (invite.actStatus !== 'LOCKED' || !invite.lockedSha256 || !invite.lockedSnapshot) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_ACT_NOT_AVAILABLE',
message: 'El Acta ya no está disponible para manifestación remota',
});
}
return invite;
}
private async assertActorAssigned(
manager: EntityManager,
actId: string,
principal: AuthPrincipal,
): Promise<void> {
if (principal.permissions.includes('inspections.manage')) return;
const [row] = await manager.query(`
SELECT 1
FROM inspection_acts act
JOIN inspection_visit_members member ON member.visit_id=act.visit_id
WHERE act.id=$1 AND member.user_id=$2 AND member.included=true
LIMIT 1
`, [actId, principal.userId]) as unknown[];
if (!row) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_INVITE_NOT_ASSIGNED',
message: 'Sólo un Inspector asignado puede emitir la invitación',
});
}
}
private async assertNoCompanyOutcome(manager: EntityManager, actId: string): Promise<void> {
if (await this.hasCompanyOutcome(manager, actId)) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED',
message: 'La manifestación de la empresa ya fue registrada',
});
}
}
private async hasCompanyOutcome(manager: EntityManager, actId: string): Promise<boolean> {
const rows = await manager.query(`
SELECT 1 FROM inspection_act_signatures
WHERE act_id=$1 AND signer_type='COMPANY_RESPONSIBLE'
LIMIT 1
`, [actId]) as unknown[];
return rows.length > 0;
}
private hashToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}
private invalidInvite(): NotFoundException {
return new NotFoundException({
code: 'COMPANY_SIGNATURE_INVITE_NOT_FOUND',
message: 'El enlace de firma no es válido',
});
}
private record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
private records(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value.map((item) => this.record(item)) : [];
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({
code: 'COMPANY_SIGNATURE_STORAGE_ERROR',
message: 'No se pudo almacenar la firma remota',
});
}
}
@@ -0,0 +1,101 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEmail,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateCompanySignatureInviteDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined)
@IsEmail()
@MaxLength(320)
recipientEmail?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(30)
expiresInDays?: number;
}
export class PublicCompanySignatureDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(200)
fullName!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
@IsString()
@IsIn(['DNI', 'CUIL', 'PASSPORT', 'OTHER'])
documentType!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(3)
@MaxLength(40)
documentNumber!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(200)
position!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
@IsString()
@IsIn(['CONFORMITY', 'DISSENT'])
manifestation!: 'CONFORMITY' | 'DISSENT';
@ValidateIf((value: PublicCompanySignatureDto) => value.manifestation === 'DISSENT')
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(10)
@MaxLength(8000)
statement?: string;
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
consentAccepted!: boolean;
}
export class PublicCompanyRefusalDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(200)
fullName!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
@IsString()
@IsIn(['DNI', 'CUIL', 'PASSPORT', 'OTHER'])
documentType!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(3)
@MaxLength(40)
documentNumber!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(200)
position!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(10)
@MaxLength(8000)
reason!: string;
}
@@ -48,9 +48,9 @@ export class InspectionClosingController {
return this.closing.upsertResponsible(actId, dto, principal, request); return this.closing.upsertResponsible(actId, dto, principal, request);
} }
@Post('ready') @Post('lock')
@RequirePermissions('inspection_closure.prepare') @RequirePermissions('inspection_closure.prepare')
ready( lock(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@CurrentAuth() principal: AuthPrincipal, @CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext, @Req() request: RequestWithContext,
@@ -58,16 +58,6 @@ export class InspectionClosingController {
return this.closing.prepare(actId, principal, request); return this.closing.prepare(actId, principal, request);
} }
@Post('reopen')
@RequirePermissions('inspection_closure.prepare')
reopen(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.closing.reopen(actId, principal, request);
}
@Post('signatures/inspector') @Post('signatures/inspector')
@RequirePermissions('inspection_closure.sign') @RequirePermissions('inspection_closure.sign')
@UseInterceptors(FileInterceptor('file', { @UseInterceptors(FileInterceptor('file', {
@@ -109,9 +99,9 @@ export class InspectionClosingController {
return this.closing.recordCompanyOutcome(actId, dto, principal, request); return this.closing.recordCompanyOutcome(actId, dto, principal, request);
} }
@Post('close') @Post('seal')
@RequirePermissions('inspection_closure.close') @RequirePermissions('inspection_closure.close')
close( seal(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: CloseInspectionActDto, @Body() dto: CloseInspectionActDto,
@CurrentAuth() principal: AuthPrincipal, @CurrentAuth() principal: AuthPrincipal,
@@ -1,6 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module'; import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
import {
CompanySignatureInviteController,
PublicCompanySignatureController,
} from './company-signature-invite.controller';
import { CompanySignatureInviteService } from './company-signature-invite.service';
import { import {
InspectionClosingController, InspectionClosingController,
InspectionSignatureContentController, InspectionSignatureContentController,
@@ -9,7 +14,12 @@ import { InspectionClosingService } from './inspection-closing.service';
@Module({ @Module({
imports: [AuditModule, InspectionReportsModule], imports: [AuditModule, InspectionReportsModule],
controllers: [InspectionClosingController, InspectionSignatureContentController], controllers: [
providers: [InspectionClosingService], InspectionClosingController,
InspectionSignatureContentController,
CompanySignatureInviteController,
PublicCompanySignatureController,
],
providers: [InspectionClosingService, CompanySignatureInviteService],
}) })
export class InspectionClosingModule {} export class InspectionClosingModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class SetFindingRecurrenceDto {
@IsUUID('4')
recurrenceOfFindingId!: string;
}
@@ -1,41 +1,65 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto'; import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { FindingCatalogService } from './finding-catalog.service';
@Injectable() @Injectable()
export class F3FindingCatalogResolverService { export class F3FindingCatalogResolverService {
constructor( constructor(private readonly dataSource: DataSource) {}
private readonly dataSource: DataSource,
private readonly legacyCatalog: FindingCatalogService,
) {}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) { async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const [asset] = await this.dataSource.query(` const [asset] = await this.dataSource.query(`
SELECT asset.id,asset.code,asset.name, SELECT asset.id,asset.code,asset.name,
type.code AS "typeCode",
asset.inventory_family_id AS "familyId", asset.inventory_family_id AS "familyId",
family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel" family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel",
family.is_active AS "familyActive"
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid WHERE asset.id=$1::uuid
`, [assetId]) as Array<{ `, [assetId]) as Array<{
id: string; id: string;
code: string; code: string;
name: string; name: string;
typeCode: string;
familyId: string | null; familyId: string | null;
familyCode: string | null; familyCode: string | null;
familyName: string | null; familyName: string | null;
familyLevel: string | null; familyLevel: string | null;
familyActive: boolean | null;
}>; }>;
if (!asset) { if (!asset) {
throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' }); throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
} }
if (!asset.familyId) return this.legacyCatalog.listApplicableForAsset(assetId, query);
const other = {
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo cuando el Hallazgo no exista en el catálogo aplicable. Se enviará una propuesta a revisión de oficina.',
};
// F5: no se mezclan catálogos históricos. Yacimiento no posee familia técnica
// en final_modelov2.xlsx, por lo que puede registrar Hallazgos mediante OTROS.
if (!asset.familyId || asset.familyActive !== true) {
return {
asset: { id: asset.id, code: asset.code, name: asset.name },
inventoryFamily: null,
typeConfigured: false,
configurationReason: asset.typeCode.toLowerCase() === 'yacimiento'
? 'F5 · Yacimiento sin catálogo precargado: Hallazgos disponibles mediante OTROS.'
: 'F5 · El elemento todavía no tiene una clasificación técnica activa.',
categories: [],
items: [],
other,
};
}
const filters = [ const filters = [
'mapping.inventory_family_id=$1::uuid', 'mapping.inventory_family_id=$1::uuid',
'item.is_active=true', 'item.is_active=true',
'category.is_active=true', 'category.is_active=true',
"lower(category.code)='f5model'",
'merge_record.source_item_id IS NULL', 'merge_record.source_item_id IS NULL',
]; ];
const params: unknown[] = [asset.familyId]; const params: unknown[] = [asset.familyId];
@@ -83,15 +107,10 @@ export class F3FindingCatalogResolverService {
level: asset.familyLevel, level: asset.familyLevel,
}, },
typeConfigured: true, typeConfigured: true,
configurationReason: `F3.1 · Catálogo del Excel asociado a ${asset.familyName ?? 'la familia técnica'}`, configurationReason: `F5 · Catálogo final_modelov2.xlsx asociado a ${asset.familyName ?? 'la clasificación técnica'}`,
categories, categories,
items, items,
other: { other,
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo cuando el hallazgo no exista en la familia técnica. Se enviará una propuesta a revisión de oficina.',
},
}; };
} }
} }
@@ -0,0 +1,53 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Req,
} from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto';
import { FindingRecurrenceService } from './finding-recurrence.service';
@Controller('inspection-findings')
export class FindingRecurrenceController {
constructor(private readonly recurrence: FindingRecurrenceService) {}
@Get(':id/recurrence')
@RequirePermissions('inspection_findings.read')
state(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.recurrence.state(id);
}
@Get(':id/recurrence-candidates')
@RequirePermissions('inspection_findings.read')
candidates(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.recurrence.candidates(id);
}
@Post(':id/recurrence')
@RequirePermissions('inspection_findings.update')
link(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: SetFindingRecurrenceDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.recurrence.link(id, dto, principal, request);
}
@Delete(':id/recurrence')
@RequirePermissions('inspection_findings.update')
clear(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.recurrence.clear(id, principal, request);
}
}
@@ -0,0 +1,268 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto';
import { InspectionFindingsService } from './inspection-findings.service';
import type { InspectionFindingView } from './inspection-findings.service';
interface FindingRecurrenceContext {
id: string;
assetId: string;
catalogItemId: string | null;
title: string;
createdAt: Date;
actId: string;
actStatus: string;
visitId: string;
visitStatus: string;
}
export interface FindingRecurrenceState {
isRecurrence: boolean;
recurrenceOfFindingId: string | null;
recurrenceOfFindingCode: string | null;
}
export type FindingWithRecurrenceView = InspectionFindingView & FindingRecurrenceState;
function comparable(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
@Injectable()
export class FindingRecurrenceService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly findings: InspectionFindingsService,
) {}
async state(id: string): Promise<FindingRecurrenceState> {
const [row] = await this.dataSource.query(`
SELECT
finding.is_recurrence AS "isRecurrence",
finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
antecedent.code AS "recurrenceOfFindingCode"
FROM inspection_findings finding
LEFT JOIN inspection_findings antecedent ON antecedent.id = finding.recurrence_of_finding_id
WHERE finding.id = $1
`, [id]) as FindingRecurrenceState[];
if (!row) {
throw new NotFoundException({
code: 'INSPECTION_FINDING_NOT_FOUND',
message: 'Hallazgo no encontrado',
});
}
return row;
}
async candidates(id: string) {
const current = await this.requireFinding(this.dataSource.manager, id, false);
const rows = await this.dataSource.query(`
SELECT
finding.id,finding.code,finding.title,finding.description,finding.status,
finding.catalog_item_id AS "catalogItemId",finding.created_at AS "createdAt",
act.id AS "actId",act.code AS "actCode",act.occurred_at AS "actOccurredAt",
visit.id AS "visitId",visit.code AS "visitCode",visit.status AS "visitStatus"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id=finding.act_id
JOIN inspection_visits visit ON visit.id=act.visit_id
WHERE finding.asset_id=$1
AND finding.id<>$2
AND finding.status<>'VOIDED'
AND finding.created_at<$3
AND (
($4::uuid IS NOT NULL AND finding.catalog_item_id=$4::uuid)
OR ($4::uuid IS NULL AND lower(btrim(finding.title))=lower(btrim($5)))
)
ORDER BY finding.created_at DESC,finding.id DESC
LIMIT 20
`, [current.assetId, current.id, current.createdAt, current.catalogItemId, current.title]);
return {
findingId: current.id,
assetId: current.assetId,
hasCandidates: rows.length > 0,
data: rows,
};
}
async link(
id: string,
dto: SetFindingRecurrenceDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingWithRecurrenceView> {
assertMobileInspector(principal);
await this.dataSource.transaction(async (manager) => {
const current = await this.requireFinding(manager, id, true);
this.assertEditable(current);
await this.assertActorAssigned(manager, current.visitId, principal);
if (id === dto.recurrenceOfFindingId) {
throw new BadRequestException({
code: 'FINDING_CANNOT_RECUR_FROM_SELF',
message: 'Un Hallazgo no puede ser reincidencia de sí mismo',
});
}
const previous = await this.requireFinding(manager, dto.recurrenceOfFindingId, false);
if (previous.assetId !== current.assetId) {
throw new ConflictException({
code: 'FINDING_RECURRENCE_DIFFERENT_INVENTORY',
message: 'La reincidencia debe referir a un Hallazgo anterior del mismo Inventario',
});
}
if (new Date(previous.createdAt).getTime() >= new Date(current.createdAt).getTime()) {
throw new ConflictException({
code: 'FINDING_RECURRENCE_NOT_PREVIOUS',
message: 'El Hallazgo de referencia debe ser anterior al actual',
});
}
const sameCatalog = current.catalogItemId && previous.catalogItemId
? current.catalogItemId === previous.catalogItemId
: current.catalogItemId === previous.catalogItemId;
const sameTitle = comparable(current.title) === comparable(previous.title);
if (!sameCatalog && !sameTitle) {
throw new ConflictException({
code: 'FINDING_RECURRENCE_NOT_EQUIVALENT',
message: 'El antecedente seleccionado no corresponde al mismo tipo de Hallazgo',
});
}
const [before] = await manager.query(`
SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId"
FROM inspection_findings WHERE id=$1 FOR UPDATE
`, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>;
await manager.query(`
UPDATE inspection_findings
SET is_recurrence=true,recurrence_of_finding_id=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [id, previous.id, principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_UPDATED,
entityType: 'inspection_finding',
entityId: id,
beforeData: before ?? { isRecurrence: false, recurrenceOfFindingId: null },
afterData: {
isRecurrence: true,
recurrenceOfFindingId: previous.id,
recurrenceOfFindingCode: await this.codeForFinding(manager, previous.id),
},
metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'LINKED' },
}, manager);
});
const [finding, recurrence] = await Promise.all([
this.findings.getById(id),
this.state(id),
]);
return { ...finding, ...recurrence };
}
async clear(
id: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingWithRecurrenceView> {
assertMobileInspector(principal);
await this.dataSource.transaction(async (manager) => {
const current = await this.requireFinding(manager, id, true);
this.assertEditable(current);
await this.assertActorAssigned(manager, current.visitId, principal);
const [before] = await manager.query(`
SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId"
FROM inspection_findings WHERE id=$1 FOR UPDATE
`, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>;
if (!before?.isRecurrence && !before?.recurrenceOfFindingId) return;
await manager.query(`
UPDATE inspection_findings
SET is_recurrence=false,recurrence_of_finding_id=NULL,updated_by=$2,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [id, principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_UPDATED,
entityType: 'inspection_finding',
entityId: id,
beforeData: before,
afterData: { isRecurrence: false, recurrenceOfFindingId: null },
metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'CLEARED' },
}, manager);
});
const [finding, recurrence] = await Promise.all([
this.findings.getById(id),
this.state(id),
]);
return { ...finding, ...recurrence };
}
private async requireFinding(
manager: EntityManager,
id: string,
lock: boolean,
): Promise<FindingRecurrenceContext> {
const [row] = await manager.query(`
SELECT finding.id,finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId",
finding.title,finding.created_at AS "createdAt",finding.act_id AS "actId",
act.status AS "actStatus",visit.id AS "visitId",visit.status AS "visitStatus"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id=finding.act_id
JOIN inspection_visits visit ON visit.id=act.visit_id
WHERE finding.id=$1
${lock ? 'FOR UPDATE OF finding,act,visit' : ''}
`, [id]) as FindingRecurrenceContext[];
if (!row) {
throw new NotFoundException({
code: 'INSPECTION_FINDING_NOT_FOUND',
message: 'Hallazgo no encontrado',
});
}
return row;
}
private assertEditable(finding: FindingRecurrenceContext): void {
if (finding.actStatus !== 'DRAFT' || finding.visitStatus !== 'IN_PROGRESS') {
throw new ConflictException({
code: 'FINDING_RECURRENCE_IMMUTABLE',
message: 'La reincidencia sólo puede definirse mientras el Acta está en BORRADOR y la Inspección en curso',
});
}
}
private async assertActorAssigned(
manager: EntityManager,
visitId: string,
principal: AuthPrincipal,
): Promise<void> {
if (principal.permissions.includes('inspections.manage')) return;
const [row] = await manager.query(`
SELECT 1 FROM inspection_visit_members
WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1
`, [visitId, principal.userId]) as unknown[];
if (!row) {
throw new ForbiddenException({
code: 'INSPECTION_NOT_ASSIGNED',
message: 'La Inspección no está asignada al usuario actual',
});
}
}
private async codeForFinding(manager: EntityManager, id: string): Promise<string | null> {
const [row] = await manager.query('SELECT code FROM inspection_findings WHERE id=$1', [id]) as Array<{ code: string }>;
return row?.code ?? null;
}
}
@@ -0,0 +1,331 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
export interface InspectionFindingWorklistCounters {
open: number;
withoutControlDate: number;
toVerify: number;
verificationOverdue: number;
verificationNext30Days: number;
readyToClose: number;
closed: number;
}
export interface InspectionFindingWorklistItem {
id: string;
actId: string;
assetId: string;
code: string;
status: 'OPEN' | 'CLOSED' | 'VOIDED';
title: string;
description: string;
severity: number | null;
nextControlOn: string | null;
closedAt: Date | null;
closureNotes: string | null;
asset: {
id: string;
code: string;
name: string;
commonName: string | null;
typeName: string;
operatorCompany: { id: string; code: string; name: string } | null;
operationalArea: { id: string; code: string; name: string } | null;
};
document: {
actId: string;
actCode: string;
actStatus: string;
visitId: string;
visitCode: string;
visitStatus: string;
};
verificationVisit: {
id: string;
code: string;
status: string;
plannedStartAt: Date | null;
} | null;
latestVerification: {
visitId: string;
visitCode: string;
visitStatus: string;
targetControlOn: string | null;
outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null;
resultNotes: string | null;
verifiedAt: Date | null;
resultRecordedAt: Date | null;
rescheduledControlOn: string | null;
evidenceCount: number;
} | null;
createdAt: Date;
updatedAt: Date;
}
export interface InspectionFindingWorklistPage {
data: InspectionFindingWorklistItem[];
meta: { page: number; pageSize: number; total: number; totalPages: number };
counters: InspectionFindingWorklistCounters;
}
@Injectable()
export class InspectionFindingWorklistService {
constructor(private readonly dataSource: DataSource) {}
async list(query: ListInspectionFindingsQueryDto): Promise<InspectionFindingWorklistPage> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const workflow = query.workflow ?? 'OPEN';
const values: unknown[] = [];
const contextFilters: string[] = [];
const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`;
const latestOutcome = `(SELECT latest_verification.outcome
FROM inspection_finding_verification_visits latest_verification
WHERE latest_verification.finding_id = finding.id
AND latest_verification.outcome IS NOT NULL
ORDER BY latest_verification.result_recorded_at DESC NULLS LAST,
latest_verification.created_at DESC,
latest_verification.id DESC
LIMIT 1)`;
const latestVisitStatus = `(SELECT latest_visit.status
FROM inspection_finding_verification_visits latest_verification
INNER JOIN inspection_visits latest_visit ON latest_visit.id = latest_verification.visit_id
WHERE latest_verification.finding_id = finding.id
AND latest_verification.outcome IS NOT NULL
ORDER BY latest_verification.result_recorded_at DESC NULLS LAST,
latest_verification.created_at DESC,
latest_verification.id DESC
LIMIT 1)`;
const add = (value: unknown): string => {
values.push(value);
return `$${values.length}`;
};
if (query.search) {
const parameter = add(`%${query.search}%`);
contextFilters.push(`(
finding.code ILIKE ${parameter}
OR finding.title ILIKE ${parameter}
OR finding.description ILIKE ${parameter}
OR asset.code ILIKE ${parameter}
OR asset.name ILIKE ${parameter}
OR company.name ILIKE ${parameter}
OR area.name ILIKE ${parameter}
OR act.code ILIKE ${parameter}
)`);
}
if (query.companyId) contextFilters.push(`company.id = ${add(query.companyId)}::uuid`);
if (query.areaId) contextFilters.push(`area.id = ${add(query.areaId)}::uuid`);
if (query.inspectorId) {
const inspector = add(query.inspectorId);
contextFilters.push(`(
visit.lead_inspector_user_id = ${inspector}::uuid
OR EXISTS (
SELECT 1 FROM inspection_visit_members member_filter
WHERE member_filter.visit_id = visit.id
AND member_filter.included = true
AND member_filter.user_id = ${inspector}::uuid
)
)`);
}
if (query.dateFrom) contextFilters.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
if (query.dateTo) contextFilters.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
const workflowFilters: string[] = [];
if (workflow === 'OPEN') workflowFilters.push(`finding.status = 'OPEN'`);
if (workflow === 'TO_SCHEDULE_VERIFICATION') {
workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`);
}
if (workflow === 'TO_VERIFY') {
workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NOT NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`);
}
if (workflow === 'VERIFICATION_OVERDUE') {
workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NOT NULL AND finding.next_control_on < ${today} AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`);
}
if (workflow === 'READY_TO_CLOSE') {
workflowFilters.push(`finding.status = 'OPEN' AND ${latestOutcome} = 'RESOLVED' AND ${latestVisitStatus} = 'CLOSED'`);
}
if (workflow === 'CLOSED') workflowFilters.push(`finding.status = 'CLOSED'`);
if (workflow === 'WAITING_COMPANY' || workflow === 'COMPANY_OVERDUE') {
// Compatibilidad de URL histórica: estos estados dejaron de existir en F4.
workflowFilters.push(`finding.status = 'OPEN'`);
}
const joins = `
INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
`;
const filters = [...contextFilters, ...workflowFilters];
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const contextWhere = contextFilters.length ? `WHERE ${contextFilters.join(' AND ')}` : '';
const [countRow] = await this.dataSource.query(`
SELECT COUNT(*)::integer AS total
FROM inspection_findings finding
${joins}
${where}
`, values) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const listValues = [...values, pageSize, (page - 1) * pageSize];
const limitParameter = `$${values.length + 1}`;
const offsetParameter = `$${values.length + 2}`;
const data = await this.dataSource.query(`
SELECT
finding.id,
finding.act_id AS "actId",
finding.asset_id AS "assetId",
finding.code,
finding.status,
finding.title,
finding.description,
finding.severity,
finding.next_control_on AS "nextControlOn",
finding.closed_at AS "closedAt",
finding.closure_notes AS "closureNotes",
JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'commonName', asset.common_name,
'typeName', asset_type.name,
'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', company.id, 'code', company.code, 'name', company.name
) END,
'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', area.id, 'code', area.code, 'name', area.name
) END
) AS asset,
JSONB_BUILD_OBJECT(
'actId', act.id,
'actCode', act.code,
'actStatus', act.status,
'visitId', visit.id,
'visitCode', visit.code,
'visitStatus', visit.status
) AS document,
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', verification_visit.id,
'code', verification_visit.code,
'status', verification_visit.status,
'plannedStartAt', verification_visit.planned_start_at
) END AS "verificationVisit",
CASE WHEN latest_verification.visit_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'visitId', latest_verification.visit_id,
'visitCode', latest_verification.visit_code,
'visitStatus', latest_verification.visit_status,
'targetControlOn', latest_verification.target_control_on,
'outcome', latest_verification.outcome,
'resultNotes', latest_verification.result_notes,
'verifiedAt', latest_verification.verified_at,
'resultRecordedAt', latest_verification.result_recorded_at,
'rescheduledControlOn', latest_verification.rescheduled_control_on,
'evidenceCount', latest_verification.evidence_count
) END AS "latestVerification",
finding.created_at AS "createdAt",
finding.updated_at AS "updatedAt"
FROM inspection_findings finding
${joins}
LEFT JOIN LATERAL (
SELECT verification_target.id, verification_target.code,
verification_target.status, verification_target.planned_start_at
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
WHERE verification_link.finding_id = finding.id
AND verification_target.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS')
ORDER BY verification_link.created_at DESC, verification_link.id DESC
LIMIT 1
) verification_visit ON true
LEFT JOIN LATERAL (
SELECT
verification_link.visit_id,
verification_target.code AS visit_code,
verification_target.status AS visit_status,
verification_link.target_control_on,
verification_link.outcome,
verification_link.result_notes,
verification_link.verified_at,
verification_link.result_recorded_at,
verification_link.rescheduled_control_on,
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence evidence
WHERE evidence.finding_id = finding.id
AND evidence.verification_visit_id = verification_link.visit_id
AND evidence.purpose = 'VERIFICATION') AS evidence_count
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
WHERE verification_link.finding_id = finding.id
ORDER BY COALESCE(verification_link.result_recorded_at, verification_link.created_at) DESC,
verification_link.id DESC
LIMIT 1
) latest_verification ON true
${where}
ORDER BY
CASE WHEN finding.status = 'OPEN' THEN 0 ELSE 1 END,
COALESCE(finding.next_control_on, '9999-12-31'::date),
finding.updated_at DESC,
finding.code
LIMIT ${limitParameter} OFFSET ${offsetParameter}
`, listValues) as InspectionFindingWorklistItem[];
const [counterRow] = await this.dataSource.query(`
SELECT
COUNT(*) FILTER (WHERE finding.status='OPEN')::integer AS "open",
COUNT(*) FILTER (
WHERE finding.status='OPEN'
AND finding.next_control_on IS NULL
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
)::integer AS "withoutControlDate",
COUNT(*) FILTER (
WHERE finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
)::integer AS "toVerify",
COUNT(*) FILTER (
WHERE finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on < ${today}
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
)::integer AS "verificationOverdue",
COUNT(*) FILTER (
WHERE finding.status='OPEN'
AND finding.next_control_on BETWEEN ${today} AND ${today} + 30
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
)::integer AS "verificationNext30Days",
COUNT(*) FILTER (
WHERE finding.status='OPEN'
AND ${latestOutcome}='RESOLVED'
AND ${latestVisitStatus}='CLOSED'
)::integer AS "readyToClose",
COUNT(*) FILTER (WHERE finding.status='CLOSED')::integer AS "closed"
FROM inspection_findings finding
${joins}
${contextWhere}
`, values) as InspectionFindingWorklistCounters[];
return {
data,
meta: {
page,
pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / pageSize),
},
counters: {
open: Number(counterRow?.open ?? 0),
withoutControlDate: Number(counterRow?.withoutControlDate ?? 0),
toVerify: Number(counterRow?.toVerify ?? 0),
verificationOverdue: Number(counterRow?.verificationOverdue ?? 0),
verificationNext30Days: Number(counterRow?.verificationNext30Days ?? 0),
readyToClose: Number(counterRow?.readyToClose ?? 0),
closed: Number(counterRow?.closed ?? 0),
},
};
}
}
@@ -15,9 +15,12 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c
import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto'; import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto';
import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto'; import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto';
import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto'; import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
import { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto';
import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto'; import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
import { FindingRecurrenceService } from './finding-recurrence.service';
import type { FindingRecurrenceState } from './finding-recurrence.service';
import { InspectionFindingWorklistService } from './inspection-finding-worklist.service';
import { InspectionFindingsService } from './inspection-findings.service'; import { InspectionFindingsService } from './inspection-findings.service';
import type { InspectionFindingView } from './inspection-findings.service';
@Controller('inspection-acts/:actId/findings') @Controller('inspection-acts/:actId/findings')
export class InspectionActFindingsController { export class InspectionActFindingsController {
@@ -43,18 +46,28 @@ export class InspectionActFindingsController {
@Controller('inspection-findings') @Controller('inspection-findings')
export class InspectionFindingsController { export class InspectionFindingsController {
constructor(private readonly findings: InspectionFindingsService) {} constructor(
private readonly findings: InspectionFindingsService,
private readonly worklist: InspectionFindingWorklistService,
private readonly recurrence: FindingRecurrenceService,
) {}
@Get() @Get()
@RequirePermissions('inspection_findings.read') @RequirePermissions('inspection_findings.read')
list(@Query() query: ListInspectionFindingsQueryDto) { list(@Query() query: ListInspectionFindingsQueryDto) {
return this.findings.listGlobal(query); return this.worklist.list(query);
} }
@Get(':id') @Get(':id')
@RequirePermissions('inspection_findings.read') @RequirePermissions('inspection_findings.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { async get(
return this.findings.getById(id); @Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
): Promise<InspectionFindingView & FindingRecurrenceState> {
const [finding, recurrence] = await Promise.all([
this.findings.getById(id),
this.recurrence.state(id),
]);
return { ...finding, ...recurrence };
} }
@Get(':id/verification-history') @Get(':id/verification-history')
@@ -84,15 +97,4 @@ export class InspectionFindingsController {
) { ) {
return this.findings.close(id, dto, principal, request); return this.findings.close(id, dto, principal, request);
} }
@Patch(':id/follow-up')
@RequirePermissions('inspection_findings.follow_up')
updateFollowUp(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateInspectionFindingFollowUpDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.findings.updateFollowUp(id, dto, principal, request);
}
} }
@@ -4,6 +4,9 @@ import { FindingCatalogController } from './finding-catalog.controller';
import { FindingCatalogMergeService } from './finding-catalog-merge.service'; import { FindingCatalogMergeService } from './finding-catalog-merge.service';
import { FindingCatalogService } from './finding-catalog.service'; import { FindingCatalogService } from './finding-catalog.service';
import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service'; import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service';
import { FindingRecurrenceController } from './finding-recurrence.controller';
import { FindingRecurrenceService } from './finding-recurrence.service';
import { InspectionFindingWorklistService } from './inspection-finding-worklist.service';
import { import {
InspectionActFindingsController, InspectionActFindingsController,
InspectionFindingsController, InspectionFindingsController,
@@ -22,6 +25,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
FindingCatalogController, FindingCatalogController,
InspectionActFindingsController, InspectionActFindingsController,
InspectionFindingsController, InspectionFindingsController,
FindingRecurrenceController,
InspectionFindingEvidenceController, InspectionFindingEvidenceController,
InspectionFindingCommunicationsController, InspectionFindingCommunicationsController,
InspectionEvidenceContentController, InspectionEvidenceContentController,
@@ -31,6 +35,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
F3FindingCatalogResolverService, F3FindingCatalogResolverService,
FindingCatalogMergeService, FindingCatalogMergeService,
InspectionFindingsService, InspectionFindingsService,
InspectionFindingWorklistService,
FindingRecurrenceService,
InspectionEvidenceService, InspectionEvidenceService,
], ],
exports: [ exports: [
@@ -38,6 +44,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
F3FindingCatalogResolverService, F3FindingCatalogResolverService,
FindingCatalogMergeService, FindingCatalogMergeService,
InspectionFindingsService, InspectionFindingsService,
InspectionFindingWorklistService,
FindingRecurrenceService,
], ],
}) })
export class InspectionFindingsModule {} export class InspectionFindingsModule {}
@@ -62,7 +62,6 @@ interface FindingDocumentView {
actStatus: InspectionActStatus; actStatus: InspectionActStatus;
visitId: string; visitId: string;
visitCode: string; visitCode: string;
visitTitle: string;
visitStatus: InspectionVisitStatus; visitStatus: InspectionVisitStatus;
} }
@@ -99,7 +98,6 @@ export interface InspectionFindingListItem {
verificationVisit: { verificationVisit: {
id: string; id: string;
code: string; code: string;
title: string;
status: InspectionVisitStatus; status: InspectionVisitStatus;
plannedStartAt: Date | null; plannedStartAt: Date | null;
} | null; } | null;
@@ -143,7 +141,6 @@ export interface FindingVerificationHistoryEvent {
verificationVisit: { verificationVisit: {
id: string; id: string;
code: string; code: string;
title: string;
status: InspectionVisitStatus; status: InspectionVisitStatus;
plannedStartAt: Date | null; plannedStartAt: Date | null;
} | null; } | null;
@@ -811,13 +808,11 @@ export class InspectionFindingsService {
'actStatus', act.status, 'actStatus', act.status,
'visitId', visit.id, 'visitId', visit.id,
'visitCode', visit.code, 'visitCode', visit.code,
'visitTitle', visit.title,
'visitStatus', visit.status 'visitStatus', visit.status
) AS document, ) AS document,
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', verification_visit.id, 'id', verification_visit.id,
'code', verification_visit.code, 'code', verification_visit.code,
'title', verification_visit.title,
'status', verification_visit.status, 'status', verification_visit.status,
'plannedStartAt', verification_visit.planned_start_at 'plannedStartAt', verification_visit.planned_start_at
) END AS "verificationVisit", ) END AS "verificationVisit",
@@ -845,7 +840,7 @@ export class InspectionFindingsService {
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id LEFT JOIN finding_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 (
SELECT verification_target.id, verification_target.code, verification_target.title, SELECT verification_target.id, verification_target.code,
verification_target.status, verification_target.planned_start_at verification_target.status, verification_target.planned_start_at
FROM inspection_finding_verification_visits verification_link FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
@@ -923,7 +918,6 @@ export class InspectionFindingsService {
CASE WHEN visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', visit.id, 'id', visit.id,
'code', visit.code, 'code', visit.code,
'title', visit.title,
'status', visit.status, 'status', visit.status,
'plannedStartAt', visit.planned_start_at 'plannedStartAt', visit.planned_start_at
) END AS "verificationVisit", ) END AS "verificationVisit",
@@ -1,17 +1,128 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Req } from '@nestjs/common'; import { Body, Controller, ForbiddenException, Get, Param, ParseUUIDPipe, Patch, Post, Put, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { 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 { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../database/entities';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
import type { DeliveryRow } from './inspection-document-delivery.service'; import type { DeliveryRow } from './inspection-document-delivery.service';
import { SmtpDeliveryService } from './smtp-delivery.service';
import { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto'; import { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
import { TestSmtpSettingsDto, UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto';
@Controller('document-delivery') @Controller('document-delivery')
export class DocumentDeliveryController { export class DocumentDeliveryController {
constructor(private readonly delivery:InspectionDocumentDeliveryService){} constructor(
@Get('settings') @RequirePermissions('document_delivery.read') settings(){return this.delivery.settings();} private readonly delivery: InspectionDocumentDeliveryService,
@Patch('settings') @RequirePermissions('document_delivery.manage') update(@Body() dto:UpdateDocumentDeliverySettingsDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.updateSettings(dto,principal,request);} private readonly smtp: SmtpDeliveryService,
@Get('outbox') @RequirePermissions('document_delivery.read') outbox(){return this.delivery.list();} private readonly audit: AuditService,
@Post('outbox/:id/retry') @RequirePermissions('document_delivery.manage') retry(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext):Promise<DeliveryRow>{return this.delivery.retry(id,principal,request);} ) {}
@Post('outbox/retry-pending') @RequirePermissions('document_delivery.manage') retryPending(@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.retryPending(principal,request);}
@Get('settings')
@RequirePermissions('document_delivery.read')
settings() {
return this.delivery.settings();
}
@Patch('settings')
@RequirePermissions('document_delivery.manage')
update(
@Body() dto: UpdateDocumentDeliverySettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.delivery.updateSettings(dto, principal, request);
}
@Get('smtp')
@RequirePermissions('document_delivery.manage')
smtpSettings(@CurrentAuth() principal: AuthPrincipal) {
this.assertSystemAdmin(principal);
return this.smtp.publicSettings();
}
@Put('smtp')
@RequirePermissions('document_delivery.manage')
async updateSmtp(
@Body() dto: UpdateSmtpSettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
this.assertSystemAdmin(principal);
const before = await this.smtp.publicSettings();
const after = await this.smtp.saveSettings(dto, principal.userId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SMTP_SETTINGS_UPDATED,
entityType: 'system_smtp_settings',
entityId: 'singleton',
beforeData: before as Record<string, unknown>,
afterData: after as Record<string, unknown>,
metadata: { passwordNeverReturned: true, restrictedToRole: 'admin' },
});
return after;
}
@Post('smtp/test')
@RequirePermissions('document_delivery.manage')
async testSmtp(
@Body() dto: TestSmtpSettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
this.assertSystemAdmin(principal);
const sent = await this.smtp.send({
to: dto.email,
subject: 'DH Inspección · Prueba SMTP',
text: 'Este correo confirma que la configuración SMTP de DH Inspección funciona correctamente.',
attachment: {
filename: 'dh-inspeccion-smtp-test.txt',
mimeType: 'text/plain',
content: Buffer.from('DH Inspección · Prueba SMTP OK\n', 'utf8'),
},
});
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SMTP_TEST_SENT,
entityType: 'system_smtp_settings',
entityId: 'singleton',
afterData: { recipient: dto.email, messageId: sent.messageId },
metadata: { restrictedToRole: 'admin' },
});
return { ok: true, recipient: dto.email, messageId: sent.messageId };
}
@Get('outbox')
@RequirePermissions('document_delivery.read')
outbox() {
return this.delivery.list();
}
@Post('outbox/:id/retry')
@RequirePermissions('document_delivery.manage')
retry(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<DeliveryRow> {
return this.delivery.retry(id, principal, request);
}
@Post('outbox/retry-pending')
@RequirePermissions('document_delivery.manage')
retryPending(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.delivery.retryPending(principal, request);
}
private assertSystemAdmin(principal: AuthPrincipal): void {
if (principal.roles.includes('admin')) return;
throw new ForbiddenException({
code: 'SMTP_SUPERADMIN_REQUIRED',
message: 'La configuración SMTP está reservada al Superadmin del sistema',
});
}
} }
@@ -0,0 +1,23 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
import { InspectionReportFollowUpType } from '../../database/entities';
export class CreateInspectionReportFollowUpDto {
@IsEnum(InspectionReportFollowUpType)
type!: InspectionReportFollowUpType;
@IsISO8601({ strict: true })
occurredAt!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MaxLength(255)
externalReference?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MaxLength(20000)
description?: string | null;
}
@@ -0,0 +1,13 @@
import { Transform } from 'class-transformer';
import { IsISO8601, IsString, MaxLength, MinLength } from 'class-validator';
export class OfficializeInspectionReportDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(255)
gedoIfIdentifier!: string;
@IsISO8601({ strict: true })
gedoOfficializedAt!: string;
}
@@ -0,0 +1,48 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { InspectionDeadlineDayType } from '../../database/entities';
export class UpdateDeadlinePolicyDto {
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
urgentDays!: number;
@IsEnum(InspectionDeadlineDayType)
urgentDayType!: InspectionDeadlineDayType;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
nonUrgentDays!: number;
@IsEnum(InspectionDeadlineDayType)
nonUrgentDayType!: InspectionDeadlineDayType;
}
export class UpsertBusinessCalendarDayDto {
@IsDateString()
date!: string;
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
isBusinessDay!: boolean;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(200)
label!: string;
}
@@ -1,2 +1,12 @@
import { Transform } from 'class-transformer'; import { IsEmail,IsOptional,MaxLength } from 'class-validator'; import { Transform } from 'class-transformer';
export class UpdateDocumentDeliverySettingsDto { @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) officeEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) directorEmail?:string|null; } import { IsEmail, IsOptional, MaxLength } from 'class-validator';
export class UpdateDocumentDeliverySettingsDto {
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
)
@IsEmail()
@MaxLength(320)
officeEmail?: string | null;
}
@@ -0,0 +1,16 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateInspectionReportDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MaxLength(20000)
executiveSummary?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MaxLength(40000)
description?: string | null;
}
@@ -0,0 +1,71 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEmail,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { SmtpSecurityMode } from '../../database/entities';
export class UpdateSmtpSettingsDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(255)
host!: string;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(65535)
port!: number;
@IsEnum(SmtpSecurityMode)
securityMode!: SmtpSecurityMode;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MaxLength(255)
username?: string | null;
/** Omitir para conservar la clave actual; enviar null/cadena vacía para quitarla. */
@IsOptional()
@IsString()
@MaxLength(1000)
password?: string | null;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(200)
fromName!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsEmail()
@MaxLength(320)
fromEmail!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null)
@IsEmail()
@MaxLength(320)
replyTo?: string | null;
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
enabled!: boolean;
}
export class TestSmtpSettingsDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsEmail()
@MaxLength(320)
email!: string;
}
@@ -1,88 +1,212 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
function asRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; } function asRecord(value: unknown): Record<string, unknown> {
function asArray(value: unknown): Array<Record<string, unknown>> { return Array.isArray(value) ? value.map(asRecord) : []; } return value && typeof value === 'object' && !Array.isArray(value)
function text(value: unknown, fallback='-'): string { const out=String(value ?? '').trim(); return out || fallback; } ? value as Record<string, unknown>
function date(value: unknown): string { const d=new Date(String(value ?? '')); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('es-AR') : '-'; } : {};
function clean(value: string): string { return value.normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[–—]/g,'-').replace(/[“”]/g,'"').replace(/[‘’]/g,"'").replace(/[^\x20-\xFF]/g,'?'); } }
function escapePdf(value: string): string { return clean(value).replaceAll('\\','\\\\').replaceAll('(','\\(').replaceAll(')','\\)'); }
function wrap(value: string, max=92): string[] { const words=clean(value).split(/\s+/).filter(Boolean); const out:string[]=[]; let line=''; for(const word of words){ const next=line?`${line} ${word}`:word; if(next.length>max&&line){out.push(line);line=word;}else line=next;} if(line)out.push(line); return out.length?out:['-']; } function asArray(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value.map(asRecord) : [];
}
function text(value: unknown, fallback = '-'): string {
const out = String(value ?? '').trim();
return out || fallback;
}
function date(value: unknown): string {
const parsed = new Date(String(value ?? ''));
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '-';
}
function clean(value: string): string {
return value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[–—]/g, '-')
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/[^\x20-\xFF]/g, '?');
}
function escapePdf(value: string): string {
return clean(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)');
}
function wrap(value: string, max = 92): string[] {
const words = clean(value).split(/\s+/).filter(Boolean);
const out: string[] = [];
let line = '';
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > max && line) {
out.push(line);
line = word;
} else {
line = next;
}
}
if (line) out.push(line);
return out.length ? out : ['-'];
}
function lockedSnapshot(snapshot: Record<string, unknown>): {
locked: Record<string, unknown>;
signatures: Array<Record<string, unknown>>;
finalSha256: unknown;
} {
const sealed = asRecord(snapshot);
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
return {
locked,
signatures: asArray(sealed.signatures),
finalSha256: sealed.finalSha256 ?? sealed.lockedSha256 ?? sealed.preparedSha256,
};
}
function urgencyLabel(value: unknown): string {
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
}
function dayTypeLabel(value: unknown): string {
return text(value, '') === 'CALENDAR' ? 'dias corridos' : 'dias habiles';
}
function lines(snapshot: Record<string, unknown>): string[] { function lines(snapshot: Record<string, unknown>): string[] {
const prepared=asRecord(asRecord(snapshot).preparedSnapshot); const source = lockedSnapshot(snapshot);
const act=asRecord(prepared.act); const locked = source.locked;
const visit=asRecord(act.visit); const act = asRecord(locked.act);
const responsible=asRecord(prepared.responsible); const inspection = asRecord(act.inspection ?? asRecord(act).visit);
const team=asArray(prepared.team); const responsible = asRecord(locked.responsible);
const assets=asArray(prepared.assets); const inventories = asArray(locked.inventories ?? locked.assets);
const findings=asArray(prepared.findings); const findings = asArray(locked.findings);
const signatures=asArray(snapshot.signatures); const signatures = source.signatures;
const companySignature=signatures.find(item=>text(item.signerType,'')==='COMPANY_RESPONSIBLE'); const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE');
const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))]; const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR');
const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))];
const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean); const deadlineText = act.deadlineAt
const out:string[]=[ ? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})`
: act.deadlineBasis === 'GEDO_DATE'
? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO`
: '-';
const out: string[] = [
'ACTA DE INSPECCION', 'ACTA DE INSPECCION',
'Documento automatico - diseno institucional pendiente de definicion',
'', '',
`Acta: ${text(act.code)}`, `Acta: ${text(act.code)}`,
`Inspeccion: ${text(visit.code)}`, `Inspeccion: ${text(inspection.code)}`,
`Fecha: ${date(act.occurredAt)}`, `Fecha: ${date(act.occurredAt)}`,
`Empresa: ${companies.join(' / ') || 'Segun alcance del acta'}`, `Urgencia: ${urgencyLabel(act.urgency)}`,
`Area: ${areas.join(' / ') || 'Segun alcance del acta'}`, `Plazo: ${deadlineText}`,
`Inspector/es: ${inspectors.join(' / ') || '-'}`,
`Responsable empresa: ${text(responsible.fullName)}`, `Responsable empresa: ${text(responsible.fullName)}`,
`Cargo: ${text(responsible.position)}`, `Cargo: ${text(responsible.position)}`,
'', '',
'OBJETIVO', ...wrap(text(visit.objective)), '', 'RESUMEN',
'RESUMEN', ...wrap(text(act.summary)), '', ...wrap(text(act.summary)),
'OBSERVACIONES', ...wrap(text(act.observations)), '', '',
'ELEMENTOS INSPECCIONADOS', 'OBSERVACIONES',
...wrap(text(act.observations)),
'',
'INVENTARIO INSPECCIONADO',
]; ];
if(!assets.length) out.push('-');
for(const item of assets) out.push(...wrap(`${text(item.code)} | ${text(item.name)}${text(item.commonName,'') ? ` | ${text(item.commonName,'')}` : ''} | ${text(item.typeName)}`)); if (!inventories.length) out.push('-');
out.push('', 'HALLAZGOS'); for (const item of inventories) {
if(!findings.length) out.push('Sin hallazgos registrados.'); out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`));
for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); }
out.push('', 'CONSTANCIA DE LA EMPRESA');
if(!companySignature) out.push('Firma o constancia pendiente.');
else if(text(companySignature.status,'')==='SIGNED'){
const manifestation=text(companySignature.companyManifestation,'CONFORMITY');
out.push(manifestation==='DISSENT'?'Firma en disidencia':'Firma en conformidad');
if(manifestation==='DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
} else {
out.push(...wrap(`${text(companySignature.status)}: ${text(companySignature.reason)}`));
} }
out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`);
out.push('', 'HALLAZGOS');
if (!findings.length) out.push('Sin hallazgos registrados.');
for (const item of findings) {
const recurrence = item.isRecurrence
? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}`
: '';
out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`));
out.push(...wrap(`Descripcion: ${text(item.description)}`));
if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`));
}
out.push('', 'FIRMAS Y CONSTANCIAS');
if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.');
for (const signature of inspectorSignatures) {
out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`));
}
if (!companySignature) {
out.push('Manifestacion de empresa: pendiente.');
} else if (text(companySignature.status, '') === 'SIGNED') {
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY');
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disidencia' : 'Empresa: firma en conformidad');
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
} else {
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
}
out.push(
'',
'INTEGRIDAD',
`Hash del Acta sellada: ${text(source.finalSha256)}`,
`Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`,
);
return out; return out;
} }
function objectBuffer(id: number, body: Buffer|string): Buffer { const data=Buffer.isBuffer(body)?body:Buffer.from(body,'latin1'); return Buffer.concat([Buffer.from(`${id} 0 obj\n`,'ascii'),data,Buffer.from('\nendobj\n','ascii')]); } function objectBuffer(id: number, body: Buffer | string): Buffer {
const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
return Buffer.concat([
Buffer.from(`${id} 0 obj\n`, 'ascii'),
data,
Buffer.from('\nendobj\n', 'ascii'),
]);
}
export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } { export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } {
const all=lines(snapshot); const all = lines(snapshot);
const chunks:Array<string[]>=[]; const chunks: Array<string[]> = [];
for(let i=0;i<all.length;i+=56) chunks.push(all.slice(i,i+56)); for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56));
if(!chunks.length) chunks.push(['ACTA DE INSPECCION']); if (!chunks.length) chunks.push(['ACTA DE INSPECCION']);
const pageCount=chunks.length;
const pageIds=Array.from({length:pageCount},(_,i)=>4+i*2); const pageCount = chunks.length;
const contentIds=Array.from({length:pageCount},(_,i)=>5+i*2); const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2);
const objects:Buffer[]=[]; const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2);
objects.push(objectBuffer(1,'<< /Type /Catalog /Pages 2 0 R >>')); const objects: Buffer[] = [];
objects.push(objectBuffer(2,`<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map(id=>`${id} 0 R`).join(' ')}] >>`)); objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>'));
objects.push(objectBuffer(3,'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')); objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`));
chunks.forEach((chunk,index)=>{ objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
const content=chunk.map((line,i)=>`${i===0?'':'T* '}(${escapePdf(line)}) Tj`).join('\n');
const stream=Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`,'latin1'); chunks.forEach((chunk, index) => {
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 >>`)); const content = chunk
objects.push(objectBuffer(contentIds[index]!,Buffer.concat([Buffer.from(`<< /Length ${stream.length} >>\nstream\n`,'ascii'),stream,Buffer.from('\nendstream','ascii')]))); .map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`)
.join('\n');
const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1');
objects.push(objectBuffer(
pageIds[index]!,
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`,
));
objects.push(objectBuffer(
contentIds[index]!,
Buffer.concat([
Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'),
stream,
Buffer.from('\nendstream', 'ascii'),
]),
));
}); });
const header=Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n','binary');
const offsets:number[]=[0]; let pos=header.length; const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary');
for(const obj of objects){ offsets.push(pos); pos+=obj.length; } const offsets: number[] = [0];
const xrefOffset=pos; let position = header.length;
const xref=[`xref\n0 ${objects.length+1}\n`,`0000000000 65535 f \n`,...objects.map((_,i)=>`${String(offsets[i+1]).padStart(10,'0')} 00000 n \n`)].join(''); for (const object of objects) {
const trailer=`trailer\n<< /Size ${objects.length+1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; offsets.push(position);
const buffer=Buffer.concat([header,...objects,Buffer.from(xref+trailer,'ascii')]); position += object.length;
return { buffer, sha256:createHash('sha256').update(buffer).digest('hex') }; }
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') };
} }
@@ -8,20 +8,112 @@ import { buildInspectionActPdf } from './inspection-act-pdf-builder';
@Injectable() @Injectable()
export class InspectionActPdfService { export class InspectionActPdfService {
private readonly root:string; private readonly root: string;
constructor(private readonly dataSource:DataSource, config:ConfigService){ const configured=config.get<string>('INSPECTION_ACT_PDF_ROOT')??'/app/storage/asset-media/inspection-acts-pdf'; if(!isAbsolute(configured))throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); this.root=resolve(configured); if(this.root===parse(this.root).root)throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); }
async ensure(actId:string):Promise<void>{ constructor(private readonly dataSource: DataSource, config: ConfigService) {
const [row]=await this.dataSource.query(`SELECT a.id,a.code,a.closure_sha256 AS "closureSha256",c.final_snapshot AS "finalSnapshot",p.status,p.stored_name AS "storedName" FROM inspection_acts a JOIN inspection_act_closures c ON c.act_id=a.id LEFT JOIN inspection_act_pdf_artifacts p ON p.act_id=a.id WHERE a.id=$1 AND a.status='CLOSED'`,[actId]) as Array<{id:string;code:string;closureSha256:string;finalSnapshot:Record<string,unknown>;status:string|null;storedName:string|null}>; const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT')
if(!row)throw new NotFoundException({code:'INSPECTION_ACT_NOT_CLOSED',message:'El acta cerrada no está disponible'}); ?? '/app/storage/asset-media/inspection-acts-pdf';
if(row.status==='READY'&&row.storedName){ try{ await this.content(actId); return; }catch{} } if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path');
await this.dataSource.query(`INSERT INTO inspection_act_pdf_artifacts (act_id,status) VALUES ($1,'PENDING') ON CONFLICT (act_id) DO UPDATE SET status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP`,[actId]); this.root = resolve(configured);
try{ if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root');
const snapshot={...row.finalSnapshot,finalSha256:row.closureSha256}; const built=buildInspectionActPdf(snapshot); await mkdir(this.root,{recursive:true,mode:0o700}); const storedName=`${actId}.pdf`; const originalName=`${row.code}.pdf`; const filePath=resolve(this.root,storedName); await writeFile(filePath,built.buffer,{mode:0o600});
await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf',size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,originalName,storedName,built.buffer.length,built.sha256]);
}catch(error){ const message=error instanceof Error?error.message.slice(0,500):'Error desconocido'; await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,message]).catch(()=>undefined); }
} }
async content(actId:string):Promise<{buffer:Buffer;originalName:string;mimeType:string}>{ const [row]=await this.dataSource.query(`SELECT original_name AS "originalName",stored_name AS "storedName",mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 FROM inspection_act_pdf_artifacts WHERE act_id=$1 AND status='READY'`,[actId]) as Array<{originalName:string;storedName:string;mimeType:string;sizeBytes:number;sha256:string}>; if(!row)throw new NotFoundException({code:'INSPECTION_ACT_PDF_NOT_READY',message:'El PDF del acta todavía no está disponible'}); const filePath=resolve(this.root,row.storedName); if(!filePath.startsWith(`${this.root}/`))throw this.storageError(); const st=await stat(filePath).catch(()=>null); if(!st?.isFile()||st.size!==row.sizeBytes)throw this.storageError(); const buffer=await readFile(filePath); if(createHash('sha256').update(buffer).digest('hex')!==row.sha256)throw this.storageError(); return {buffer,originalName:row.originalName,mimeType:row.mimeType}; } async ensure(actId: string): Promise<void> {
private storageError(){return new InternalServerErrorException({code:'INSPECTION_ACT_PDF_STORAGE_ERROR',message:'El PDF del acta no está disponible o no supera la validación de integridad'});} const [row] = await this.dataSource.query(`
SELECT
act.id,
act.code,
act.closure_sha256 AS "closureSha256",
closure.final_snapshot AS "finalSnapshot",
artifact.status,
artifact.stored_name AS "storedName"
FROM inspection_acts act
JOIN inspection_act_closures closure ON closure.act_id=act.id
LEFT JOIN inspection_act_pdf_artifacts artifact ON artifact.act_id=act.id
WHERE act.id=$1 AND act.status='SEALED'
`, [actId]) as Array<{
id: string;
code: string;
closureSha256: string;
finalSnapshot: Record<string, unknown>;
status: string | null;
storedName: string | null;
}>;
if (!row) {
throw new NotFoundException({
code: 'INSPECTION_ACT_NOT_SEALED',
message: 'El PDF sólo puede generarse desde un Acta SELLADA',
});
}
if (row.status === 'READY' && row.storedName) {
try {
await this.content(actId);
return;
} catch {}
}
await this.dataSource.query(`
INSERT INTO inspection_act_pdf_artifacts(act_id,status)
VALUES($1,'PENDING')
ON CONFLICT (act_id) DO UPDATE SET
status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP
`, [actId]);
try {
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
const built = buildInspectionActPdf(snapshot);
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${actId}.pdf`;
const originalName = `${row.code}.pdf`;
const filePath = resolve(this.root, storedName);
await writeFile(filePath, built.buffer, { mode: 0o600 });
await this.dataSource.query(`
UPDATE inspection_act_pdf_artifacts
SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf',
size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL,
updated_at=CURRENT_TIMESTAMP
WHERE act_id=$1
`, [actId, originalName, storedName, built.buffer.length, built.sha256]);
} catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido';
await this.dataSource.query(`
UPDATE inspection_act_pdf_artifacts
SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP
WHERE act_id=$1
`, [actId, message]).catch(() => undefined);
}
}
async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(`
SELECT original_name AS "originalName",stored_name AS "storedName",
mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256
FROM inspection_act_pdf_artifacts
WHERE act_id=$1 AND status='READY'
`, [actId]) as Array<{
originalName: string;
storedName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
}>;
if (!row) {
throw new NotFoundException({
code: 'INSPECTION_ACT_PDF_NOT_READY',
message: 'El PDF del Acta todavía no está disponible',
});
}
const filePath = resolve(this.root, row.storedName);
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.storageError();
const buffer = await readFile(filePath);
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError();
return { buffer, originalName: row.originalName, mimeType: row.mimeType };
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({
code: 'INSPECTION_ACT_PDF_STORAGE_ERROR',
message: 'El PDF del Acta no está disponible o no supera la validación de integridad',
});
}
} }
@@ -0,0 +1,53 @@
import { Body, Controller, Delete, Get, Param, Put, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { UpdateDeadlinePolicyDto, UpsertBusinessCalendarDayDto } from './dto/update-deadline-policy.dto';
import { InspectionDeadlineAdminService } from './inspection-deadline-admin.service';
@Controller('inspection-deadlines')
export class InspectionDeadlineAdminController {
constructor(private readonly deadlines: InspectionDeadlineAdminService) {}
@Get('policy')
@RequirePermissions('inspections.read')
policy() {
return this.deadlines.policy();
}
@Put('policy')
@RequirePermissions('inspections.manage')
updatePolicy(
@Body() dto: UpdateDeadlinePolicyDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.deadlines.updatePolicy(dto, principal, request);
}
@Get('calendar')
@RequirePermissions('inspections.read')
calendar() {
return this.deadlines.calendar();
}
@Put('calendar')
@RequirePermissions('inspections.manage')
upsertDay(
@Body() dto: UpsertBusinessCalendarDayDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.deadlines.upsertCalendarDay(dto, principal, request);
}
@Delete('calendar/:date')
@RequirePermissions('inspections.manage')
removeDay(
@Param('date') date: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.deadlines.removeCalendarDay(date, principal, request);
}
}
@@ -0,0 +1,160 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import type { UpdateDeadlinePolicyDto, UpsertBusinessCalendarDayDto } from './dto/update-deadline-policy.dto';
@Injectable()
export class InspectionDeadlineAdminService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async policy() {
const [row] = await this.dataSource.query(`
SELECT id,
urgent_days AS "urgentDays",
urgent_day_type AS "urgentDayType",
non_urgent_days AS "nonUrgentDays",
non_urgent_day_type AS "nonUrgentDayType",
updated_by AS "updatedBy",
updated_at AS "updatedAt"
FROM inspection_deadline_policies
ORDER BY created_at
LIMIT 1
`);
return row ?? {
urgentDays: 5,
urgentDayType: 'BUSINESS',
nonUrgentDays: 10,
nonUrgentDayType: 'BUSINESS',
};
}
async updatePolicy(
dto: UpdateDeadlinePolicyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const before = await this.policy();
const [existing] = await this.dataSource.query(`
SELECT id FROM inspection_deadline_policies ORDER BY created_at LIMIT 1
`) as Array<{ id: string }>;
if (existing) {
await this.dataSource.query(`
UPDATE inspection_deadline_policies
SET urgent_days=$2,
urgent_day_type=$3,
non_urgent_days=$4,
non_urgent_day_type=$5,
updated_by=$6,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [
existing.id,
dto.urgentDays,
dto.urgentDayType,
dto.nonUrgentDays,
dto.nonUrgentDayType,
principal.userId,
]);
} else {
await this.dataSource.query(`
INSERT INTO inspection_deadline_policies(
urgent_days,urgent_day_type,non_urgent_days,non_urgent_day_type,updated_by
) VALUES($1,$2,$3,$4,$5)
`, [
dto.urgentDays,
dto.urgentDayType,
dto.nonUrgentDays,
dto.nonUrgentDayType,
principal.userId,
]);
}
const after = await this.policy();
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_DEADLINE_POLICY_UPDATED,
entityType: 'inspection_deadline_policy',
entityId: String((after as { id?: string }).id ?? 'singleton'),
beforeData: before as Record<string, unknown>,
afterData: after as Record<string, unknown>,
metadata: {
appliesOnlyToFutureLockedActs: true,
historicalActsKeepSnapshot: true,
},
});
return after;
}
async calendar() {
const data = await this.dataSource.query(`
SELECT id,date,is_business_day AS "isBusinessDay",label,
updated_by AS "updatedBy",created_at AS "createdAt",updated_at AS "updatedAt"
FROM inspection_business_calendar_days
ORDER BY date
`);
return { data };
}
async upsertCalendarDay(
dto: UpsertBusinessCalendarDayDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const [before] = await this.dataSource.query(`
SELECT id,date,is_business_day AS "isBusinessDay",label
FROM inspection_business_calendar_days WHERE date=$1::date
`, [dto.date]);
await this.dataSource.query(`
INSERT INTO inspection_business_calendar_days(date,is_business_day,label,updated_by)
VALUES($1::date,$2,$3,$4)
ON CONFLICT(date) DO UPDATE SET
is_business_day=EXCLUDED.is_business_day,
label=EXCLUDED.label,
updated_by=EXCLUDED.updated_by,
updated_at=CURRENT_TIMESTAMP
`, [dto.date, dto.isBusinessDay, dto.label, principal.userId]);
const [after] = await this.dataSource.query(`
SELECT id,date,is_business_day AS "isBusinessDay",label,
updated_by AS "updatedBy",updated_at AS "updatedAt"
FROM inspection_business_calendar_days WHERE date=$1::date
`, [dto.date]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_BUSINESS_CALENDAR_UPDATED,
entityType: 'inspection_business_calendar_day',
entityId: String(after.id),
beforeData: before ?? null,
afterData: after,
});
return after;
}
async removeCalendarDay(
date: string,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const [before] = await this.dataSource.query(`
SELECT id,date,is_business_day AS "isBusinessDay",label
FROM inspection_business_calendar_days WHERE date=$1::date
`, [date]);
if (before) {
await this.dataSource.query(`DELETE FROM inspection_business_calendar_days WHERE id=$1`, [before.id]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_BUSINESS_CALENDAR_UPDATED,
entityType: 'inspection_business_calendar_day',
entityId: String(before.id),
beforeData: before,
afterData: null,
metadata: { removed: true },
});
}
return { removed: Boolean(before), date };
}
}
@@ -10,7 +10,7 @@ import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionReportWordService } from './inspection-report-word.service';
import { SmtpDeliveryService } from './smtp-delivery.service'; import { SmtpDeliveryService } from './smtp-delivery.service';
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR'; export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR';
export interface DeliveryRow { export interface DeliveryRow {
id: string; id: string;
@@ -40,21 +40,14 @@ export class InspectionDocumentDeliveryService {
async settings() { async settings() {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT SELECT office_email AS "officeEmail",updated_at AS "updatedAt"
office_email AS "officeEmail",
director_email AS "directorEmail",
updated_at AS "updatedAt"
FROM institutional_delivery_settings FROM institutional_delivery_settings
WHERE id=1 WHERE id=1
`) as Array<{ `) as Array<{ officeEmail: string | null; updatedAt: Date }>;
officeEmail: string | null;
directorEmail: string | null;
updatedAt: Date;
}>;
return { return {
...row, ...row,
smtpConfigured: this.smtp.configured(), smtpConfigured: await this.smtp.configured(),
mailFrom: this.config.get<string>('MAIL_FROM') ?? null, mailFrom: await this.smtp.fromAddress() ?? this.config.get<string>('MAIL_FROM') ?? null,
}; };
} }
@@ -66,9 +59,9 @@ export class InspectionDocumentDeliveryService {
const before = await this.settings(); const before = await this.settings();
await this.dataSource.query(` await this.dataSource.query(`
UPDATE institutional_delivery_settings UPDATE institutional_delivery_settings
SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP SET office_email=$1,updated_by=$2,updated_at=CURRENT_TIMESTAMP
WHERE id=1 WHERE id=1
`, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]); `, [dto.officeEmail ?? null, principal.userId]);
const after = await this.settings(); const after = await this.settings();
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -84,33 +77,23 @@ export class InspectionDocumentDeliveryService {
async list() { async list() {
const data = await this.dataSource.query(` const data = await this.dataSource.query(`
SELECT SELECT
d.id, d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.act_id AS "actId", d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
d.report_id AS "reportId", d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
d.document_kind AS "documentKind", d.recipient_email AS "recipientEmail",d.status,d.attempts,
d.recipient_kind AS "recipientKind", d.last_attempt_at AS "lastAttemptAt",d.sent_at AS "sentAt",
d.recipient_asset_id AS "recipientAssetId", d.provider_message_id AS "providerMessageId",d.last_error AS "lastError",
d.recipient_user_id AS "recipientUserId", d.created_at AS "createdAt",a.code AS "actCode",r.code AS "reportCode",
d.recipient_email AS "recipientEmail",
d.status,
d.attempts,
d.last_attempt_at AS "lastAttemptAt",
d.sent_at AS "sentAt",
d.provider_message_id AS "providerMessageId",
d.last_error AS "lastError",
d.created_at AS "createdAt",
a.code AS "actCode",
r.code AS "reportCode",
recipient.name AS "recipientAssetName", recipient.name AS "recipientAssetName",
CASE CASE WHEN recipient_user.id IS NULL THEN NULL
WHEN recipient_user.id IS NULL THEN NULL ELSE btrim(concat_ws(' ',recipient_user.first_name,recipient_user.last_name))
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
END AS "recipientUserName" END AS "recipientUserName"
FROM inspection_document_deliveries d FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id LEFT JOIN inspection_reports r ON r.id=d.report_id
LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id
WHERE d.recipient_kind<>'DIRECTOR'
ORDER BY d.created_at DESC ORDER BY d.created_at DESC
LIMIT 200 LIMIT 200
`); `);
@@ -154,11 +137,9 @@ export class InspectionDocumentDeliveryService {
async retryPending(principal: AuthPrincipal, request: RequestWithContext) { async retryPending(principal: AuthPrincipal, request: RequestWithContext) {
const rows = await this.dataSource.query(` const rows = await this.dataSource.query(`
SELECT id SELECT id FROM inspection_document_deliveries
FROM inspection_document_deliveries WHERE status<>'SENT' AND recipient_kind<>'DIRECTOR'
WHERE status<>'SENT' ORDER BY created_at ASC LIMIT 100
ORDER BY created_at ASC
LIMIT 100
`) as Array<{ id: string }>; `) as Array<{ id: string }>;
for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined); for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined);
return { processed: rows.length }; return { processed: rows.length };
@@ -166,10 +147,9 @@ export class InspectionDocumentDeliveryService {
private async ensureRows(actId: string, reportId: string | null) { private async ensureRows(actId: string, reportId: string | null) {
const [settings] = await this.dataSource.query(` const [settings] = await this.dataSource.query(`
SELECT office_email AS "officeEmail",director_email AS "directorEmail" SELECT office_email AS "officeEmail"
FROM institutional_delivery_settings FROM institutional_delivery_settings WHERE id=1
WHERE id=1 `) as Array<{ officeEmail: string | null }>;
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
const companies = await this.dataSource.query(` const companies = await this.dataSource.query(`
SELECT DISTINCT company.id,profile.notification_email AS email SELECT DISTINCT company.id,profile.notification_email AS email
@@ -185,9 +165,7 @@ export class InspectionDocumentDeliveryService {
`, [actId]) as Array<{ id: string; email: string | null }>; `, [actId]) as Array<{ id: string; email: string | null }>;
const [inspector] = await this.dataSource.query(` const [inspector] = await this.dataSource.query(`
SELECT SELECT inspector.id,inspector.email
inspector.id,
inspector.email
FROM inspection_acts act FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id JOIN inspection_visits visit ON visit.id=act.visit_id
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id JOIN users inspector ON inspector.id=visit.lead_inspector_user_id
@@ -196,52 +174,32 @@ export class InspectionDocumentDeliveryService {
for (const company of companies) { for (const company of companies) {
await this.upsertRow({ await this.upsertRow({
actId, actId,reportId,documentKind:'ACT_PDF',recipientKind:'COMPANY',
reportId, recipientAssetId:company.id,recipientUserId:null,recipientKey:company.id,
documentKind: 'ACT_PDF', recipientEmail:company.email,
recipientKind: 'COMPANY',
recipientAssetId: company.id,
recipientUserId: null,
recipientKey: company.id,
recipientEmail: company.email,
}); });
} }
await this.upsertRow({ await this.upsertRow({
actId, actId,reportId,documentKind:'ACT_PDF',recipientKind:'OFFICE',
reportId, recipientAssetId:null,recipientUserId:null,
documentKind: 'ACT_PDF', recipientKey:'00000000-0000-0000-0000-000000000000',
recipientKind: 'OFFICE', recipientEmail:settings?.officeEmail ?? null,
recipientAssetId: null,
recipientUserId: null,
recipientKey: '00000000-0000-0000-0000-000000000000',
recipientEmail: settings?.officeEmail ?? null,
}); });
if (inspector) { if (inspector) {
await this.upsertRow({ await this.upsertRow({
actId, actId,reportId,documentKind:'ACT_PDF',recipientKind:'INSPECTOR',
reportId, recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id,
documentKind: 'ACT_PDF', recipientEmail:inspector.email,
recipientKind: 'INSPECTOR',
recipientAssetId: null,
recipientUserId: inspector.id,
recipientKey: inspector.id,
recipientEmail: inspector.email,
});
}
if (reportId) {
await this.upsertRow({
actId,
reportId,
documentKind: 'REPORT_WORD',
recipientKind: 'DIRECTOR',
recipientAssetId: null,
recipientUserId: null,
recipientKey: '00000000-0000-0000-0000-000000000000',
recipientEmail: settings?.directorEmail ?? null,
}); });
if (reportId) {
await this.upsertRow({
actId,reportId,documentKind:'REPORT_WORD',recipientKind:'INSPECTOR',
recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id,
recipientEmail:inspector.email,
});
}
} }
} }
@@ -267,31 +225,21 @@ export class InspectionDocumentDeliveryService {
recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id), recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id),
recipient_email=CASE recipient_email=CASE
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email
ELSE EXCLUDED.recipient_email ELSE EXCLUDED.recipient_email END,
END,
status=CASE status=CASE
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT' WHEN inspection_document_deliveries.status='SENT' THEN 'SENT'
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT' WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
ELSE inspection_document_deliveries.status ELSE inspection_document_deliveries.status END,
END,
updated_at=CURRENT_TIMESTAMP updated_at=CURRENT_TIMESTAMP
`, [ `, [
input.actId, input.actId,input.reportId,input.documentKind,input.recipientKind,
input.reportId, input.recipientAssetId,input.recipientUserId,input.recipientKey,input.recipientEmail,initialStatus,
input.documentKind,
input.recipientKind,
input.recipientAssetId,
input.recipientUserId,
input.recipientKey,
input.recipientEmail,
initialStatus,
]); ]);
} }
private async rowsForAct(actId: string): Promise<DeliveryRow[]> { private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
return this.dataSource.query(` return this.dataSource.query(`
SELECT SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",d.status,d.attempts, d.recipient_email AS "recipientEmail",d.status,d.attempts,
@@ -299,15 +247,14 @@ export class InspectionDocumentDeliveryService {
FROM inspection_document_deliveries d FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id LEFT JOIN inspection_reports r ON r.id=d.report_id
WHERE d.act_id=$1 WHERE d.act_id=$1 AND d.recipient_kind<>'DIRECTOR'
ORDER BY d.created_at ORDER BY d.created_at
`, [actId]) as Promise<DeliveryRow[]>; `, [actId]) as Promise<DeliveryRow[]>;
} }
private async load(id: string): Promise<DeliveryRow> { private async load(id: string): Promise<DeliveryRow> {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",d.status,d.attempts, d.recipient_email AS "recipientEmail",d.status,d.attempts,
@@ -315,12 +262,11 @@ export class InspectionDocumentDeliveryService {
FROM inspection_document_deliveries d FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id LEFT JOIN inspection_reports r ON r.id=d.report_id
WHERE d.id=$1 WHERE d.id=$1 AND d.recipient_kind<>'DIRECTOR'
`, [id]) as DeliveryRow[]; `, [id]) as DeliveryRow[];
if (!row) { if (!row) {
throw new NotFoundException({ throw new NotFoundException({
code: 'DOCUMENT_DELIVERY_NOT_FOUND', code:'DOCUMENT_DELIVERY_NOT_FOUND',message:'Entrega documental no encontrada',
message: 'Entrega documental no encontrada',
}); });
} }
return row; return row;
@@ -330,47 +276,37 @@ export class InspectionDocumentDeliveryService {
let email: string | null = null; let email: string | null = null;
if (row.recipientKind === 'COMPANY' && row.recipientAssetId) { if (row.recipientKind === 'COMPANY' && row.recipientAssetId) {
const [company] = await this.dataSource.query(` const [company] = await this.dataSource.query(`
SELECT notification_email AS email SELECT notification_email AS email FROM organization_profiles WHERE asset_id=$1
FROM organization_profiles
WHERE asset_id=$1
`, [row.recipientAssetId]) as Array<{ email: string | null }>; `, [row.recipientAssetId]) as Array<{ email: string | null }>;
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 SELECT email FROM users WHERE id=$1 AND is_active=true
FROM users
WHERE id=$1 AND is_active=true
`, [row.recipientUserId]) as Array<{ email: string | null }>; `, [row.recipientUserId]) as Array<{ email: string | null }>;
email = inspector?.email ?? null; email = inspector?.email ?? null;
} else { } else {
const [settings] = await this.dataSource.query(` const [settings] = await this.dataSource.query(`
SELECT office_email AS "officeEmail",director_email AS "directorEmail" SELECT office_email AS "officeEmail" FROM institutional_delivery_settings WHERE id=1
FROM institutional_delivery_settings `) as Array<{ officeEmail: string | null }>;
WHERE id=1 email = settings?.officeEmail ?? null;
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
email = row.recipientKind === 'OFFICE'
? settings?.officeEmail ?? null
: settings?.directorEmail ?? null;
} }
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_document_deliveries UPDATE inspection_document_deliveries
SET recipient_email=$2, SET recipient_email=$2,
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END, status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
last_error=NULL, last_error=NULL,updated_at=CURRENT_TIMESTAMP
updated_at=CURRENT_TIMESTAMP
WHERE id=$1 AND status<>'SENT' WHERE id=$1 AND status<>'SENT'
`, [row.id, email]); `, [row.id,email]);
} }
private async attempt(row: DeliveryRow) { private async attempt(row: DeliveryRow) {
if (row.status === 'SENT') return; if (row.status === 'SENT') return;
if (!row.recipientEmail) { if (!row.recipientEmail) {
await this.setStatus(row.id, 'WAITING_RECIPIENT', 'Destinatario no configurado'); await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');
return; return;
} }
if (!this.smtp.configured()) { if (!await this.smtp.configured()) {
await this.setStatus(row.id, 'WAITING_TRANSPORT', 'SMTP no configurado'); await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');
return; return;
} }
@@ -379,28 +315,18 @@ export class InspectionDocumentDeliveryService {
if (row.documentKind === 'ACT_PDF') { if (row.documentKind === 'ACT_PDF') {
await this.pdf.ensure(row.actId); await this.pdf.ensure(row.actId);
const file = await this.pdf.content(row.actId); const file = await this.pdf.content(row.actId);
attachment = { attachment = { filename:file.originalName,mimeType:file.mimeType,content:file.buffer };
filename: file.originalName,
mimeType: file.mimeType,
content: file.buffer,
};
} else { } else {
if (!row.reportId) throw new Error('Informe no vinculado'); if (!row.reportId) throw new Error('Informe no vinculado');
await this.word.ensure(row.reportId); await this.word.ensure(row.reportId);
const file = await this.word.content(row.reportId); const file = await this.word.content(row.reportId);
const { readFile } = await import('node:fs/promises'); const { readFile } = await import('node:fs/promises');
attachment = { attachment = {
filename: file.originalName, filename:file.originalName,mimeType:file.mimeType,content:await readFile(file.filePath),
mimeType: file.mimeType,
content: await readFile(file.filePath),
}; };
} }
} catch (error) { } catch (error) {
await this.setStatus( await this.setStatus(row.id,'WAITING_ARTIFACT',error instanceof Error ? error.message : 'Documento no disponible');
row.id,
'WAITING_ARTIFACT',
error instanceof Error ? error.message : 'Documento no disponible',
);
return; return;
} }
@@ -409,62 +335,41 @@ export class InspectionDocumentDeliveryService {
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING', SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',
last_error=NULL,updated_at=CURRENT_TIMESTAMP last_error=NULL,updated_at=CURRENT_TIMESTAMP
WHERE id=$1 WHERE id=$1
`, [row.id]); `,[row.id]);
try { try {
const label = row.documentKind === 'ACT_PDF' const label = row.documentKind === 'ACT_PDF'
? `Acta ${row.actCode}` ? `Acta ${row.actCode}`
: `Informe ${row.reportCode ?? ''}`; : `Informe ${row.reportCode ?? ''}`;
const text = row.documentKind === 'REPORT_WORD' const text = row.documentKind === 'REPORT_WORD'
? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.` ? `Se adjunta el INF editable ${row.reportCode ?? ''} para su revisión y preparación antes de cargarlo en GEDO.`
: row.recipientKind === 'INSPECTOR' : row.recipientKind === 'INSPECTOR'
? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.` ? `Se adjunta copia del acta sellada e inmutable ${row.actCode} correspondiente a tu inspección.`
: `Se adjunta el acta cerrada 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, to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
subject: `DH Inspección · ${label}`,
text,
attachment,
}); });
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,
last_error=NULL,updated_at=CURRENT_TIMESTAMP last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1
WHERE id=$1 `,[row.id,sent.messageId]);
`, [row.id, sent.messageId]);
await this.audit.record({ await this.audit.record({
action: AuditAction.DOCUMENT_DELIVERY_SENT, action:AuditAction.DOCUMENT_DELIVERY_SENT,
entityType: 'inspection_document_delivery', entityType:'inspection_document_delivery',entityId:row.id,source:AuditSource.SYSTEM,
entityId: row.id, actorUserId:null,actorUsername:null,requestId:null,ip:null,userAgent:null,beforeData:null,
source: AuditSource.SYSTEM, afterData:{recipientKind:row.recipientKind,recipientUserId:row.recipientUserId,documentKind:row.documentKind,status:'SENT'},
actorUserId: null, metadata:{actId:row.actId,reportId:row.reportId},
actorUsername: null,
requestId: null,
ip: null,
userAgent: null,
beforeData: null,
afterData: {
recipientKind: row.recipientKind,
recipientUserId: row.recipientUserId,
documentKind: row.documentKind,
status: 'SENT',
},
metadata: { actId: row.actId, reportId: row.reportId },
}); });
} catch (error) { } catch (error) {
await this.setStatus( await this.setStatus(row.id,'FAILED',error instanceof Error ? error.message : 'Error de entrega');
row.id,
'FAILED',
error instanceof Error ? error.message : 'Error de entrega',
);
} }
} }
private async setStatus(id: string, status: string, error: string) { private async setStatus(id: string,status: string,error: string) {
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_document_deliveries UPDATE inspection_document_deliveries
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$1
WHERE id=$1 `,[id,status,error.slice(0,500)]);
`, [id, status, error.slice(0, 500)]);
} }
} }
@@ -1,87 +0,0 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto';
import { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto';
import { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto';
import {
MAX_INSPECTION_REPORT_REVISION_BYTES,
type UploadedInspectionReportRevisionFile,
} from './inspection-report-revision-file';
import {
InspectionReportReviewService,
type InspectionReportReviewView,
} from './inspection-report-review.service';
@Controller('inspection-reports/:reportId/review')
export class InspectionReportReviewController {
constructor(private readonly review: InspectionReportReviewService) {}
@Get()
@RequirePermissions('inspection_reports.read')
get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string): Promise<InspectionReportReviewView> {
return this.review.get(reportId);
}
@Post('revisions')
@RequirePermissions('inspection_reports.revise')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_INSPECTION_REPORT_REVISION_BYTES, files: 1 } }))
revision(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: CreateInspectionReportRevisionDto,
@UploadedFile() file: UploadedInspectionReportRevisionFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<InspectionReportReviewView> {
return this.review.createRevision(reportId, dto, file, principal, request);
}
@Post('approve')
@RequirePermissions('inspection_reports.review')
approve(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: ApproveInspectionReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<InspectionReportReviewView> {
return this.review.approve(reportId, dto, principal, request);
}
@Post('sign-final')
@RequirePermissions('inspection_reports.sign_final')
signFinal(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: SignFinalInspectionReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<InspectionReportReviewView> {
return this.review.signFinal(reportId, dto, principal, request);
}
}
@Controller('inspection-report-revisions')
export class InspectionReportRevisionContentController {
constructor(private readonly review: InspectionReportReviewService) {}
@Get(':revisionId/content')
@RequirePermissions('inspection_reports.read')
async content(
@Param('revisionId', new ParseUUIDPipe({ version: '4' })) revisionId: string,
@Res() response: Response,
): Promise<void> {
const content = await this.review.revisionContent(revisionId);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => {
response.sendFile(content.filePath, (error) => {
if (error) rejectSend(error);
else resolveSend();
});
});
}
}
@@ -1,433 +0,0 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import { ConflictException, ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import { sha256CanonicalJson } from '../inspection-closing/canonical-json';
import type { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto';
import type { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto';
import type { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto';
import {
INSPECTION_REPORT_WORD_MIME,
inspectInspectionReportRevisionUpload,
type UploadedInspectionReportRevisionFile,
validateInspectionReportRevisionContainer,
} from './inspection-report-revision-file';
import { InspectionReportWordService } from './inspection-report-word.service';
export type InspectionReportReviewStatus = 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED';
export type InspectionReportRevisionSource = 'AUTO' | 'DIRECTOR_UPLOAD';
export interface InspectionReportReviewPerson {
id: string;
username: string;
firstName: string;
lastName: string;
}
export interface InspectionReportRevisionView {
id: string;
reportId: string;
revisionNumber: number;
source: InspectionReportRevisionSource;
originalName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
changeSummary: string | null;
createdAt: Date;
createdBy: InspectionReportReviewPerson;
}
export interface InspectionReportSignatureView {
id: string;
revisionId: string;
signedAt: Date;
confirmationText: string;
signatureSha256: string;
signedBy: InspectionReportReviewPerson;
}
export interface InspectionReportReviewView {
reportId: string;
reportCode: string;
reviewStatus: InspectionReportReviewStatus;
currentRevisionNumber: number;
approvedRevisionId: string | null;
approvedAt: Date | null;
reviewNote: string | null;
approvedBy: InspectionReportReviewPerson | null;
signature: InspectionReportSignatureView | null;
revisions: InspectionReportRevisionView[];
}
interface LockedReport {
id: string;
code: string;
status: string;
frozenSha256: string;
reviewStatus: InspectionReportReviewStatus;
currentRevisionNumber: number;
approvedRevisionId: string | null;
approvedAt: Date | null;
reviewNote: string | null;
}
interface StoredRevisionRow {
id: string;
reportId: string;
revisionNumber: number;
source: InspectionReportRevisionSource;
originalName: string;
storedName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
}
const FINAL_CONFIRMATION_TEXT = 'Confirmo que revisé la versión aprobada y firmo electrónicamente el informe final como Director de Hidrocarburos.';
@Injectable()
export class InspectionReportReviewService {
private readonly revisionRoot: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly word: InspectionReportWordService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_REPORT_REVISION_ROOT') ?? '/app/storage/asset-media/inspection-report-revisions';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_REVISION_ROOT must be an absolute path');
this.revisionRoot = resolve(configured);
if (this.revisionRoot === parse(this.revisionRoot).root) throw new Error('INSPECTION_REPORT_REVISION_ROOT cannot be the filesystem root');
}
async get(reportId: string): Promise<InspectionReportReviewView> {
await this.word.ensure(reportId);
await this.ensureAutomaticRevision(reportId);
return this.loadView(reportId);
}
async createRevision(
reportId: string,
dto: CreateInspectionReportRevisionDto,
file: UploadedInspectionReportRevisionFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportReviewView> {
inspectInspectionReportRevisionUpload(file);
await this.assertDirector(principal.userId, this.dataSource.manager);
await mkdir(this.revisionRoot, { recursive: true, mode: 0o700 });
const storedName = `${reportId}-${randomUUID()}.docx`;
const filePath = resolve(this.revisionRoot, storedName);
if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError();
await writeFile(filePath, file!.buffer, { mode: 0o600 });
try {
await validateInspectionReportRevisionContainer(filePath);
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
const created = await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
this.assertOpenForReview(report);
const nextRevision = report.currentRevisionNumber + 1;
const [row] = await manager.query(`
INSERT INTO inspection_report_revisions (
report_id, revision_number, source, original_name, stored_name, mime_type,
size_bytes, sha256, change_summary, created_by
) VALUES ($1,$2,'DIRECTOR_UPLOAD',$3,$4,$5,$6,$7,$8,$9)
RETURNING id
`, [
report.id,
nextRevision,
this.cleanOriginalName(file!.originalname),
storedName,
INSPECTION_REPORT_WORD_MIME,
file!.buffer.length,
sha256,
dto.changeSummary.trim(),
principal.userId,
]) as Array<{ id: string }>;
await manager.query(`
UPDATE inspection_reports
SET current_revision_number = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [report.id, nextRevision]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_REVISION_ADDED,
entityType: 'inspection_report_revision',
entityId: row.id,
afterData: {
reportId: report.id,
reportCode: report.code,
revisionNumber: nextRevision,
sha256,
source: 'DIRECTOR_UPLOAD',
},
}, manager);
return row.id;
});
if (!created) throw this.storageError();
} catch (error) {
await rm(filePath, { force: true }).catch(() => undefined);
throw error;
}
return this.loadView(reportId);
}
async approve(
reportId: string,
dto: ApproveInspectionReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportReviewView> {
await this.dataSource.transaction(async (manager) => {
await this.assertDirector(principal.userId, manager);
const report = await this.lockReport(manager, reportId);
this.assertOpenForReview(report);
const [revision] = await manager.query(`
SELECT id, revision_number AS "revisionNumber", sha256
FROM inspection_report_revisions
WHERE report_id = $1 AND revision_number = $2
`, [report.id, report.currentRevisionNumber]) as Array<{ id: string; revisionNumber: number; sha256: string }>;
if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_REVISION_REQUIRED', message: 'El informe necesita una versión Word válida antes de aprobarse' });
const approvedAt = new Date();
await manager.query(`
UPDATE inspection_reports
SET review_status = 'APPROVED', approved_revision_id = $2, approved_by = $3,
approved_at = $4, review_note = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [report.id, revision.id, principal.userId, approvedAt, dto.note?.trim() || null]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_APPROVED,
entityType: 'inspection_report',
entityId: report.id,
afterData: {
reportCode: report.code,
revisionId: revision.id,
revisionNumber: revision.revisionNumber,
revisionSha256: revision.sha256,
approvedAt: approvedAt.toISOString(),
},
}, manager);
});
return this.loadView(reportId);
}
async signFinal(
reportId: string,
dto: SignFinalInspectionReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportReviewView> {
if (dto.confirmation !== true) throw new ConflictException({ code: 'INSPECTION_REPORT_SIGNATURE_CONFIRMATION_REQUIRED', message: 'Debés confirmar expresamente la firma final del informe' });
await this.dataSource.transaction(async (manager) => {
await this.assertDirector(principal.userId, manager);
const report = await this.lockReport(manager, reportId);
if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe ya tiene una firma final inmutable' });
if (report.reviewStatus !== 'APPROVED' || !report.approvedRevisionId || !report.approvedAt) {
throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_APPROVED', message: 'El Director debe aprobar una versión antes de firmar el informe final' });
}
const [revision] = await manager.query(`
SELECT id, revision_number AS "revisionNumber", sha256
FROM inspection_report_revisions
WHERE id = $1 AND report_id = $2
`, [report.approvedRevisionId, report.id]) as Array<{ id: string; revisionNumber: number; sha256: string }>;
if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_APPROVED_REVISION_MISSING', message: 'La versión aprobada no está disponible para la firma final' });
const signedAt = new Date();
const payload = {
schemaVersion: 'DH-INSPECTION-REPORT-SIGNATURE-V1',
reportId: report.id,
reportCode: report.code,
reportFrozenSha256: report.frozenSha256,
revisionId: revision.id,
revisionNumber: revision.revisionNumber,
revisionSha256: revision.sha256,
signedBy: principal.userId,
signedByUsername: principal.username,
signedAt: signedAt.toISOString(),
confirmationText: FINAL_CONFIRMATION_TEXT,
};
const signatureSha256 = sha256CanonicalJson(payload);
const [signature] = await manager.query(`
INSERT INTO inspection_report_signatures (
report_id, revision_id, signed_by, signed_at, confirmation_text,
signature_payload, signature_sha256
) VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING id
`, [report.id, revision.id, principal.userId, signedAt, FINAL_CONFIRMATION_TEXT, payload, signatureSha256]) as Array<{ id: string }>;
await manager.query(`
UPDATE inspection_reports
SET review_status = 'SIGNED', signed_at = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [report.id, signedAt]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_SIGNED,
entityType: 'inspection_report_signature',
entityId: signature.id,
afterData: {
reportId: report.id,
reportCode: report.code,
revisionId: revision.id,
revisionNumber: revision.revisionNumber,
revisionSha256: revision.sha256,
signatureSha256,
signedAt: signedAt.toISOString(),
},
}, manager);
});
return this.loadView(reportId);
}
async revisionContent(revisionId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [revision] = await this.dataSource.query(`
SELECT id, report_id AS "reportId", revision_number AS "revisionNumber", source,
original_name AS "originalName", stored_name AS "storedName", mime_type AS "mimeType",
size_bytes::integer AS "sizeBytes", sha256
FROM inspection_report_revisions WHERE id = $1
`, [revisionId]) as StoredRevisionRow[];
if (!revision) throw new NotFoundException({ code: 'INSPECTION_REPORT_REVISION_NOT_FOUND', message: 'Versión del informe no encontrada' });
if (revision.source === 'AUTO') return this.word.content(revision.reportId);
const filePath = resolve(this.revisionRoot, revision.storedName);
if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError();
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat?.isFile() || fileStat.size !== revision.sizeBytes) throw this.storageError();
const buffer = await readFile(filePath);
const sha256 = createHash('sha256').update(buffer).digest('hex');
if (sha256 !== revision.sha256) throw this.storageError();
return { filePath, originalName: revision.originalName, mimeType: revision.mimeType };
}
private async ensureAutomaticRevision(reportId: string): Promise<void> {
await this.dataSource.query(`
INSERT INTO inspection_report_revisions (
report_id, revision_number, source, original_name, stored_name, mime_type,
size_bytes, sha256, change_summary, created_by, created_at
)
SELECT
id, 1, 'AUTO', word_original_name, word_stored_name, word_mime_type,
word_size_bytes, word_sha256, 'Versión automática inicial', generated_by,
COALESCE(word_generated_at, generated_at)
FROM inspection_reports
WHERE id = $1
AND word_status = 'READY'
AND word_original_name IS NOT NULL
AND word_stored_name IS NOT NULL
AND word_mime_type = $2
AND word_size_bytes > 0
AND word_sha256 ~ '^[0-9a-f]{64}$'
ON CONFLICT (report_id, revision_number) DO NOTHING
`, [reportId, INSPECTION_REPORT_WORD_MIME]);
await this.dataSource.query(`
UPDATE inspection_reports
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND EXISTS (SELECT 1 FROM inspection_report_revisions WHERE report_id = $1 AND revision_number = 1)
`, [reportId]);
}
private async loadView(reportId: string): Promise<InspectionReportReviewView> {
const [report] = await this.dataSource.query(`
SELECT
report.id AS "reportId",
report.code AS "reportCode",
report.review_status AS "reviewStatus",
report.current_revision_number AS "currentRevisionNumber",
report.approved_revision_id AS "approvedRevisionId",
report.approved_at AS "approvedAt",
report.review_note AS "reviewNote",
CASE WHEN approver.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', approver.id, 'username', approver.username, 'firstName', approver.first_name, 'lastName', approver.last_name
) END AS "approvedBy"
FROM inspection_reports report
LEFT JOIN users approver ON approver.id = report.approved_by
WHERE report.id = $1
`, [reportId]) as Array<Omit<InspectionReportReviewView, 'signature' | 'revisions'>>;
if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
const revisions = await this.dataSource.query(`
SELECT
revision.id,
revision.report_id AS "reportId",
revision.revision_number AS "revisionNumber",
revision.source,
revision.original_name AS "originalName",
revision.mime_type AS "mimeType",
revision.size_bytes::integer AS "sizeBytes",
revision.sha256,
revision.change_summary AS "changeSummary",
revision.created_at AS "createdAt",
JSONB_BUILD_OBJECT(
'id', creator.id, 'username', creator.username, 'firstName', creator.first_name, 'lastName', creator.last_name
) AS "createdBy"
FROM inspection_report_revisions revision
JOIN users creator ON creator.id = revision.created_by
WHERE revision.report_id = $1
ORDER BY revision.revision_number DESC
`, [reportId]) as InspectionReportRevisionView[];
const [signature] = await this.dataSource.query(`
SELECT
signature.id,
signature.revision_id AS "revisionId",
signature.signed_at AS "signedAt",
signature.confirmation_text AS "confirmationText",
signature.signature_sha256 AS "signatureSha256",
JSONB_BUILD_OBJECT(
'id', signer.id, 'username', signer.username, 'firstName', signer.first_name, 'lastName', signer.last_name
) AS "signedBy"
FROM inspection_report_signatures signature
JOIN users signer ON signer.id = signature.signed_by
WHERE signature.report_id = $1
`, [reportId]) as InspectionReportSignatureView[];
return { ...report, signature: signature ?? null, revisions };
}
private async lockReport(manager: EntityManager, reportId: string): Promise<LockedReport> {
const [report] = await manager.query(`
SELECT
id, code, status, frozen_sha256 AS "frozenSha256",
review_status AS "reviewStatus",
current_revision_number AS "currentRevisionNumber",
approved_revision_id AS "approvedRevisionId",
approved_at AS "approvedAt",
review_note AS "reviewNote"
FROM inspection_reports
WHERE id = $1
FOR UPDATE
`, [reportId]) as LockedReport[];
if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
if (report.status !== 'FROZEN') throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_REVIEWABLE', message: 'Sólo los informes congelados pueden ingresar al circuito de revisión' });
return report;
}
private assertOpenForReview(report: LockedReport): void {
if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe firmado es definitivo y no admite nuevas versiones' });
if (report.reviewStatus === 'APPROVED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_APPROVED', message: 'La versión ya fue aprobada y sólo resta la firma final del Director' });
}
private async assertDirector(userId: string, manager: EntityManager): Promise<void> {
const [role] = await manager.query(`
SELECT role.code
FROM user_roles membership
JOIN roles role ON role.id = membership.role_id
WHERE membership.user_id = $1 AND role.code = 'director'
LIMIT 1
`, [userId]) as Array<{ code: string }>;
if (!role) throw new ForbiddenException({ code: 'INSPECTION_REPORT_DIRECTOR_REQUIRED', message: 'Esta operación está reservada al Director de Hidrocarburos' });
}
private cleanOriginalName(value: string): string {
const cleaned = value.replace(/[\\/\0\r\n]/g, '_').trim();
return (cleaned || 'informe-corregido.docx').slice(0, 255);
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({ code: 'INSPECTION_REPORT_REVISION_STORAGE_ERROR', message: 'La versión del informe no está disponible o no supera la validación de integridad' });
}
}
@@ -11,6 +11,8 @@ interface ReportWordInput {
generatedAt: Date; generatedAt: Date;
frozenSha256: string; frozenSha256: string;
frozenSnapshot: Record<string, unknown>; frozenSnapshot: Record<string, unknown>;
executiveSummary?: string | null;
reportDescription?: string | null;
} }
const crcTable = (() => { const crcTable = (() => {
@@ -56,8 +58,8 @@ function text(value: unknown, fallback = '—'): string {
} }
function isoDate(value: unknown): string { function isoDate(value: unknown): string {
const date = new Date(String(value ?? '')); const parsed = new Date(String(value ?? ''));
return Number.isFinite(date.getTime()) ? date.toLocaleDateString('es-AR') : '—'; return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '—';
} }
function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string { function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string {
@@ -76,52 +78,96 @@ function table(headers: string[], rows: string[][]): string {
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="B7C9D6"/><w:left w:val="single" w:sz="4" w:color="B7C9D6"/><w:bottom w:val="single" w:sz="4" w:color="B7C9D6"/><w:right w:val="single" w:sz="4" w:color="B7C9D6"/><w:insideH w:val="single" w:sz="4" w:color="D8E1E8"/><w:insideV w:val="single" w:sz="4" w:color="D8E1E8"/></w:tblBorders></w:tblPr>${header}${body}</w:tbl>`; return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="B7C9D6"/><w:left w:val="single" w:sz="4" w:color="B7C9D6"/><w:bottom w:val="single" w:sz="4" w:color="B7C9D6"/><w:right w:val="single" w:sz="4" w:color="B7C9D6"/><w:insideH w:val="single" w:sz="4" w:color="D8E1E8"/><w:insideV w:val="single" w:sz="4" w:color="D8E1E8"/></w:tblBorders></w:tblPr>${header}${body}</w:tbl>`;
} }
function f4Snapshot(input: ReportWordInput) {
const source = asRecord(input.frozenSnapshot.source);
const sealedAct = asRecord(input.frozenSnapshot.sealedAct ?? input.frozenSnapshot.actClosure);
const locked = asRecord(sealedAct.lockedSnapshot ?? sealedAct.preparedSnapshot);
const act = asRecord(locked.act);
const inspection = asRecord(act.inspection ?? act.visit);
const responsible = asRecord(locked.responsible);
const inventories = asArray(locked.inventories ?? locked.assets);
const findings = asArray(locked.findings);
const signatures = asArray(sealedAct.signatures);
return { source, sealedAct, locked, act, inspection, responsible, inventories, findings, signatures };
}
function urgency(value: unknown): string {
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
}
function deadline(act: Record<string, unknown>): string {
const type = text(act.deadlineDayType, '') === 'CALENDAR' ? 'días corridos' : 'días hábiles';
if (act.deadlineAt) return `${isoDate(act.deadlineAt)} · ${text(act.deadlineDays)} ${type}`;
if (text(act.deadlineBasis, '') === 'GEDO_DATE') return `${text(act.deadlineDays)} ${type} desde la fecha de oficialización GEDO`;
return '—';
}
function documentXml(input: ReportWordInput): string { function documentXml(input: ReportWordInput): string {
const report = asRecord(input.frozenSnapshot.report); const snapshot = f4Snapshot(input);
const actClosure = asRecord(input.frozenSnapshot.actClosure); const findingRows = snapshot.findings.map((item) => [
const prepared = asRecord(actClosure.preparedSnapshot); text(item.code),
const act = asRecord(prepared.act); text(item.title),
const visit = asRecord(act.visit); text(item.description),
const responsible = asRecord(prepared.responsible); item.isRecurrence ? `${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No',
const team = asArray(prepared.team); item.severity == null ? '—' : `${text(item.severity)}/10`,
const assets = asArray(prepared.assets); ]);
const findings = asArray(prepared.findings); const inventoryRows = snapshot.inventories.map((item) => [
const signatures = asArray(actClosure.signatures); text(item.code),
const companies = [...new Set(assets.map((item) => text(asRecord(item.operatorCompany).name, '')).filter(Boolean))]; text(item.name),
const areas = [...new Set(assets.map((item) => text(asRecord(item.operationalArea).name, '')).filter(Boolean))]; text(item.typeName ?? item.typeCode),
const inspectorNames = team.map((member) => `${text(member.firstName, '')} ${text(member.lastName, '')}`.trim()).filter(Boolean); ]);
const assetRows = assets.map((item) => [text(item.code), text(item.name), text(item.commonName, ''), text(item.typeName)]); const signatureRows = snapshot.signatures.map((item) => [
const findingRows = findings.map((item) => [text(item.code), text(item.title), text(item.description), item.severity == null ? '—' : `${text(item.severity)}/10`, isoDate(item.correctionDueOn)]); text(item.signerType),
const signatureRows = signatures.map((item) => [text(item.signerType), text(item.signerName), text(item.status), isoDate(item.signedAt ?? item.createdAt)]); 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 = [ const body = [
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'), paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
paragraph('Borrador automático para revisión del Director de Hidrocarburos', 'Heading2'), paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'),
labelValue('Informe', input.code), labelValue('Informe', input.code),
labelValue('Título', input.title), labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)),
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)),
labelValue('Urgencia', urgency(snapshot.act.urgency)),
labelValue('Plazo', deadline(snapshot.act)),
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')), labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
labelValue('Acta', text(report.actCode ?? act.code)), paragraph('Resumen ejecutivo', 'Heading1'),
labelValue('Inspección', text(report.visitCode ?? visit.code)), paragraph(executive),
labelValue('Empresa', companies.join(' · ') || 'Según alcance del acta'), paragraph('Descripción / análisis técnico', 'Heading1'),
labelValue('Área', areas.join(' · ') || 'Según alcance del acta'), paragraph(description),
labelValue('Inspector/es', inspectorNames.join(' · ') || '—'), paragraph('Acta fuente · contenido inmutable', 'Heading1'),
labelValue('Responsable de empresa', text(responsible.fullName)), paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
labelValue('Cargo', text(responsible.position)), labelValue('Resumen del Acta', text(snapshot.act.summary)),
paragraph('Datos de la visita', 'Heading1'), labelValue('Observaciones del Acta', text(snapshot.act.observations)),
labelValue('Objetivo', text(visit.objective)), labelValue('Responsable de empresa', text(snapshot.responsible.fullName)),
labelValue('Fecha de inspección', isoDate(act.occurredAt)), labelValue('Cargo', text(snapshot.responsible.position)),
labelValue('Resumen', text(act.summary)), paragraph('Inventario inspeccionado', 'Heading1'),
labelValue('Observaciones', text(act.observations)), inventoryRows.length
paragraph('Elementos inspeccionados', 'Heading1'), ? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
assetRows.length ? table(['Código', 'Nombre técnico', 'Nombre habitual', 'Tipo'], assetRows) : paragraph('No se registraron elementos en la instantánea.'), : paragraph('No se registraron elementos de Inventario en el Acta.'),
paragraph('Hallazgos', 'Heading1'), paragraph('Hallazgos', 'Heading1'),
findingRows.length ? table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento'], findingRows) : paragraph('No se registraron hallazgos en la instantánea.'), findingRows.length
paragraph('Firmas y constancias', 'Heading1'), ? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows)
signatureRows.length ? table(['Tipo', 'Firmante', 'Estado', 'Fecha'], signatureRows) : paragraph('No se registraron firmas en la instantánea.'), : paragraph('El Acta no contiene Hallazgos.'),
paragraph('Integridad documental', 'Heading1'), paragraph('Firmas y manifestaciones', 'Heading1'),
labelValue('Hash del informe', input.frozenSha256), signatureRows.length
labelValue('Hash de cierre del acta', text(report.actClosureSha256)), ? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows)
paragraph('El contenido de este archivo fue generado desde la instantánea congelada del acta. El diseño institucional y la firma final del Director se incorporarán en la etapa de revisión correspondiente.'), : paragraph('No se registraron firmas en la instantánea sellada.'),
paragraph('Integridad de la fuente', 'Heading1'),
labelValue('Hash de la fuente del INF', input.frozenSha256),
labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)),
paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'),
].join(''); ].join('');
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>`; 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>`;
} }
@@ -147,6 +193,7 @@ function buildZip(entries: ZipEntry[]): Buffer {
local.writeUInt16LE(name.length, 26); local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28); local.writeUInt16LE(0, 28);
locals.push(local, name, entry.data); locals.push(local, name, entry.data);
const central = Buffer.alloc(46); const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0); central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 4);
@@ -168,6 +215,7 @@ function buildZip(entries: ZipEntry[]): Buffer {
centrals.push(central, name); centrals.push(central, name);
offset += local.length + name.length + entry.data.length; offset += local.length + name.length + entry.data.length;
} }
const centralData = Buffer.concat(centrals); const centralData = Buffer.concat(centrals);
const end = Buffer.alloc(22); const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0); end.writeUInt32LE(0x06054b50, 0);
@@ -12,6 +12,8 @@ interface WordRow {
id: string; id: string;
code: string; code: string;
title: string; title: string;
executiveSummary: string | null;
reportDescription: string | null;
generatedAt: Date; generatedAt: Date;
frozenSha256: string; frozenSha256: string;
frozenSnapshot: Record<string, unknown>; frozenSnapshot: Record<string, unknown>;
@@ -29,7 +31,8 @@ export class InspectionReportWordService {
private readonly root: string; private readonly root: string;
constructor(private readonly dataSource: DataSource, config: ConfigService) { constructor(private readonly dataSource: DataSource, config: ConfigService) {
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT') ?? '/app/storage/asset-media/inspection-reports-word'; const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
?? '/app/storage/asset-media/inspection-reports-word';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path'); if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_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_REPORT_WORD_ROOT cannot be the filesystem root'); if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root');
@@ -38,7 +41,11 @@ export class InspectionReportWordService {
async ensure(reportId: string): Promise<void> { async ensure(reportId: string): Promise<void> {
const row = await this.load(reportId); const row = await this.load(reportId);
if (row.wordStatus === 'READY' && row.wordStoredName) { if (row.wordStatus === 'READY' && row.wordStoredName) {
try { await this.content(row.id); await this.ensureInitialRevision(row); return; } catch {} try {
await this.content(row.id);
await this.ensureInitialRevision(row);
return;
} catch {}
} }
try { try {
const built = buildInspectionReportWord({ const built = buildInspectionReportWord({
@@ -47,40 +54,51 @@ export class InspectionReportWordService {
generatedAt: new Date(row.generatedAt), generatedAt: new Date(row.generatedAt),
frozenSha256: row.frozenSha256, frozenSha256: row.frozenSha256,
frozenSnapshot: row.frozenSnapshot, frozenSnapshot: row.frozenSnapshot,
executiveSummary: row.executiveSummary,
reportDescription: row.reportDescription,
}); });
await mkdir(this.root, { recursive: true, mode: 0o700 }); await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${row.id}.docx`; const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;
const originalName = `${row.code}.docx`; const originalName = `${row.code}.docx`;
const filePath = resolve(this.root, storedName); const filePath = resolve(this.root, storedName);
await writeFile(filePath, built.buffer, { mode: 0o600 }); await writeFile(filePath, built.buffer, { mode: 0o600 });
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_reports UPDATE inspection_reports
SET word_status = 'READY', SET word_status='READY',
word_original_name = $2, word_original_name=$2,
word_stored_name = $3, word_stored_name=$3,
word_mime_type = $4, word_mime_type=$4,
word_size_bytes = $5, word_size_bytes=$5,
word_sha256 = $6, word_sha256=$6,
word_generated_at = CURRENT_TIMESTAMP, word_generated_at=CURRENT_TIMESTAMP,
word_error = NULL, word_error=NULL,
updated_at = CURRENT_TIMESTAMP updated_at=CURRENT_TIMESTAMP
WHERE id = $1 WHERE id=$1
`, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]); `, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]);
await this.ensureInitialRevision(await this.load(row.id)); await this.ensureInitialRevision(await this.load(row.id));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido'; const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido';
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_reports UPDATE inspection_reports
SET word_status = 'FAILED', word_error = $2, updated_at = CURRENT_TIMESTAMP SET word_status='FAILED',word_error=$2,updated_at=CURRENT_TIMESTAMP
WHERE id = $1 WHERE id=$1
`, [row.id, message]).catch(() => undefined); `, [row.id, message]).catch(() => undefined);
} }
} }
async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const row = await this.load(reportId); const row = await this.load(reportId);
if (row.wordStatus !== 'READY' || !row.wordStoredName || !row.wordOriginalName || !row.wordSha256 || !row.wordSizeBytes) { if (
throw new NotFoundException({ code: 'INSPECTION_REPORT_WORD_NOT_READY', message: 'El archivo Word del informe todavía no está disponible' }); row.wordStatus !== 'READY'
|| !row.wordStoredName
|| !row.wordOriginalName
|| !row.wordSha256
|| !row.wordSizeBytes
) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_WORD_NOT_READY',
message: 'El archivo Word del informe todavía no está disponible',
});
} }
const filePath = resolve(this.root, row.wordStoredName); const filePath = resolve(this.root, row.wordStoredName);
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError(); if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
@@ -89,40 +107,78 @@ export class InspectionReportWordService {
const buffer = await readFile(filePath); const buffer = await readFile(filePath);
const sha256 = createHash('sha256').update(buffer).digest('hex'); const sha256 = createHash('sha256').update(buffer).digest('hex');
if (sha256 !== row.wordSha256) throw this.storageError(); if (sha256 !== row.wordSha256) throw this.storageError();
return { filePath, originalName: row.wordOriginalName, mimeType: row.wordMimeType ?? WORD_MIME }; return {
filePath,
originalName: row.wordOriginalName,
mimeType: row.wordMimeType ?? WORD_MIME,
};
} }
private async ensureInitialRevision(row: WordRow): Promise<void> { private async ensureInitialRevision(row: WordRow): Promise<void> {
if (row.wordStatus !== 'READY' || !row.wordOriginalName || !row.wordStoredName || !row.wordMimeType || !row.wordSizeBytes || !row.wordSha256) return; if (
row.wordStatus !== 'READY'
|| !row.wordOriginalName
|| !row.wordStoredName
|| !row.wordMimeType
|| !row.wordSizeBytes
|| !row.wordSha256
) return;
await this.dataSource.query(` await this.dataSource.query(`
INSERT INTO inspection_report_revisions ( INSERT INTO inspection_report_revisions (
report_id, revision_number, source, original_name, stored_name, mime_type, report_id,revision_number,source,original_name,stored_name,mime_type,
size_bytes, sha256, change_summary, created_by, created_at size_bytes,sha256,change_summary,created_by,created_at
) VALUES ($1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial',$7,COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP)) ) VALUES (
ON CONFLICT (report_id, revision_number) DO NOTHING $1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial editable',$7,
`, [row.id, row.wordOriginalName, row.wordStoredName, row.wordMimeType, row.wordSizeBytes, row.wordSha256, row.generatedBy]); COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP)
)
ON CONFLICT (report_id,revision_number) DO NOTHING
`, [
row.id,
row.wordOriginalName,
row.wordStoredName,
row.wordMimeType,
row.wordSizeBytes,
row.wordSha256,
row.generatedBy,
]);
await this.dataSource.query(` await this.dataSource.query(`
UPDATE inspection_reports UPDATE inspection_reports
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP SET current_revision_number=GREATEST(current_revision_number,1),updated_at=CURRENT_TIMESTAMP
WHERE id = $1 WHERE id=$1
`, [row.id]); `, [row.id]);
} }
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, generated_at AS "generatedAt", frozen_sha256 AS "frozenSha256", SELECT id,code,title,
frozen_snapshot AS "frozenSnapshot", word_status AS "wordStatus", executive_summary AS "executiveSummary",
word_original_name AS "wordOriginalName", word_stored_name AS "wordStoredName", report_description AS "reportDescription",
word_mime_type AS "wordMimeType", word_size_bytes::integer AS "wordSizeBytes", generated_at AS "generatedAt",
word_sha256 AS "wordSha256", generated_by AS "generatedBy" frozen_sha256 AS "frozenSha256",
FROM inspection_reports WHERE id = $1 frozen_snapshot AS "frozenSnapshot",
word_status AS "wordStatus",
word_original_name AS "wordOriginalName",
word_stored_name AS "wordStoredName",
word_mime_type AS "wordMimeType",
word_size_bytes::integer AS "wordSizeBytes",
word_sha256 AS "wordSha256",
generated_by AS "generatedBy"
FROM inspection_reports
WHERE id=$1
`, [reportId]) as WordRow[]; `, [reportId]) as WordRow[];
if (!row) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); if (!row) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_NOT_FOUND',
message: 'Informe de inspección no encontrado',
});
}
return row; return row;
} }
private storageError(): InternalServerErrorException { private storageError(): InternalServerErrorException {
return new InternalServerErrorException({ code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR', message: 'El archivo Word del informe no está disponible o no supera la validación de integridad' }); return new InternalServerErrorException({
code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR',
message: 'El archivo Word del informe no está disponible o no supera la validación de integridad',
});
} }
} }
@@ -0,0 +1,428 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import {
BadRequestException,
ConflictException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditAction,
InspectionReportStatus,
} from '../database/entities';
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
export interface UploadedInspectionReportFile {
buffer: Buffer;
originalname: string;
mimetype: string;
size: number;
}
interface ReportRow {
id: string;
actId: string;
visitId: string;
code: string;
status: InspectionReportStatus;
executiveSummary: string | null;
reportDescription: string | null;
gedoIfIdentifier: string | null;
gedoOfficializedAt: Date | null;
}
function reportNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_REPORT_NOT_FOUND',
message: 'Informe de inspección no encontrado',
});
}
@Injectable()
export class InspectionReportWorkflowService {
private readonly root: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
?? '/app/storage/asset-media/inspection-reports';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_UPLOAD_ROOT must be absolute');
this.root = resolve(configured);
if (this.root === parse(this.root).root) {
throw new Error('INSPECTION_REPORT_UPLOAD_ROOT cannot be filesystem root');
}
}
async updateNarrative(
reportId: string,
dto: UpdateInspectionReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
if (report.status !== InspectionReportStatus.WORKING) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
message: 'El contenido editable del INF queda cerrado cuando se registra su IF oficial de GEDO',
});
}
const before = {
executiveSummary: report.executiveSummary,
reportDescription: report.reportDescription,
};
const executiveSummary = dto.executiveSummary !== undefined
? dto.executiveSummary
: report.executiveSummary;
const reportDescription = dto.description !== undefined
? dto.description
: report.reportDescription;
await manager.query(`
UPDATE inspection_reports
SET executive_summary=$2,
report_description=$3,
word_status=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN 'PENDING' ELSE word_status END,
word_original_name=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_original_name END,
word_stored_name=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_stored_name END,
word_mime_type=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_mime_type END,
word_size_bytes=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_size_bytes END,
word_sha256=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_sha256 END,
word_generated_at=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_generated_at END,
word_error=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_error END,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [reportId, executiveSummary, reportDescription]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_UPDATED,
entityType: 'inspection_report',
entityId: reportId,
beforeData: before,
afterData: { executiveSummary, reportDescription },
metadata: { reportCode: report.code, actId: report.actId },
}, manager);
return this.getWorkflowView(manager, reportId);
});
}
async officialize(
reportId: string,
dto: OfficializeInspectionReportDto,
file: UploadedInspectionReportFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
this.assertPdf(file);
const id = randomUUID();
const storedName = `gedo-${id}.pdf`;
const filePath = resolve(this.root, storedName);
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
await mkdir(this.root, { recursive: true, mode: 0o700 });
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
try {
return await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
if (report.status !== InspectionReportStatus.WORKING) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
message: 'El IF oficial de GEDO ya fue registrado y es inmutable',
});
}
const officializedAt = new Date(dto.gedoOfficializedAt);
const now = new Date();
if (officializedAt.getTime() > now.getTime() + 24 * 60 * 60 * 1000) {
throw new BadRequestException({
code: 'INVALID_GEDO_DATE',
message: 'La fecha de GEDO no puede estar más de 24 horas en el futuro',
});
}
await manager.query(`
UPDATE inspection_reports
SET status='OFFICIALIZED',
gedo_if_identifier=$2,
gedo_officialized_at=$3,
gedo_pdf_original_name=$4,
gedo_pdf_stored_name=$5,
gedo_pdf_mime_type='application/pdf',
gedo_pdf_size_bytes=$6,
gedo_pdf_sha256=$7,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [
reportId,
dto.gedoIfIdentifier,
officializedAt,
file!.originalname,
storedName,
file!.buffer.length,
sha256,
]);
// GEDO oficializa el INF, pero no equivale por sí solo a la notificación
// administrativa de un Acta no urgente. El vencimiento queda pendiente
// hasta que el procedimiento defina y registre el evento de notificación.
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_OFFICIALIZED,
entityType: 'inspection_report',
entityId: reportId,
afterData: {
status: InspectionReportStatus.OFFICIALIZED,
gedoIfIdentifier: dto.gedoIfIdentifier,
gedoOfficializedAt: officializedAt,
gedoPdfOriginalName: file!.originalname,
gedoPdfSha256: sha256,
},
metadata: {
reportCode: report.code,
actId: report.actId,
immutable: true,
deadlineActivation: 'PENDING_NOTIFICATION_EVENT',
},
}, manager);
return this.getWorkflowView(manager, reportId);
});
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(`
SELECT
gedo_pdf_original_name AS "originalName",
gedo_pdf_stored_name AS "storedName",
gedo_pdf_mime_type AS "mimeType",
gedo_pdf_size_bytes::integer AS "sizeBytes",
gedo_pdf_sha256 AS sha256
FROM inspection_reports
WHERE id=$1
`, [reportId]) as Array<{
originalName: string | null;
storedName: string | null;
mimeType: string | null;
sizeBytes: number | null;
sha256: string | null;
}>;
if (!row) throw reportNotFound();
if (!row.originalName || !row.storedName || !row.sizeBytes || !row.sha256) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_GEDO_PDF_NOT_AVAILABLE',
message: 'El PDF oficial de GEDO todavía no está disponible',
});
}
const filePath = resolve(this.root, row.storedName);
if (filePath === this.root || !filePath.startsWith(`${this.root}/`)) throw this.reportStorageError();
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.reportStorageError();
const buffer = await readFile(filePath);
const sha256 = createHash('sha256').update(buffer).digest('hex');
if (sha256 !== row.sha256) throw this.reportStorageError();
return {
filePath,
originalName: row.originalName,
mimeType: row.mimeType ?? 'application/pdf',
};
}
async addFollowUp(
reportId: string,
dto: CreateInspectionReportFollowUpDto,
file: UploadedInspectionReportFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
if (file && file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_FILE_TOO_LARGE',
message: 'El archivo supera el máximo permitido de 40 MB',
});
}
const followUpId = randomUUID();
const ext = this.safeExtension(file?.originalname);
const storedName = file ? `followup-${followUpId}${ext}` : null;
const filePath = storedName ? resolve(this.root, storedName) : null;
const sha256 = file ? createHash('sha256').update(file.buffer).digest('hex') : null;
if (filePath && file) {
await mkdir(this.root, { recursive: true, mode: 0o700 });
await writeFile(filePath, file.buffer, { flag: 'wx', mode: 0o600 });
}
try {
return await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
const occurredAt = new Date(dto.occurredAt);
await manager.query(`
INSERT INTO inspection_report_follow_ups (
id,report_id,type,external_reference,occurred_at,description,
original_name,stored_name,mime_type,size_bytes,sha256,created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
`, [
followUpId,
reportId,
dto.type,
dto.externalReference ?? null,
occurredAt,
dto.description ?? null,
file?.originalname ?? null,
storedName,
file?.mimetype ?? null,
file?.buffer.length ?? null,
sha256,
principal.userId,
]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_FOLLOW_UP_ADDED,
entityType: 'inspection_report_follow_up',
entityId: followUpId,
afterData: {
reportId,
type: dto.type,
externalReference: dto.externalReference ?? null,
occurredAt,
description: dto.description ?? null,
originalName: file?.originalname ?? null,
sha256,
},
metadata: { reportCode: report.code, actId: report.actId, appendOnly: true },
}, manager);
return this.listFollowUpsWithManager(manager, reportId);
});
} catch (error) {
if (filePath) await unlink(filePath).catch(() => undefined);
throw error;
}
}
async listFollowUps(reportId: string) {
const report = await this.getReport(this.dataSource.manager, reportId);
if (!report) throw reportNotFound();
return this.listFollowUpsWithManager(this.dataSource.manager, reportId);
}
private async listFollowUpsWithManager(manager: EntityManager, reportId: string) {
return manager.query(`
SELECT
follow_up.id,
follow_up.report_id AS "reportId",
follow_up.type,
follow_up.external_reference AS "externalReference",
follow_up.occurred_at AS "occurredAt",
follow_up.description,
follow_up.original_name AS "originalName",
follow_up.mime_type AS "mimeType",
follow_up.size_bytes AS "sizeBytes",
follow_up.sha256,
follow_up.created_by AS "createdBy",
follow_up.created_at AS "createdAt"
FROM inspection_report_follow_ups follow_up
WHERE follow_up.report_id=$1
ORDER BY follow_up.occurred_at,follow_up.created_at,follow_up.id
`, [reportId]);
}
private reportStorageError(): InternalServerErrorException {
return new InternalServerErrorException({
code: 'INSPECTION_REPORT_GEDO_PDF_STORAGE_ERROR',
message: 'El PDF oficial de GEDO no está disponible o no supera la validación de integridad',
});
}
private assertPdf(file: UploadedInspectionReportFile | undefined): void {
if (!file?.buffer?.length) {
throw new BadRequestException({
code: 'GEDO_PDF_REQUIRED',
message: 'Debe adjuntarse el PDF oficial generado por GEDO',
});
}
if (file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) {
throw new BadRequestException({
code: 'GEDO_PDF_TOO_LARGE',
message: 'El PDF oficial supera el máximo permitido de 40 MB',
});
}
if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') {
throw new BadRequestException({
code: 'INVALID_GEDO_PDF',
message: 'El documento oficial de GEDO debe ser un PDF válido',
});
}
}
private safeExtension(name: string | undefined): string {
if (!name) return '';
const dot = name.lastIndexOf('.');
if (dot < 0) return '';
const ext = name.slice(dot).toLowerCase();
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
}
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
const [row] = await manager.query(`
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
executive_summary AS "executiveSummary",report_description AS "reportDescription",
gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt"
FROM inspection_reports WHERE id=$1 FOR UPDATE
`, [id]) as ReportRow[];
if (!row) throw reportNotFound();
return row;
}
private async getReport(manager: EntityManager, id: string): Promise<ReportRow | null> {
const [row] = await manager.query(`
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
executive_summary AS "executiveSummary",report_description AS "reportDescription",
gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt"
FROM inspection_reports WHERE id=$1
`, [id]) as ReportRow[];
return row ?? null;
}
private async getWorkflowView(manager: EntityManager, id: string) {
const report = await this.getReport(manager, id);
if (!report) throw reportNotFound();
const [act] = await manager.query(`
SELECT code,urgency,deadline_days AS "deadlineDays",deadline_day_type AS "deadlineDayType",
deadline_basis AS "deadlineBasis",deadline_base_at AS "deadlineBaseAt",deadline_at AS "deadlineAt"
FROM inspection_acts WHERE id=$1
`, [report.actId]);
return {
...report,
act,
followUps: await this.listFollowUpsWithManager(manager, id),
};
}
}
@@ -1,15 +1,41 @@
import { Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, Res } from '@nestjs/common'; import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { 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 { 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';
import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
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 { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import {
InspectionReportWorkflowService,
MAX_INSPECTION_REPORT_FILE_BYTES,
type UploadedInspectionReportFile,
} from './inspection-report-workflow.service';
import { InspectionReportsService } from './inspection-reports.service'; import { InspectionReportsService } from './inspection-reports.service';
import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionReportWordService } from './inspection-report-word.service';
@Controller('inspection-reports') @Controller('inspection-reports')
export class InspectionReportsController { export class InspectionReportsController {
constructor(private readonly reports: InspectionReportsService, private readonly word: InspectionReportWordService) {} constructor(
private readonly reports: InspectionReportsService,
private readonly word: InspectionReportWordService,
private readonly workflow: InspectionReportWorkflowService,
) {}
@Get() @Get()
@RequirePermissions('inspection_reports.read') @RequirePermissions('inspection_reports.read')
@@ -42,6 +68,65 @@ export class InspectionReportsController {
return response.sendFile(content.filePath); return response.sendFile(content.filePath);
} }
@Get(':id/gedo-pdf')
@RequirePermissions('inspection_reports.read')
async gedoPdfContent(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Res() response: Response,
) {
const content = await this.workflow.officialPdfContent(id);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
return response.sendFile(content.filePath);
}
@Patch(':id')
@RequirePermissions('inspection_reports.generate')
updateNarrative(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateInspectionReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.updateNarrative(id, dto, principal, request);
}
@Post(':id/officialize')
@RequirePermissions('inspection_reports.generate')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 },
}))
officialize(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: OfficializeInspectionReportDto,
@UploadedFile() file: UploadedInspectionReportFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.officialize(id, dto, file, principal, request);
}
@Get(':id/follow-ups')
@RequirePermissions('inspection_reports.read')
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.workflow.listFollowUps(id);
}
@Post(':id/follow-ups')
@RequirePermissions('inspection_reports.generate')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 },
}))
addFollowUp(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CreateInspectionReportFollowUpDto,
@UploadedFile() file: UploadedInspectionReportFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.addFollowUp(id, dto, file, principal, request);
}
@Get(':id') @Get(':id')
@RequirePermissions('inspection_reports.read') @RequirePermissions('inspection_reports.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.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 { InspectionDeadlineAdminService } from './inspection-deadline-admin.service';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller'; import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
import { InspectionReportReviewService } from './inspection-report-review.service';
import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller'; import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
import { InspectionReportsService } from './inspection-reports.service'; import { InspectionReportsService } from './inspection-reports.service';
@@ -15,18 +16,18 @@ import { SmtpDeliveryService } from './smtp-delivery.service';
controllers: [ controllers: [
InspectionReportsController, InspectionReportsController,
InspectionActReportController, InspectionActReportController,
InspectionDeadlineAdminController,
DocumentDeliveryController, DocumentDeliveryController,
InspectionReportReviewController,
InspectionReportRevisionContentController,
], ],
providers: [ providers: [
InspectionReportsService, InspectionReportsService,
InspectionReportWorkflowService,
InspectionReportWordService, InspectionReportWordService,
InspectionActPdfService, InspectionActPdfService,
InspectionDeadlineAdminService,
InspectionDocumentDeliveryService, InspectionDocumentDeliveryService,
SmtpDeliveryService, SmtpDeliveryService,
InspectionReportReviewService,
], ],
exports: [InspectionReportsService], exports: [InspectionReportsService, SmtpDeliveryService],
}) })
export class InspectionReportsModule {} export class InspectionReportsModule {}
@@ -5,7 +5,6 @@ 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 {
AuditAction, AuditAction,
DocumentSequenceType,
InspectionActStatus, InspectionActStatus,
InspectionReportPdfStatus, InspectionReportPdfStatus,
InspectionReportStatus, InspectionReportStatus,
@@ -16,7 +15,7 @@ import type { ListInspectionReportsQueryDto } from './dto/list-inspection-report
import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-REPORT-V1'; const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-INF-V4';
interface ContextAsset { interface ContextAsset {
id: string; id: string;
@@ -32,21 +31,32 @@ export interface InspectionReportListItem {
reportNumber: number; reportNumber: number;
code: string; code: string;
status: InspectionReportStatus; status: InspectionReportStatus;
executiveSummary: string | null;
reportDescription: string | null;
gedoIfIdentifier: string | null;
gedoOfficializedAt: Date | null;
gedoPdfOriginalName: string | null;
gedoPdfSha256: string | null;
pdfStatus: InspectionReportPdfStatus; pdfStatus: InspectionReportPdfStatus;
wordStatus: 'PENDING' | 'READY' | 'FAILED'; wordStatus: 'PENDING' | 'READY' | 'FAILED';
wordGeneratedAt: Date | null; wordGeneratedAt: Date | null;
reviewStatus: 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED';
currentRevisionNumber: number;
approvedAt: Date | null;
signedAt: Date | null;
title: string; title: string;
actVersion: number; actVersion: number;
actClosureSha256: string; actClosureSha256: string;
frozenSha256: string; frozenSha256: string;
generatedAt: Date; generatedAt: Date;
generatedBy: { id: string; username: string; firstName: string; lastName: string }; generatedBy: { id: string; username: string; firstName: string; lastName: string };
act: { id: string; code: string; title: string; status: string; occurredAt: Date; closedAt: Date | null }; act: {
visit: { id: string; code: string; title: string; status: string }; id: string;
code: string;
title: string;
status: string;
occurredAt: Date;
sealedAt: Date | null;
urgency: string;
deadlineAt: Date | null;
};
visit: { id: string; code: string; status: string };
companies: ContextAsset[]; companies: ContextAsset[];
areas: ContextAsset[]; areas: ContextAsset[];
findingCount: number; findingCount: number;
@@ -63,10 +73,9 @@ export interface PendingInspectionReportItem {
actTitle: string; actTitle: string;
actYear: number; actYear: number;
occurredAt: Date; occurredAt: Date;
closedAt: Date; sealedAt: Date;
closureSha256: string; closureSha256: string;
visitCode: string; visitCode: string;
visitTitle: string;
companies: ContextAsset[]; companies: ContextAsset[];
areas: ContextAsset[]; areas: ContextAsset[];
findingCount: number; findingCount: number;
@@ -127,7 +136,7 @@ export class InspectionReportsService {
async listPending(query: ListInspectionReportsQueryDto) { async listPending(query: ListInspectionReportsQueryDto) {
const conditions = [ const conditions = [
`act.status = 'CLOSED'`, `act.status = 'SEALED'`,
'report.id IS NULL', 'report.id IS NULL',
'act.closure_sha256 IS NOT NULL', 'act.closure_sha256 IS NOT NULL',
]; ];
@@ -142,22 +151,6 @@ export class InspectionReportsService {
act.code ILIKE ${search} act.code ILIKE ${search}
OR act.title ILIKE ${search} OR act.title ILIKE ${search}
OR visit.code ILIKE ${search} OR visit.code ILIKE ${search}
OR visit.title ILIKE ${search}
OR EXISTS (
SELECT 1
FROM inspection_act_assets search_link
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
WHERE search_link.act_id = act.id
AND search_link.included = true
AND (
search_asset.name ILIKE ${search}
OR search_asset.code ILIKE ${search}
OR search_company.name ILIKE ${search}
OR search_area.name ILIKE ${search}
)
)
)`); )`);
} }
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
@@ -196,10 +189,9 @@ export class InspectionReportsService {
act.title AS "actTitle", act.title AS "actTitle",
act.act_year AS "actYear", act.act_year AS "actYear",
act.occurred_at AS "occurredAt", act.occurred_at AS "occurredAt",
act.closed_at AS "closedAt", act.sealed_at AS "sealedAt",
act.closure_sha256 AS "closureSha256", act.closure_sha256 AS "closureSha256",
visit.code AS "visitCode", visit.code AS "visitCode",
visit.title AS "visitTitle",
COALESCE(context.companies, '[]'::jsonb) AS companies, COALESCE(context.companies, '[]'::jsonb) AS companies,
COALESCE(context.areas, '[]'::jsonb) AS areas, COALESCE(context.areas, '[]'::jsonb) AS areas,
COALESCE(finding_count.total, 0)::integer AS "findingCount" COALESCE(finding_count.total, 0)::integer AS "findingCount"
@@ -226,16 +218,7 @@ export class InspectionReportsService {
} }
async get(id: string): Promise<InspectionReportView> { async get(id: string): Promise<InspectionReportView> {
const [report] = (await this.dataSource.query( return this.getWithManager(this.dataSource.manager, id);
`${this.reportSelect('WHERE report.id = $1')}`,
[id],
)) as InspectionReportListItem[];
if (!report) throw reportNotFound();
const [snapshot] = (await this.dataSource.query(
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
[id],
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
} }
async generate( async generate(
@@ -244,144 +227,144 @@ export class InspectionReportsService {
request: RequestWithContext, request: RequestWithContext,
): Promise<InspectionReportView> { ): Promise<InspectionReportView> {
assertMobileInspector(principal); assertMobileInspector(principal);
const report = await this.dataSource.transaction(async (manager) => this.ensureFrozenReport(manager, actId, principal, request)); const report = await this.dataSource.transaction((manager) =>
this.ensureFrozenReport(manager, actId, principal, request),
);
await this.word.ensure(report.id); await this.word.ensure(report.id);
return this.get(report.id); return this.get(report.id);
} }
/**
* Conserva una copia interna inmutable del Acta sellada como fuente documental.
* El INF que se genera desde esa fuente permanece editable y versionable.
*/
async ensureFrozenReport( async ensureFrozenReport(
manager: EntityManager, manager: EntityManager,
actId: string, actId: string,
principal: AuthPrincipal, principal: AuthPrincipal,
request: RequestWithContext, request: RequestWithContext,
): Promise<InspectionReportView> { ): Promise<InspectionReportView> {
const [act] = (await manager.query(` const [act] = (await manager.query(`
SELECT SELECT
act.id,
act.visit_id AS "visitId",
act.act_year AS "actYear",
act.code,
act.title,
act.status,
act.current_version AS "currentVersion",
act.closure_sha256 AS "closureSha256",
visit.code AS "visitCode",
visit.title AS "visitTitle"
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
FOR UPDATE OF act
`, [actId])) as Array<{
id: string;
visitId: string;
actYear: number;
code: string;
title: string;
status: InspectionActStatus;
currentVersion: number;
closureSha256: string | null;
visitCode: string;
visitTitle: string;
}>;
if (!act) throw actNotFound();
if (act.status !== InspectionActStatus.CLOSED || !act.closureSha256) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ACT_NOT_CLOSED',
message: 'El informe sólo puede emitirse después del cierre definitivo del acta',
});
}
await this.assertActorAssigned(manager, act.visitId, principal.userId);
const [existing] = (await manager.query(
'SELECT id FROM inspection_reports WHERE act_id = $1',
[actId],
)) as Array<{ id: string }>;
if (existing) return this.getWithManager(manager, existing.id);
const [closure] = (await manager.query(`
SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256"
FROM inspection_act_closures
WHERE act_id = $1
`, [actId])) as Array<{
finalSnapshot: Record<string, unknown> | null;
finalSha256: string | null;
}>;
if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) {
throw new ConflictException({
code: 'INSPECTION_REPORT_CLOSURE_NOT_FROZEN',
message: 'El cierre del acta no tiene una instantánea final válida para emitir el informe',
});
}
const reportNumber = await this.allocateNumber(manager, act.actYear);
if (reportNumber > 999999) {
throw new ConflictException({
code: 'INSPECTION_REPORT_SEQUENCE_EXHAUSTED',
message: 'La numeración anual de informes agotó su rango disponible',
});
}
const code = `INF-${act.actYear}-${String(reportNumber).padStart(6, '0')}`;
const generatedAt = new Date();
const title = `Informe técnico · ${act.title}`.slice(0, 220);
const frozenSnapshot = {
schemaVersion: REPORT_SCHEMA_VERSION,
report: {
code,
reportYear: act.actYear,
reportNumber,
generatedAt: generatedAt.toISOString(),
generatedBy: principal.userId,
generatedByUsername: principal.username,
visitId: act.visitId,
visitCode: act.visitCode,
visitTitle: act.visitTitle,
actId: act.id,
actCode: act.code,
actVersion: act.currentVersion,
actClosureSha256: act.closureSha256,
},
actClosure: closure.finalSnapshot,
};
const frozenSha256 = sha256CanonicalJson(frozenSnapshot);
const [created] = (await manager.query(`
INSERT INTO inspection_reports (
visit_id, act_id, report_year, report_number, code, status, pdf_status,
title, act_version, act_closure_sha256, frozen_sha256, frozen_snapshot,
generated_at, generated_by
) VALUES (
$1, $2, $3, $4, $5, 'FROZEN', 'PENDING', $6, $7, $8, $9, $10, $11, $12
)
RETURNING id
`, [
act.visitId,
act.id, act.id,
act.actYear, act.visit_id AS "visitId",
reportNumber, act.act_year AS "actYear",
act.act_number AS "actNumber",
act.code,
act.title,
act.status,
act.current_version AS "currentVersion",
act.closure_sha256 AS "closureSha256",
act.occurred_at AS "occurredAt",
visit.code AS "visitCode"
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
FOR UPDATE OF act
`, [actId])) as Array<{
id: string;
visitId: string;
actYear: number;
actNumber: number;
code: string;
title: string;
status: InspectionActStatus;
currentVersion: number;
closureSha256: string | null;
occurredAt: Date;
visitCode: string;
}>;
if (!act) throw actNotFound();
if (act.status !== InspectionActStatus.SEALED || !act.closureSha256) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ACT_NOT_SEALED',
message: 'El INF sólo puede generarse después de sellar el acta',
});
}
await this.assertActorAssigned(manager, act.visitId, principal.userId);
const [existing] = (await manager.query(
'SELECT id FROM inspection_reports WHERE act_id = $1',
[actId],
)) as Array<{ id: string }>;
if (existing) return this.getWithManager(manager, existing.id);
const [closure] = (await manager.query(`
SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256"
FROM inspection_act_closures
WHERE act_id = $1
`, [actId])) as Array<{
finalSnapshot: Record<string, unknown> | null;
finalSha256: string | null;
}>;
if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ACT_SNAPSHOT_INVALID',
message: 'El acta sellada no tiene una instantánea final válida',
});
}
const reportNumber = act.actNumber;
const code = act.code.replace(/^ACT-/, 'INF-');
const generatedAt = new Date();
const title = `Informe de inspección · ${act.code}`.slice(0, 220);
const frozenSnapshot = {
schemaVersion: REPORT_SCHEMA_VERSION,
source: {
actId: act.id,
actCode: act.code,
actVersion: act.currentVersion,
actClosureSha256: act.closureSha256,
inspectionId: act.visitId,
inspectionCode: act.visitCode,
},
sealedAct: closure.finalSnapshot,
};
const frozenSha256 = sha256CanonicalJson(frozenSnapshot);
const [created] = (await manager.query(`
INSERT INTO inspection_reports (
visit_id,act_id,report_year,report_number,code,status,pdf_status,
executive_summary,report_description,title,act_version,act_closure_sha256,
frozen_sha256,frozen_snapshot,generated_at,generated_by
) VALUES (
$1,$2,$3,$4,$5,'WORKING','PENDING',NULL,NULL,$6,$7,$8,$9,$10,$11,$12
)
RETURNING id
`, [
act.visitId,
act.id,
act.actYear,
reportNumber,
code,
title,
act.currentVersion,
act.closureSha256,
frozenSha256,
frozenSnapshot,
generatedAt,
principal.userId,
])) as Array<{ id: string }>;
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_GENERATED,
entityType: 'inspection_report',
entityId: created.id,
afterData: {
code, code,
title, actId: act.id,
act.currentVersion, inspectionId: act.visitId,
act.closureSha256, actVersion: act.currentVersion,
frozenSha256, frozenSha256,
frozenSnapshot, status: InspectionReportStatus.WORKING,
generatedAt, },
principal.userId, }, manager);
])) as Array<{ id: string }>; return this.getWithManager(manager, created.id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_GENERATED,
entityType: 'inspection_report',
entityId: created.id,
afterData: {
code,
actId: act.id,
visitId: act.visitId,
actVersion: act.currentVersion,
frozenSha256,
pdfStatus: InspectionReportPdfStatus.PENDING,
},
}, manager);
return this.getWithManager(manager, created.id);
} }
async ensureWordForAct(actId: string): Promise<void> { async ensureWordForAct(actId: string): Promise<void> {
const [row] = await this.dataSource.query('SELECT id FROM inspection_reports WHERE act_id = $1', [actId]) as Array<{ id: string }>; const [row] = await this.dataSource.query(
'SELECT id FROM inspection_reports WHERE act_id = $1',
[actId],
) as Array<{ id: string }>;
if (row) await this.word.ensure(row.id); if (row) await this.word.ensure(row.id);
await this.delivery.dispatchForAct(actId).catch(() => undefined); await this.delivery.dispatchForAct(actId).catch(() => undefined);
} }
@@ -398,25 +381,10 @@ export class InspectionReportsService {
conditions.push(`( conditions.push(`(
report.code ILIKE ${search} report.code ILIKE ${search}
OR report.title ILIKE ${search} OR report.title ILIKE ${search}
OR report.gedo_if_identifier ILIKE ${search}
OR act.code ILIKE ${search} OR act.code ILIKE ${search}
OR act.title ILIKE ${search} OR act.title ILIKE ${search}
OR visit.code ILIKE ${search} OR visit.code ILIKE ${search}
OR visit.title ILIKE ${search}
OR EXISTS (
SELECT 1
FROM inspection_act_assets search_link
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
WHERE search_link.act_id = act.id
AND search_link.included = true
AND (
search_asset.name ILIKE ${search}
OR search_asset.code ILIKE ${search}
OR search_company.name ILIKE ${search}
OR search_area.name ILIKE ${search}
)
)
)`); )`);
} }
if (query.year) conditions.push(`report.report_year = ${add(query.year)}`); if (query.year) conditions.push(`report.report_year = ${add(query.year)}`);
@@ -454,20 +422,16 @@ export class InspectionReportsService {
return ` return `
SELECT SELECT
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id', company.id, 'id',company.id,'code',company.code,'name',company.name
'code', company.code, )) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies,
'name', company.name
)) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies,
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id', area.id, 'id',area.id,'code',area.code,'name',area.name
'code', area.code, )) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas
'name', area.name
)) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas
FROM inspection_act_assets context_link FROM inspection_act_assets context_link
INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id
LEFT JOIN assets company ON company.id = context_asset.operator_company_id LEFT JOIN assets company ON company.id=context_asset.operator_company_id
LEFT JOIN assets area ON area.id = context_asset.operational_area_id LEFT JOIN assets area ON area.id=context_asset.operational_area_id
WHERE context_link.act_id = act.id AND context_link.included = true WHERE context_link.act_id=act.id AND context_link.included=true
`; `;
} }
@@ -481,50 +445,43 @@ export class InspectionReportsService {
report.report_number AS "reportNumber", report.report_number AS "reportNumber",
report.code, report.code,
report.status, report.status,
report.executive_summary AS "executiveSummary",
report.report_description AS "reportDescription",
report.gedo_if_identifier AS "gedoIfIdentifier",
report.gedo_officialized_at AS "gedoOfficializedAt",
report.gedo_pdf_original_name AS "gedoPdfOriginalName",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
report.pdf_status AS "pdfStatus", report.pdf_status AS "pdfStatus",
report.word_status AS "wordStatus", report.word_status AS "wordStatus",
report.word_generated_at AS "wordGeneratedAt", report.word_generated_at AS "wordGeneratedAt",
report.review_status AS "reviewStatus",
report.current_revision_number AS "currentRevisionNumber",
report.approved_at AS "approvedAt",
report.signed_at AS "signedAt",
report.title, report.title,
report.act_version AS "actVersion", report.act_version AS "actVersion",
report.act_closure_sha256 AS "actClosureSha256", report.act_closure_sha256 AS "actClosureSha256",
report.frozen_sha256 AS "frozenSha256", report.frozen_sha256 AS "frozenSha256",
report.generated_at AS "generatedAt", report.generated_at AS "generatedAt",
JSONB_BUILD_OBJECT( JSONB_BUILD_OBJECT(
'id', generator.id, 'id',generator.id,'username',generator.username,
'username', generator.username, 'firstName',generator.first_name,'lastName',generator.last_name
'firstName', generator.first_name,
'lastName', generator.last_name
) AS "generatedBy", ) AS "generatedBy",
JSONB_BUILD_OBJECT( JSONB_BUILD_OBJECT(
'id', act.id, 'id',act.id,'code',act.code,'title',act.title,'status',act.status,
'code', act.code, 'occurredAt',act.occurred_at,'sealedAt',act.sealed_at,
'title', act.title, 'urgency',act.urgency,'deadlineAt',act.deadline_at
'status', act.status,
'occurredAt', act.occurred_at,
'closedAt', act.closed_at
) AS act, ) AS act,
JSONB_BUILD_OBJECT( JSONB_BUILD_OBJECT(
'id', visit.id, 'id',visit.id,'code',visit.code,'status',visit.status
'code', visit.code,
'title', visit.title,
'status', visit.status
) AS visit, ) AS visit,
COALESCE(context.companies, '[]'::jsonb) AS companies, COALESCE(context.companies,'[]'::jsonb) AS companies,
COALESCE(context.areas, '[]'::jsonb) AS areas, COALESCE(context.areas,'[]'::jsonb) AS areas,
COALESCE(finding_count.total, 0)::integer AS "findingCount" COALESCE(finding_count.total,0)::integer AS "findingCount"
FROM inspection_reports report FROM inspection_reports report
INNER JOIN inspection_acts act ON act.id = report.act_id INNER JOIN inspection_acts act ON act.id=report.act_id
INNER JOIN inspection_visits visit ON visit.id = report.visit_id INNER JOIN inspection_visits visit ON visit.id=report.visit_id
INNER JOIN users generator ON generator.id = report.generated_by INNER JOIN users generator ON generator.id=report.generated_by
LEFT JOIN LATERAL (${this.contextSelect()}) context ON true LEFT JOIN LATERAL (${this.contextSelect()}) context ON true
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT COUNT(*) AS total SELECT COUNT(*) AS total FROM inspection_findings finding
FROM inspection_findings finding WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
) finding_count ON true ) finding_count ON true
${where} ${where}
`; `;
@@ -543,29 +500,15 @@ export class InspectionReportsService {
return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
} }
private async allocateNumber(manager: EntityManager, year: number): Promise<number> {
const [row] = (await manager.query(`
INSERT INTO document_annual_sequences (document_type, year, last_number)
VALUES ($1, $2, 1)
ON CONFLICT (document_type, year) DO UPDATE SET
last_number = document_annual_sequences.last_number + 1,
updated_at = CURRENT_TIMESTAMP
RETURNING last_number AS number
`, [DocumentSequenceType.REPORT, year])) as Array<{ number: number }>;
return Number(row.number);
}
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> { private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT 1 SELECT 1 FROM inspection_visit_members
FROM inspection_visit_members WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1
WHERE visit_id = $1 AND user_id = $2 AND included = true
LIMIT 1
`, [visitId, userId])) as unknown[]; `, [visitId, userId])) as unknown[];
if (rows.length === 0) { if (!rows.length) {
throw new ForbiddenException({ throw new ForbiddenException({
code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED', code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED',
message: 'Sólo un inspector asignado a la visita puede solicitar el informe', message: 'Sólo un inspector asignado a la inspección puede solicitar el informe',
}); });
} }
} }
@@ -1,13 +1,27 @@
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 { randomUUID } from 'node:crypto';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { SmtpSecurityMode } from '../database/entities';
interface MailAttachment { filename:string; mimeType:string; content:Buffer; } interface MailAttachment { filename:string; mimeType:string; content:Buffer; }
interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; } interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; }
interface Reply { code:number; text:string; } interface Reply { code:number; text:string; }
export interface EffectiveSmtpSettings {
source: 'DATABASE' | 'ENVIRONMENT';
host: string;
port: number;
securityMode: SmtpSecurityMode;
username: string | null;
password: string;
fromName: string | null;
fromEmail: string;
replyTo: string | null;
}
class SmtpSession { class SmtpSession {
private buffer=''; private waiters:Array<(reply:Reply)=>void>=[]; private replyLines:string[]=[]; private buffer=''; private waiters:Array<(reply:Reply)=>void>=[]; private replyLines:string[]=[];
private readonly dataHandler=(chunk:Buffer|string)=>this.onData(typeof chunk==='string'?chunk:chunk.toString('utf8')); private readonly dataHandler=(chunk:Buffer|string)=>this.onData(typeof chunk==='string'?chunk:chunk.toString('utf8'));
@@ -20,18 +34,164 @@ class SmtpSession {
async command(command:string, expected:number|number[]){ this.socket.write(`${command}\r\n`); const reply=await this.reply(); const allowed=Array.isArray(expected)?expected:[expected]; if(!allowed.includes(reply.code))throw new Error(`SMTP ${reply.code}: ${reply.text}`); return reply; } async command(command:string, expected:number|number[]){ this.socket.write(`${command}\r\n`); const reply=await this.reply(); const allowed=Array.isArray(expected)?expected:[expected]; if(!allowed.includes(reply.code))throw new Error(`SMTP ${reply.code}: ${reply.text}`); return reply; }
write(data:string){this.socket.write(data);} write(data:string){this.socket.write(data);}
end(){this.socket.end();} end(){this.socket.end();}
current(){return this.socket;}
} }
function subject(value:string){return `=?UTF-8?B?${Buffer.from(value,'utf8').toString('base64')}?=`;} function subject(value:string){return `=?UTF-8?B?${Buffer.from(value,'utf8').toString('base64')}?=`;}
function envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();} function envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();}
function mime(input:MailInput,from:string){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].join('\r\n'); return body.replace(/^\./gm,'..'); } function mime(input:MailInput,from:string,replyTo:string|null){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,replyTo?`Reply-To: ${replyTo}`:null,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].filter((line):line is string=>line!==null).join('\r\n'); return body.replace(/^\./gm,'..'); }
@Injectable() @Injectable()
export class SmtpDeliveryService { export class SmtpDeliveryService {
constructor(private readonly config:ConfigService){} constructor(
configured(){return Boolean(this.config.get<string>('SMTP_HOST')&&this.config.get<string>('MAIL_FROM'));} private readonly dataSource:DataSource,
async send(input:MailInput):Promise<{messageId:string}>{ const host=this.config.get<string>('SMTP_HOST'); const from=this.config.get<string>('MAIL_FROM'); if(!host||!from)throw new Error('SMTP no configurado'); const port=Number(this.config.get<string>('SMTP_PORT')??587); const secure=String(this.config.get<string>('SMTP_SECURE')??'false').toLowerCase()==='true'; const user=this.config.get<string>('SMTP_USER')??''; const pass=this.config.get<string>('SMTP_PASS')??''; const base=await new Promise<Socket|TLSSocket>((resolve,reject)=>{ if(secure){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); } else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); } }); const session=new SmtpSession(base); const welcome=await session.reply(); if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`); let ehlo=await session.command(`EHLO dh-inspeccion`,250); if(!secure){ if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS'); await session.command('STARTTLS',220); const rawForTls=session.detachForUpgrade() as Socket; const upgraded=await new Promise<TLSSocket>((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }); session.replaceSocket(upgraded); ehlo=await session.command('EHLO dh-inspeccion',250); } private readonly config:ConfigService,
if(user){ if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${user}\u0000${pass}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); } else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(user).toString('base64'),334); await session.command(Buffer.from(pass).toString('base64'),235); } } ){}
await session.command(`MAIL FROM:<${envelope(from)}>`,250); await session.command(`RCPT TO:<${input.to}>`,[250,251]); await session.command('DATA',354); session.write(`${mime(input,from)}\r\n.\r\n`); const sent=await session.reply(); if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`); await session.command('QUIT',221).catch(()=>undefined); session.end(); const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i); return {messageId:match?.[1]??randomUUID()}; }
async configured():Promise<boolean>{return Boolean(await this.resolveSettings());}
async fromAddress():Promise<string|null>{
const settings=await this.resolveSettings();
if(!settings)return null;
return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail;
}
async publicSettings(){
const [row]=await this.dataSource.query(`
SELECT id,host,port,security_mode AS "securityMode",username,
(password_enc IS NOT NULL) AS "hasPassword",from_name AS "fromName",
from_email AS "fromEmail",reply_to AS "replyTo",enabled,
updated_at AS "updatedAt"
FROM system_smtp_settings ORDER BY created_at LIMIT 1
`) as Array<Record<string,unknown>>;
if(row)return {...row,source:'DATABASE'};
const fallback=await this.resolveEnvironment();
return fallback?{
source:'ENVIRONMENT',host:fallback.host,port:fallback.port,
securityMode:fallback.securityMode,username:fallback.username,
hasPassword:Boolean(fallback.password),fromName:fallback.fromName,
fromEmail:fallback.fromEmail,replyTo:fallback.replyTo,enabled:true,
}:{source:'NONE',enabled:false};
}
async saveSettings(input:{
host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null;
password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean;
},userId:string){
const [existing]=await this.dataSource.query(`SELECT id,password_enc AS "passwordEnc" FROM system_smtp_settings ORDER BY created_at LIMIT 1`) as Array<{id:string;passwordEnc:string|null}>;
const passwordEnc=input.password===undefined
? existing?.passwordEnc??null
: input.password?this.encryptSecret(input.password):null;
if(existing){
await this.dataSource.query(`
UPDATE system_smtp_settings SET host=$2,port=$3,security_mode=$4,username=$5,
password_enc=$6,from_name=$7,from_email=$8,reply_to=$9,enabled=$10,
updated_by=$11,updated_at=CURRENT_TIMESTAMP WHERE id=$1
`,[existing.id,input.host,input.port,input.securityMode,input.username??null,passwordEnc,input.fromName,input.fromEmail,input.replyTo??null,input.enabled,userId]);
}else{
await this.dataSource.query(`
INSERT INTO system_smtp_settings(host,port,security_mode,username,password_enc,from_name,from_email,reply_to,enabled,updated_by)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
`,[input.host,input.port,input.securityMode,input.username??null,passwordEnc,input.fromName,input.fromEmail,input.replyTo??null,input.enabled,userId]);
}
return this.publicSettings();
}
async send(input:MailInput):Promise<{messageId:string}>{
const settings=await this.resolveSettings();
if(!settings)throw new Error('SMTP no configurado');
const {host,port,securityMode,userName,password}= {
host:settings.host,port:settings.port,securityMode:settings.securityMode,
userName:settings.username??'',password:settings.password,
};
const implicitTls=securityMode===SmtpSecurityMode.TLS;
const base=await new Promise<Socket|TLSSocket>((resolve,reject)=>{
if(implicitTls){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }
else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); }
});
const session=new SmtpSession(base);
const welcome=await session.reply();
if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`);
let ehlo=await session.command('EHLO dh-inspeccion',250);
if(securityMode===SmtpSecurityMode.STARTTLS){
if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS');
await session.command('STARTTLS',220);
const rawForTls=session.detachForUpgrade() as Socket;
const upgraded=await new Promise<TLSSocket>((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); });
session.replaceSocket(upgraded);
ehlo=await session.command('EHLO dh-inspeccion',250);
}
if(userName){
if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${userName}\u0000${password}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); }
else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(userName).toString('base64'),334); await session.command(Buffer.from(password).toString('base64'),235); }
}
const from=settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail;
await session.command(`MAIL FROM:<${envelope(from)}>`,250);
await session.command(`RCPT TO:<${input.to}>`,[250,251]);
await session.command('DATA',354);
session.write(`${mime(input,from,settings.replyTo)}\r\n.\r\n`);
const sent=await session.reply();
if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`);
await session.command('QUIT',221).catch(()=>undefined);
session.end();
const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i);
return {messageId:match?.[1]??randomUUID()};
}
private async resolveSettings():Promise<EffectiveSmtpSettings|null>{
const [row]=await this.dataSource.query(`
SELECT host,port,security_mode AS "securityMode",username,password_enc AS "passwordEnc",
from_name AS "fromName",from_email AS "fromEmail",reply_to AS "replyTo",enabled
FROM system_smtp_settings ORDER BY created_at LIMIT 1
`) as Array<{
host:string;port:number;securityMode:SmtpSecurityMode;username:string|null;passwordEnc:string|null;
fromName:string;fromEmail:string;replyTo:string|null;enabled:boolean;
}>;
if(row){
if(!row.enabled)return null;
return {
source:'DATABASE',host:row.host,port:Number(row.port),securityMode:row.securityMode,
username:row.username,password:row.passwordEnc?this.decryptSecret(row.passwordEnc):'',
fromName:row.fromName,fromEmail:row.fromEmail,replyTo:row.replyTo,
};
}
return this.resolveEnvironment();
}
private async resolveEnvironment():Promise<EffectiveSmtpSettings|null>{
const host=this.config.get<string>('SMTP_HOST');
const rawFrom=this.config.get<string>('MAIL_FROM');
if(!host||!rawFrom)return null;
const fromEmail=envelope(rawFrom);
const fromName=rawFrom.includes('<')?rawFrom.slice(0,rawFrom.indexOf('<')).trim().replace(/^"|"$/g,'')||null:null;
const secure=String(this.config.get<string>('SMTP_SECURE')??'false').toLowerCase()==='true';
return {
source:'ENVIRONMENT',host,port:Number(this.config.get<string>('SMTP_PORT')??(secure?465:587)),
securityMode:secure?SmtpSecurityMode.TLS:SmtpSecurityMode.STARTTLS,
username:this.config.get<string>('SMTP_USER')??null,password:this.config.get<string>('SMTP_PASS')??'',
fromName,fromEmail,replyTo:null,
};
}
private encryptionKey():Buffer{
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada');
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes');
return key;
}
private encryptSecret(value:string):string{
const iv=randomBytes(12); const cipher=createCipheriv('aes-256-gcm',this.encryptionKey(),iv);
const encrypted=Buffer.concat([cipher.update(value,'utf8'),cipher.final()]);
const tag=cipher.getAuthTag();
return `v1:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
}
private decryptSecret(value:string):string{
const [version,ivB64,tagB64,cipherB64]=value.split(':');
if(version!=='v1'||!ivB64||!tagB64||!cipherB64)throw new Error('Credencial SMTP cifrada inválida');
const decipher=createDecipheriv('aes-256-gcm',this.encryptionKey(),Buffer.from(ivB64,'base64'));
decipher.setAuthTag(Buffer.from(tagB64,'base64'));
return Buffer.concat([decipher.update(Buffer.from(cipherB64,'base64')),decipher.final()]).toString('utf8');
}
} }
@@ -27,10 +27,8 @@ interface VerificationContextView {
interface VerificationVisitView { interface VerificationVisitView {
id: string; id: string;
code: string; code: string;
title: string;
status: InspectionVisitStatus; status: InspectionVisitStatus;
plannedStartAt: Date | null; plannedStartAt: Date | null;
plannedEndAt: Date | null;
} }
export interface VerificationPlanningItem { export interface VerificationPlanningItem {
@@ -39,12 +37,11 @@ export interface VerificationPlanningItem {
title: string; title: string;
status: 'OPEN'; status: 'OPEN';
nextControlOn: string; nextControlOn: string;
companyResponseReceivedOn: string;
asset: VerificationContextView & { typeName: string }; asset: VerificationContextView & { typeName: string };
company: VerificationContextView | null; company: VerificationContextView | null;
area: VerificationContextView | null; area: VerificationContextView | null;
act: { id: string; code: string }; act: { id: string; code: string };
sourceVisit: { id: string; code: string; title: string }; sourceVisit: { id: string; code: string };
verificationVisit: VerificationVisitView | null; verificationVisit: VerificationVisitView | null;
} }
@@ -75,7 +72,6 @@ interface LockedFindingRow {
areaId: string | null; areaId: string | null;
areaCode: string | null; areaCode: string | null;
areaName: string | null; areaName: string | null;
companyResponseReceivedOn: string | null;
nextControlOn: string | null; nextControlOn: string | null;
status: string; status: string;
} }
@@ -93,7 +89,6 @@ export class InspectionVerificationsService {
const values: unknown[] = []; const values: unknown[] = [];
const baseFilters = [ const baseFilters = [
`finding.status = 'OPEN'`, `finding.status = 'OPEN'`,
`finding.company_response_received_on IS NOT NULL`,
`finding.next_control_on IS NOT NULL`, `finding.next_control_on IS NOT NULL`,
`COALESCE(( `COALESCE((
SELECT latest_verification.outcome SELECT latest_verification.outcome
@@ -132,10 +127,8 @@ export class InspectionVerificationsService {
SELECT SELECT
visit.id, visit.id,
visit.code, visit.code,
visit.title,
visit.status, visit.status,
visit.planned_start_at AS "plannedStartAt", visit.planned_start_at AS "plannedStartAt"
visit.planned_end_at AS "plannedEndAt"
FROM inspection_finding_verification_visits verification_link FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE verification_link.finding_id = finding.id WHERE verification_link.finding_id = finding.id
@@ -181,7 +174,6 @@ export class InspectionVerificationsService {
finding.title, finding.title,
finding.status, finding.status,
finding.next_control_on AS "nextControlOn", finding.next_control_on AS "nextControlOn",
finding.company_response_received_on AS "companyResponseReceivedOn",
jsonb_build_object( jsonb_build_object(
'id', asset.id, 'id', asset.id,
'code', asset.code, 'code', asset.code,
@@ -201,16 +193,13 @@ export class InspectionVerificationsService {
jsonb_build_object('id', act.id, 'code', act.code) AS act, jsonb_build_object('id', act.id, 'code', act.code) AS act,
jsonb_build_object( jsonb_build_object(
'id', source_visit.id, 'id', source_visit.id,
'code', source_visit.code, 'code', source_visit.code
'title', source_visit.title
) AS "sourceVisit", ) AS "sourceVisit",
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE jsonb_build_object( CASE WHEN verification_visit.id IS NULL THEN NULL ELSE jsonb_build_object(
'id', verification_visit.id, 'id', verification_visit.id,
'code', verification_visit.code, 'code', verification_visit.code,
'title', verification_visit.title,
'status', verification_visit.status, 'status', verification_visit.status,
'plannedStartAt', verification_visit."plannedStartAt", 'plannedStartAt', verification_visit."plannedStartAt"
'plannedEndAt', verification_visit."plannedEndAt"
) END AS "verificationVisit" ) END AS "verificationVisit"
FROM inspection_findings finding FROM inspection_findings finding
${joins} ${joins}
@@ -503,7 +492,6 @@ export class InspectionVerificationsService {
finding.title, finding.title,
finding.status, finding.status,
finding.asset_id AS "assetId", finding.asset_id AS "assetId",
finding.company_response_received_on AS "companyResponseReceivedOn",
finding.next_control_on AS "nextControlOn", finding.next_control_on AS "nextControlOn",
asset.code AS "assetCode", asset.code AS "assetCode",
asset.name AS "assetName", asset.name AS "assetName",
@@ -524,13 +512,11 @@ export class InspectionVerificationsService {
if (rows.length !== findingIds.length) { if (rows.length !== findingIds.length) {
throw new NotFoundException({ code: 'VERIFICATION_FINDING_NOT_FOUND', message: 'Uno o más hallazgos ya no están disponibles' }); throw new NotFoundException({ code: 'VERIFICATION_FINDING_NOT_FOUND', message: 'Uno o más hallazgos ya no están disponibles' });
} }
const invalid = rows.find((row) => const invalid = rows.find((row) => row.status !== 'OPEN' || !row.nextControlOn);
row.status !== 'OPEN' || !row.companyResponseReceivedOn || !row.nextControlOn,
);
if (invalid) { if (invalid) {
throw new ConflictException({ throw new ConflictException({
code: 'VERIFICATION_FINDING_NOT_READY', code: 'VERIFICATION_FINDING_NOT_READY',
message: `${invalid.code} no está listo para una verificación planificada`, message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`,
}); });
} }
const missingContext = rows.find((row) => !row.companyId || !row.areaId); const missingContext = rows.find((row) => !row.companyId || !row.areaId);
@@ -569,7 +555,6 @@ export class InspectionVerificationsService {
const companyName = rows[0]?.companyName ?? 'Empresa'; const companyName = rows[0]?.companyName ?? 'Empresa';
const areaName = rows[0]?.areaName ?? 'Área'; const areaName = rows[0]?.areaName ?? 'Área';
const code = await nextInspectionVisitCode(manager, plannedStartAt); const code = await nextInspectionVisitCode(manager, plannedStartAt);
const title = code;
const findingCodes = rows.map((row) => row.code).join(', '); const findingCodes = rows.map((row) => row.code).join(', ');
const notes = dto.notes?.trim() || null; const notes = dto.notes?.trim() || null;
const instructions = [ const instructions = [
@@ -579,7 +564,6 @@ export class InspectionVerificationsService {
const visit = manager.getRepository(InspectionVisit).create({ const visit = manager.getRepository(InspectionVisit).create({
code, code,
title,
objective: 'Verificar en campo la resolución de hallazgos con fecha de control programada.', objective: 'Verificar en campo la resolución de hallazgos con fecha de control programada.',
status: InspectionVisitStatus.DRAFT, status: InspectionVisitStatus.DRAFT,
scopeAssetId: areaId, scopeAssetId: areaId,
@@ -587,7 +571,6 @@ export class InspectionVerificationsService {
operatorCompanyId: companyId, operatorCompanyId: companyId,
leadInspectorUserId: null, leadInspectorUserId: null,
plannedStartAt, plannedStartAt,
plannedEndAt: null,
actualStartedAt: null, actualStartedAt: null,
actualClosedAt: null, actualClosedAt: null,
instructions, instructions,
@@ -650,7 +633,6 @@ export class InspectionVerificationsService {
code: visit.code, code: visit.code,
status: visit.status, status: visit.status,
plannedStartAt: visit.plannedStartAt, plannedStartAt: visit.plannedStartAt,
plannedEndAt: visit.plannedEndAt,
companyId, companyId,
areaId, areaId,
findingIds, findingIds,
@@ -662,10 +644,8 @@ export class InspectionVerificationsService {
visit: { visit: {
id: visit.id, id: visit.id,
code: visit.code, code: visit.code,
title: visit.title,
status: visit.status, status: visit.status,
plannedStartAt: visit.plannedStartAt, plannedStartAt: visit.plannedStartAt,
plannedEndAt: visit.plannedEndAt,
}, },
company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName }, company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName },
area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName }, area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName },
@@ -719,9 +699,6 @@ export class InspectionVerificationsService {
'title', finding.title, 'title', finding.title,
'description', finding.description, 'description', finding.description,
'correctionDueOn', finding.correction_due_on, 'correctionDueOn', finding.correction_due_on,
'companyResponse', finding.company_response,
'companyResponseReceivedOn', finding.company_response_received_on,
'companyCommittedCorrectionOn', finding.company_committed_correction_on,
'nextControlOn', finding.next_control_on, 'nextControlOn', finding.next_control_on,
'currentVersion', finding.current_version, 'currentVersion', finding.current_version,
'verification', JSONB_BUILD_OBJECT( 'verification', JSONB_BUILD_OBJECT(
@@ -759,5 +736,4 @@ export class InspectionVerificationsService {
]); ]);
return versionNumber; return versionNumber;
} }
} }

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