Compare commits
227
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3027e9a8c8 | ||
|
|
09a190532c | ||
|
|
8a8ab47453 | ||
|
|
9b86f49c9e | ||
|
|
8584249eb2 | ||
|
|
5dc7ea037d | ||
|
|
969ffddc76 | ||
|
|
4c47916bd1 | ||
|
|
2121df0b55 | ||
|
|
fcd64d5f43 | ||
|
|
844c1a950f | ||
|
|
e1e44e6352 | ||
|
|
94a8d5c92f | ||
|
|
3bfa3bce1b | ||
|
|
4df8f40dd4 | ||
|
|
7b6460dff2 | ||
|
|
30353c338b | ||
|
|
95a41f39d7 | ||
|
|
100d457484 | ||
|
|
4d0ed8b046 | ||
|
|
a510d309bc | ||
|
|
9606168ac6 | ||
|
|
0442511065 | ||
|
|
27d14d2d4e | ||
|
|
fa64ba7727 | ||
|
|
d0b805b281 | ||
|
|
b3125b51ad | ||
|
|
b67db745e4 | ||
|
|
f0ffde975b | ||
|
|
d8b02bfaf1 | ||
|
|
7a406df1ad | ||
|
|
8c89cd05c9 | ||
|
|
f7a4e7f228 | ||
|
|
7b9d2b7903 | ||
|
|
53db8b93bb | ||
|
|
61f77505df | ||
|
|
c28e5b060d | ||
|
|
2c1fdf7fb2 | ||
|
|
afb6feb4c1 | ||
|
|
7174d379e5 | ||
|
|
ff7a99725e | ||
|
|
6f100bf9ba | ||
|
|
2968f83b36 | ||
|
|
cac3161cbb | ||
|
|
97296ba37b | ||
|
|
80275969c9 | ||
|
|
345ef76820 | ||
|
|
40e33f8312 | ||
|
|
57739dcb3e | ||
|
|
271290abe4 | ||
|
|
5393a40e8b | ||
|
|
3a1379d49a | ||
|
|
ad44dc7ef6 | ||
|
|
248fb6285b | ||
|
|
792e21e669 | ||
|
|
9fab773090 | ||
|
|
38742bf140 | ||
|
|
2018e1aeba | ||
|
|
c11808f560 | ||
|
|
28ec002710 | ||
|
|
90851e6e7d | ||
|
|
0e2b524a26 | ||
|
|
5bd392dcb4 | ||
|
|
232614ccf0 | ||
|
|
b24c3787c9 | ||
|
|
71d0884557 | ||
|
|
2506bece76 | ||
|
|
a9fc9f3e8e | ||
|
|
f70962a819 | ||
|
|
e6ae2807bc | ||
|
|
5404409042 | ||
|
|
49b321ca87 | ||
|
|
048b7b90af | ||
|
|
c53997a41c | ||
|
|
459ad03326 | ||
|
|
a816f4cfdf | ||
|
|
98545820ac | ||
|
|
a9b2ed4b4b | ||
|
|
5386ccf9b5 | ||
|
|
0df7935b54 | ||
|
|
2d2a268220 | ||
|
|
b021189a94 | ||
|
|
ce0bbf1d94 | ||
|
|
3f0a1e5441 | ||
|
|
bd767b7e32 | ||
|
|
d08162fca8 | ||
|
|
2826960d71 | ||
|
|
0718414fdb | ||
|
|
a4a57d3109 | ||
|
|
e3b24cba3d | ||
|
|
cc8c5d9392 | ||
|
|
27f52a05c8 | ||
|
|
b5b6942e39 | ||
|
|
0609b7faab | ||
|
|
0aa4e06d17 | ||
|
|
d2ae9f9bd4 | ||
|
|
e3c347dbb8 | ||
|
|
116dd24b7b | ||
|
|
c2c935fd85 | ||
|
|
068cf8ae3a | ||
|
|
fbf426ed90 | ||
|
|
46c2abb4d1 | ||
|
|
50728e83ba | ||
|
|
cbf8f0c738 | ||
|
|
e2049b084f | ||
|
|
73eb6fc192 | ||
|
|
89f48dd82d | ||
|
|
5b22dde1c6 | ||
|
|
3d3d89a76a | ||
|
|
29864fb2ae | ||
|
|
dea007581c | ||
|
|
de87952743 | ||
|
|
0bab3e5b70 | ||
|
|
17e2e1902b | ||
|
|
3e2b021943 | ||
|
|
830addb7b5 | ||
|
|
2319d06685 | ||
|
|
8d962e783d | ||
|
|
b69b773094 | ||
|
|
5996c3819a | ||
|
|
7ac42fa05d | ||
|
|
3180101ef7 | ||
|
|
572c8a4af1 | ||
|
|
91480779dd | ||
|
|
0a6500fbff | ||
|
|
69bc720899 | ||
|
|
ce33b32820 | ||
|
|
212f4dcee5 | ||
|
|
1775392eb1 | ||
|
|
92cf0dee97 | ||
|
|
2e5c4c35aa | ||
|
|
1192ed2cdd | ||
|
|
4db3797afc | ||
|
|
7a910d7505 | ||
|
|
c999354645 | ||
|
|
bc7cd48436 | ||
|
|
44eccaa36c | ||
|
|
82d8af2728 | ||
|
|
408fd5990d | ||
|
|
c715029dbc | ||
|
|
f83ab824ff | ||
|
|
513fe6121b | ||
|
|
fb12ce3cff | ||
|
|
558fdbc85d | ||
|
|
4a86dd15d9 | ||
|
|
86e692c537 | ||
|
|
f8e866a140 | ||
|
|
30f543ad08 | ||
|
|
747ad21262 | ||
|
|
a9a6b2fa1f | ||
|
|
4caf6216de | ||
|
|
f20cc28045 | ||
|
|
3b4310b63a | ||
|
|
335d327984 | ||
|
|
f4e5480ac8 | ||
|
|
bdcf193a90 | ||
|
|
e577e4c976 | ||
|
|
343dbe086f | ||
|
|
a178f64763 | ||
|
|
badb79be5b | ||
|
|
161f4a9252 | ||
|
|
73c69c7a62 | ||
|
|
ef084dd899 | ||
|
|
eeb35da02c | ||
|
|
0db4567289 | ||
|
|
53702009e3 | ||
|
|
f5ab6ce885 | ||
|
|
1bed1cae98 | ||
|
|
d9698f11f0 | ||
|
|
d0c55b8b39 | ||
|
|
009f4d10cf | ||
|
|
c30f167731 | ||
|
|
8b033dc297 | ||
|
|
236605a7d7 | ||
|
|
af6256ce0f | ||
|
|
0a8cb8f8e5 | ||
|
|
5e14ffda17 | ||
|
|
fcf52c60ff | ||
|
|
577e267f49 | ||
|
|
83ab2d4060 | ||
|
|
2b63638c35 | ||
|
|
c3c798baed | ||
|
|
a836c7535a | ||
|
|
88179eb67c | ||
|
|
4d5c83732e | ||
|
|
0833746e1d | ||
|
|
e02a3d9b58 | ||
|
|
cbe69a8fff | ||
|
|
8b4cc99e53 | ||
|
|
5c1c046be6 | ||
|
|
e25f72ac0a | ||
|
|
5e6631554a | ||
|
|
9a79b7eb86 | ||
|
|
d071e6be74 | ||
|
|
2d97398c01 | ||
|
|
489fb309b3 | ||
|
|
a7f53f32f0 | ||
|
|
ce3d3cf8b7 | ||
|
|
96093d9ed4 | ||
|
|
a1d994b6d4 | ||
|
|
39846c81a4 | ||
|
|
31256ea0b5 | ||
|
|
ad66bb3e61 | ||
|
|
0c35e801eb | ||
|
|
b8fde4c32d | ||
|
|
416aac335c | ||
|
|
97a6fb029d | ||
|
|
f23a28e438 | ||
|
|
c348f3162c | ||
|
|
56f9c4234b | ||
|
|
e7a9ff7dd9 | ||
|
|
01efa2a8cc | ||
|
|
bda4ce224d | ||
|
|
92240b21c2 | ||
|
|
6cc687d5e1 | ||
|
|
c2d4f82a3a | ||
|
|
f184c73c9e | ||
|
|
d834bc7b82 | ||
|
|
c97e5ae33c | ||
|
|
0f1164fae8 | ||
|
|
ab665e51b5 | ||
|
|
13f2dcfa95 | ||
|
|
2fb48ec41e | ||
|
|
52a068c817 | ||
|
|
a9efd71367 | ||
|
|
7a41ce25ad | ||
|
|
48bbe293d9 |
+11
-1
@@ -21,10 +21,20 @@ ACCESS_COOKIE_NAME=dhv2_access
|
||||
REFRESH_COOKIE_NAME=dhv2_refresh
|
||||
CSRF_COOKIE_NAME=dhv2_csrf
|
||||
|
||||
INSPECTION_SIGNATURE_ROOT=/app/storage/asset-media/inspection-signatures
|
||||
INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word
|
||||
INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions
|
||||
|
||||
INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf
|
||||
|
||||
# Enlace público de un solo uso para firma/manifestación de empresa.
|
||||
# En producción: https://dhv2.korexlabs.com/firma-acta
|
||||
# En entorno de prueba: https://prueba.dhv2.korexlabs.com/firma-acta
|
||||
COMPANY_SIGNATURE_PUBLIC_BASE_URL=https://dhv2.korexlabs.com/firma-acta
|
||||
|
||||
# Clave maestra de 32 bytes (64 hex o base64) para cifrar la contraseña SMTP guardada por Superadmin.
|
||||
SMTP_SETTINGS_MASTER_KEY=CHANGE_ME_WITH_32_RANDOM_BYTES
|
||||
|
||||
# Fallback SMTP. Se usa sólo hasta que Superadmin guarde una configuración en la base de datos.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
|
||||
@@ -62,6 +62,11 @@ jobs:
|
||||
while IFS= read -r -d '' script; do
|
||||
bash -n "$script"
|
||||
done < <(find scripts -type f -name '*.sh' -print0)
|
||||
- name: Validate deploy preflight parity
|
||||
run: |
|
||||
grep -Fq -- '$STAGE/docker-compose.yml:/docker-compose.yml:ro' scripts/deploy-github.sh
|
||||
grep -Fq -- '$STAGE/web-v2:/web-v2:ro' scripts/deploy-github.sh
|
||||
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
|
||||
- name: Validate Compose
|
||||
run: docker compose --env-file .env.example config >/dev/null
|
||||
- name: VPS-equivalent isolated API preflight
|
||||
@@ -72,6 +77,9 @@ jobs:
|
||||
docker run --rm \
|
||||
-v "$PWD/api-v3/test:/app/test:ro" \
|
||||
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
||||
-v "$PWD/docker-compose.yml:/docker-compose.yml:ro" \
|
||||
-v "$PWD/web-v2:/web-v2:ro" \
|
||||
-v "$PWD/android-app:/android-app:ro" \
|
||||
"$image" npm test
|
||||
docker image rm "$image" >/dev/null 2>&1 || true
|
||||
- name: Build production images
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
name: F4 Document Flow CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'feature/f4*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'api-v3/**'
|
||||
- 'web-v2/**'
|
||||
- 'android-app/**'
|
||||
- 'scripts/**'
|
||||
- '.github/workflows/f4-ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api:
|
||||
name: API · F4
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api-v3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: api-v3/package-lock.json
|
||||
- run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Tests
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
web:
|
||||
name: WEB · regression
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web-v2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: web-v2/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- name: F3.1 structural WEB contract
|
||||
run: bash ../scripts/check-f3-1-web-contract.sh
|
||||
- run: npm run build
|
||||
|
||||
deploy-preflight:
|
||||
name: VPS-equivalent preflight / Docker
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [api, web]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate shell scripts
|
||||
run: |
|
||||
while IFS= read -r -d '' script; do
|
||||
bash -n "$script"
|
||||
done < <(find scripts -type f -name '*.sh' -print0)
|
||||
- name: Validate Compose
|
||||
run: docker compose --env-file .env.example config >/dev/null
|
||||
- name: VPS-equivalent isolated API tests
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
image="dhv2-api:f4-preflight-${GITHUB_SHA::12}"
|
||||
docker build --target builder -t "$image" api-v3
|
||||
docker run --rm \
|
||||
-v "$PWD/api-v3/test:/app/test:ro" \
|
||||
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
||||
"$image" npm test
|
||||
docker image rm "$image" >/dev/null 2>&1 || true
|
||||
- name: Build production images
|
||||
run: docker compose --env-file .env.example build api migrate web
|
||||
@@ -151,11 +151,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
fun createActForSelectedInventory() {
|
||||
fun createActForSelectedInventory(urgency: String = "NON_URGENT") {
|
||||
val currentVisit = visit ?: return
|
||||
val asset = selectedFieldAsset?.asset
|
||||
if (currentVisit.status != "IN_PROGRESS") {
|
||||
error = "La inspección debe estar en curso para crear un Acta."
|
||||
error = "La Inspección debe estar en curso para crear un Acta."
|
||||
return
|
||||
}
|
||||
if (urgency !in setOf("URGENT", "NON_URGENT")) {
|
||||
error = "Elegí si el Acta es urgente o no urgente."
|
||||
return
|
||||
}
|
||||
if (asset == null) {
|
||||
@@ -163,15 +167,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
if (acts.any { it.status == "DRAFT" }) {
|
||||
error = "Ya existe un Acta en borrador. Cerrala o cancelala antes de crear la siguiente."
|
||||
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
val created = actsRepository.create(currentVisit.id, asset.id, currentVisit.code)
|
||||
val created = actsRepository.create(
|
||||
currentVisit.id,
|
||||
asset.id,
|
||||
currentVisit.code,
|
||||
urgency,
|
||||
)
|
||||
selectedAct = created
|
||||
actClosure = actsRepository.closure(created.id)
|
||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||
notice = "${created.code} creada. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
|
||||
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
|
||||
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
|
||||
}
|
||||
@@ -196,7 +206,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val visitId = visit?.id ?: return
|
||||
launchBusy {
|
||||
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
|
||||
notice = "Inventario agregado a la inspección."
|
||||
notice = "Inventario agregado a la Inspección."
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
val draft = selectedDraftAct()
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||
@@ -222,7 +232,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
) {
|
||||
val visitId = visit?.id ?: return
|
||||
if (visit?.status != "IN_PROGRESS") {
|
||||
error = "La inspección debe estar en curso para dar de alta Inventario."
|
||||
error = "La Inspección debe estar en curso para dar de alta Inventario."
|
||||
return
|
||||
}
|
||||
if (type.familyRequired && familyId == null) {
|
||||
@@ -449,26 +459,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
absenceReason = reason.trim(),
|
||||
),
|
||||
)
|
||||
notice = "Ausencia del responsable registrada."
|
||||
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta."
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareSelectedAct() {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.prepare(actId)
|
||||
actClosure = actsRepository.lock(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
notice = "Acta preparada. Su contenido quedó congelado para las firmas."
|
||||
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
|
||||
}
|
||||
}
|
||||
|
||||
fun reopenSelectedAct() {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.reopen(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
notice = "Acta reabierta. Podés corregirla antes de volver a preparar."
|
||||
}
|
||||
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
|
||||
}
|
||||
|
||||
fun signSelectedActAsInspector(
|
||||
@@ -507,13 +512,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
fun recordCompanyOutcome(status: String, reason: String) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
if (status != "REFUSED") {
|
||||
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
|
||||
return
|
||||
}
|
||||
if (reason.trim().length < 10) {
|
||||
error = "Indicá un motivo de al menos 10 caracteres."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
actClosure = actsRepository.companyOutcome(actId, status, reason)
|
||||
notice = if (status == "ABSENT") "Ausencia de empresa asentada." else "Negativa a firmar asentada."
|
||||
notice = "Negativa a firmar asentada."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,11 +530,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val currentVisit = visit ?: return
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.closeAct(actId)
|
||||
actClosure = actsRepository.seal(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||
clearFindingState()
|
||||
notice = "${selectedAct?.code ?: "Acta"} cerrada e inmutable. Podés crear otra Acta o finalizar la inspección."
|
||||
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,7 +543,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
launchBusy {
|
||||
visit = actsRepository.closeVisit(visitId)
|
||||
loadVisitsInternal()
|
||||
notice = "Inspección cerrada. Las Actas y documentos quedan disponibles para oficina."
|
||||
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ data class PersonSummary(
|
||||
data class VisitSummary(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val title: String? = null,
|
||||
val objective: String? = null,
|
||||
val status: String,
|
||||
val scopeAsset: AssetSummary? = null,
|
||||
@@ -153,7 +152,6 @@ data class ChecklistSummary(
|
||||
data class VisitDetail(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val title: String? = null,
|
||||
val objective: String? = null,
|
||||
val status: String,
|
||||
val operationalArea: AssetSummary? = null,
|
||||
|
||||
@@ -37,6 +37,15 @@ data class MobileActSummary(
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
val urgency: String = "NON_URGENT",
|
||||
val deadlineDays: Int? = null,
|
||||
val deadlineDayType: String? = null,
|
||||
val deadlineBasis: String? = null,
|
||||
val deadlineBaseAt: String? = null,
|
||||
val deadlineAt: String? = null,
|
||||
val lockedAt: String? = null,
|
||||
val lockedSha256: String? = null,
|
||||
val sealedAt: String? = null,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
@@ -53,6 +62,15 @@ data class MobileActDetail(
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
val urgency: String = "NON_URGENT",
|
||||
val deadlineDays: Int? = null,
|
||||
val deadlineDayType: String? = null,
|
||||
val deadlineBasis: String? = null,
|
||||
val deadlineBaseAt: String? = null,
|
||||
val deadlineAt: String? = null,
|
||||
val lockedAt: String? = null,
|
||||
val lockedSha256: String? = null,
|
||||
val sealedAt: String? = null,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
@@ -75,6 +93,7 @@ data class MobileActListResponse(
|
||||
|
||||
data class CreateMobileActRequest(
|
||||
val occurredAt: String,
|
||||
val urgency: String,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
@@ -113,6 +132,15 @@ data class MobileActClosureHeader(
|
||||
val code: String,
|
||||
val status: String,
|
||||
val visitId: String,
|
||||
val urgency: String = "NON_URGENT",
|
||||
val deadlineDays: Int? = null,
|
||||
val deadlineDayType: String? = null,
|
||||
val deadlineBasis: String? = null,
|
||||
val deadlineBaseAt: String? = null,
|
||||
val deadlineAt: String? = null,
|
||||
val lockedAt: String? = null,
|
||||
val lockedSha256: String? = null,
|
||||
val sealedAt: String? = null,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
@@ -169,7 +197,7 @@ data class MobileCompanyOutcomeRequest(
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
data class MobileCloseActRequest(
|
||||
data class MobileSealActRequest(
|
||||
val clientClosedAt: String = Instant.now().toString(),
|
||||
val uploadMode: String = "ONLINE",
|
||||
)
|
||||
@@ -219,14 +247,8 @@ private interface MobileActsApi {
|
||||
@Body request: MobileResponsibleRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/ready")
|
||||
suspend fun ready(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/reopen")
|
||||
suspend fun reopen(
|
||||
@POST("inspection-acts/{actId}/lock")
|
||||
suspend fun lock(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActClosure
|
||||
@@ -268,11 +290,11 @@ private interface MobileActsApi {
|
||||
@Body request: MobileCompanyOutcomeRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/close")
|
||||
suspend fun closeAct(
|
||||
@POST("inspection-acts/{actId}/seal")
|
||||
suspend fun sealAct(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Body request: MobileCloseActRequest,
|
||||
@Body request: MobileSealActRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-visits/{visitId}/close")
|
||||
@@ -305,14 +327,20 @@ class MobileActsRepository(context: Context) {
|
||||
api.act("Bearer ${session.accessToken}", actId)
|
||||
}
|
||||
|
||||
suspend fun create(visitId: String, assetId: String, visitCode: String): MobileActDetail = authorized { session ->
|
||||
suspend fun create(
|
||||
visitId: String,
|
||||
assetId: String,
|
||||
visitCode: String,
|
||||
urgency: String = "NON_URGENT",
|
||||
): MobileActDetail = authorized { session ->
|
||||
api.createAct(
|
||||
"Bearer ${session.accessToken}",
|
||||
visitId,
|
||||
CreateMobileActRequest(
|
||||
occurredAt = Instant.now().toString(),
|
||||
urgency = urgency,
|
||||
title = "Acta de inspección $visitCode",
|
||||
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la visita.",
|
||||
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
|
||||
assetIds = listOf(assetId),
|
||||
),
|
||||
)
|
||||
@@ -335,12 +363,8 @@ class MobileActsRepository(context: Context) {
|
||||
api.responsible("Bearer ${session.accessToken}", actId, request)
|
||||
}
|
||||
|
||||
suspend fun prepare(actId: String): MobileActClosure = authorized { session ->
|
||||
api.ready("Bearer ${session.accessToken}", actId)
|
||||
}
|
||||
|
||||
suspend fun reopen(actId: String): MobileActClosure = authorized { session ->
|
||||
api.reopen("Bearer ${session.accessToken}", actId)
|
||||
suspend fun lock(actId: String): MobileActClosure = authorized { session ->
|
||||
api.lock("Bearer ${session.accessToken}", actId)
|
||||
}
|
||||
|
||||
suspend fun signInspector(
|
||||
@@ -378,8 +402,8 @@ class MobileActsRepository(context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun closeAct(actId: String): MobileActClosure = authorized { session ->
|
||||
api.closeAct("Bearer ${session.accessToken}", actId, MobileCloseActRequest())
|
||||
suspend fun seal(actId: String): MobileActClosure = authorized { session ->
|
||||
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
|
||||
}
|
||||
|
||||
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
|
||||
|
||||
@@ -60,6 +60,7 @@ fun MobileActsScreen(
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
|
||||
var attendance by rememberSaveable(selected?.id) {
|
||||
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
|
||||
}
|
||||
@@ -129,19 +130,20 @@ fun MobileActsScreen(
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Actas de esta inspección", fontWeight = FontWeight.Bold)
|
||||
Text("Actas de esta Inspección", fontWeight = FontWeight.Bold)
|
||||
if (model.acts.isEmpty()) {
|
||||
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
|
||||
}
|
||||
model.acts.forEach { act ->
|
||||
val active = selected?.id == act.id
|
||||
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente"
|
||||
OutlinedButton(
|
||||
onClick = { model.selectAct(act.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
(if (active) "✓ " else "") +
|
||||
"${act.code} · ${actStatusLabel(act.status)} · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}",
|
||||
"${act.code} · ${actStatusLabel(act.status)} · $urgency · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -156,12 +158,31 @@ fun MobileActsScreen(
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Nueva Acta", fontWeight = FontWeight.Bold)
|
||||
if (model.acts.any { it.status == "READY" }) {
|
||||
if (model.acts.any { it.status == "LOCKED" }) {
|
||||
Text(
|
||||
"Puede existir una nueva Acta en borrador aunque haya Actas preparadas pendientes de firma de empresa. Sólo se permite un borrador a la vez.",
|
||||
"Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AssistChip(
|
||||
onClick = { newActUrgency = "NON_URGENT" },
|
||||
label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
|
||||
)
|
||||
AssistChip(
|
||||
onClick = { newActUrgency = "URGENT" },
|
||||
label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (newActUrgency == "URGENT") {
|
||||
"El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla."
|
||||
} else {
|
||||
"El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
val selectedInventory = model.selectedFieldAsset?.asset
|
||||
if (selectedInventory == null) {
|
||||
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
|
||||
@@ -171,7 +192,7 @@ fun MobileActsScreen(
|
||||
} else {
|
||||
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
|
||||
Button(
|
||||
onClick = { model.createActForSelectedInventory() },
|
||||
onClick = { model.createActForSelectedInventory(newActUrgency) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Crear nueva Acta") }
|
||||
@@ -186,9 +207,14 @@ fun MobileActsScreen(
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(actStatusLabel(selected.status))
|
||||
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
|
||||
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
|
||||
Text(selected.summary, style = MaterialTheme.typography.bodySmall)
|
||||
selected.closureSha256?.let { Text("Hash final: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
|
||||
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,31 +260,28 @@ fun MobileActsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita. Un Acta de verificación puede prepararse sin Hallazgos nuevos si la verificación ya fue registrada.")
|
||||
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.")
|
||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
|
||||
|
||||
HorizontalDivider()
|
||||
Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Al preparar, el contenido se congela y se calcula su hash. El servidor exige al menos un Hallazgo o una verificación registrada.")
|
||||
Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
|
||||
Button(
|
||||
onClick = { model.prepareSelectedAct() },
|
||||
enabled = !model.busy && closure?.responsible != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Preparar Acta para firmas") }
|
||||
) { Text("Finalizar y BLOQUEAR Acta") }
|
||||
}
|
||||
|
||||
"READY" -> {
|
||||
"LOCKED" -> {
|
||||
val signatures = closure?.signatures.orEmpty()
|
||||
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
|
||||
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
||||
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
|
||||
|
||||
Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
if (signatures.isEmpty()) {
|
||||
OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Volver a borrador")
|
||||
}
|
||||
}
|
||||
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
|
||||
closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
||||
@@ -274,29 +297,21 @@ fun MobileActsScreen(
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Recepción de la empresa", fontWeight = FontWeight.Bold)
|
||||
if (companyOutcome != null) {
|
||||
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
|
||||
if (companyOutcome != null && companyResolved) {
|
||||
val detail = when (companyOutcome.status) {
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada"
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
|
||||
"REFUSED" -> "Negativa a firmar"
|
||||
"ABSENT" -> "Responsable ausente"
|
||||
else -> companyOutcome.status
|
||||
}
|
||||
Text("✓ $detail", color = MaterialTheme.colorScheme.primary)
|
||||
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
||||
Text("El responsable fue registrado como ausente.")
|
||||
Button(
|
||||
onClick = {
|
||||
model.recordCompanyOutcome(
|
||||
"ABSENT",
|
||||
closure.responsible.absenceReason ?: "Responsable de empresa ausente durante la inspección",
|
||||
)
|
||||
},
|
||||
enabled = !model.busy && inspectorSigned,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Asentar ausencia en el Acta") }
|
||||
Text(
|
||||
"El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -333,28 +348,28 @@ fun MobileActsScreen(
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
if (inspectorSigned && companyOutcome != null) {
|
||||
if (inspectorSigned && companyResolved) {
|
||||
Button(
|
||||
onClick = { model.closeSelectedAct() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar Acta definitivamente") }
|
||||
} else if (inspectorSigned) {
|
||||
) { Text("SELLAR Acta definitivamente") }
|
||||
} else {
|
||||
Text(
|
||||
"La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.",
|
||||
"Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"CLOSED" -> {
|
||||
"SEALED" -> {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold)
|
||||
Text("El PDF, informe Word y entregas documentales se generan desde este cierre.")
|
||||
Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)
|
||||
Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")
|
||||
closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
@@ -366,16 +381,19 @@ fun MobileActsScreen(
|
||||
|
||||
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
val drafts = model.acts.count { it.status == "DRAFT" }
|
||||
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"No puede quedar ninguna Acta en borrador. Al cerrar, el servidor verifica que cada Acta preparada tenga firma de inspector; si falta alguna, indicará cuál debe completarse.",
|
||||
)
|
||||
val activeActs = model.acts.filter { it.status != "CANCELLED" }
|
||||
val pendingActs = activeActs.filter { it.status != "SEALED" }
|
||||
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (pendingActs.isEmpty()) {
|
||||
Text("Todas las Actas están SELLADAS. La Inspección ya puede cerrarse.")
|
||||
} else {
|
||||
Text("No puede cerrarse todavía. Falta SELLAR: ${pendingActs.joinToString { it.code }}")
|
||||
}
|
||||
Button(
|
||||
onClick = { model.closeInspection() },
|
||||
enabled = !model.busy && drafts == 0,
|
||||
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar inspección y salir de la empresa") }
|
||||
) { Text("Cerrar Inspección y salir de la empresa") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,8 +415,8 @@ private fun F32ActMessage(model: MainViewModel) {
|
||||
|
||||
private fun actStatusLabel(status: String): String = when (status) {
|
||||
"DRAFT" -> "Borrador"
|
||||
"READY" -> "Preparada para firmas"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"LOCKED" -> "BLOQUEADA"
|
||||
"SEALED" -> "SELLADA"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> status
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class ActAdministrationService {
|
||||
COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_count
|
||||
FROM inspection_findings f WHERE f.act_id = ia.id
|
||||
) fc ON true
|
||||
WHERE ia.status IN ('CLOSED', 'RECTIFIED')
|
||||
WHERE ia.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
|
||||
), classified AS (
|
||||
SELECT *, CASE
|
||||
WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED'
|
||||
@@ -110,7 +110,7 @@ export class ActAdministrationService {
|
||||
const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]);
|
||||
const act = rows[0];
|
||||
if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' });
|
||||
if (!['CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_CLOSED', message: 'El seguimiento administrativo comienza cuando el Acta está cerrada.' });
|
||||
if (!['SEALED', 'CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_SEALED', message: 'El seguimiento administrativo comienza cuando el Acta está sellada.' });
|
||||
return act;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ export class FieldBriefingService {
|
||||
ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC
|
||||
LIMIT 1
|
||||
) response ON true
|
||||
WHERE act.status IN ('CLOSED', 'RECTIFIED')
|
||||
WHERE act.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
|
||||
AND source_visit.id <> $1
|
||||
AND source_visit.operational_area_id = $2::uuid
|
||||
AND source_visit.operator_company_id = $3::uuid
|
||||
|
||||
+20
-13
@@ -3,27 +3,26 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ActAdministrationModule } from './act-administration/act-administration.module';
|
||||
import { AdministrationModule } from './administration/administration.module';
|
||||
import { AssetImportsModule } from './asset-imports/asset-imports.module';
|
||||
import { AssetMasterModule } from './asset-master/asset-master.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AccessTokenGuard } from './auth/guards/access-token.guard';
|
||||
import { AdministrationModule } from './administration/administration.module';
|
||||
import { AuthorizationModule } from './authorization/authorization.module';
|
||||
import { PermissionsGuard } from './authorization/guards/permissions.guard';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AccessTokenGuard } from './auth/guards/access-token.guard';
|
||||
import { CsrfGuard } from './auth/guards/csrf.guard';
|
||||
import { PhaseADataModule } from './core-data/phase-a-data.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { AssetMasterModule } from './asset-master/asset-master.module';
|
||||
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
|
||||
import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
|
||||
import { InspectionClosingModule } from './inspection-closing/inspection-closing.module';
|
||||
import { InspectionDeadlinesModule } from './inspection-deadlines/inspection-deadlines.module';
|
||||
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
|
||||
import { InspectionClosingModule } from './inspection-closing/inspection-closing.module';
|
||||
import { AssetImportsModule } from './asset-imports/asset-imports.module';
|
||||
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
|
||||
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
|
||||
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
|
||||
import { ActAdministrationModule } from './act-administration/act-administration.module';
|
||||
|
||||
function required(config: ConfigService, key: string): string {
|
||||
const value = config.get<string>(key);
|
||||
@@ -33,7 +32,10 @@ function required(config: ConfigService, key: string): string {
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
ignoreEnvFile: true,
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
@@ -51,7 +53,13 @@ function required(config: ConfigService, key: string): string {
|
||||
connectTimeoutMS: 5000,
|
||||
}),
|
||||
}),
|
||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 120 }]),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
name: 'default',
|
||||
ttl: 60_000,
|
||||
limit: 120,
|
||||
},
|
||||
]),
|
||||
PhaseADataModule,
|
||||
AuditModule,
|
||||
AuthorizationModule,
|
||||
@@ -63,7 +71,6 @@ function required(config: ConfigService, key: string): string {
|
||||
InspectionActsModule,
|
||||
InspectionFindingsModule,
|
||||
InspectionClosingModule,
|
||||
InspectionDeadlinesModule,
|
||||
InspectionReportsModule,
|
||||
InspectionVerificationsModule,
|
||||
ActAdministrationModule,
|
||||
|
||||
@@ -41,14 +41,14 @@ export interface AssetVersionDetail extends AssetVersionSummary {
|
||||
function assetNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_NOT_FOUND',
|
||||
message: 'Activo no encontrado',
|
||||
message: 'Inventario no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
function versionNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_VERSION_NOT_FOUND',
|
||||
message: 'Versión de activo no encontrada',
|
||||
message: 'Versión de Inventario no encontrada',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export class AssetHistoryService {
|
||||
|
||||
const versionNumber = Number(versionRow.current_version);
|
||||
if (!Number.isInteger(versionNumber) || versionNumber < 1) {
|
||||
throw new Error(`Versión de activo inválida después de incrementar: ${versionRow.current_version}`);
|
||||
throw new Error(`Versión de Inventario inválida después de incrementar: ${versionRow.current_version}`);
|
||||
}
|
||||
|
||||
const snapshot = await this.loadCurrentSnapshot(manager, assetId);
|
||||
@@ -101,17 +101,8 @@ export class AssetHistoryService {
|
||||
asset_id, version_number, change_type, changed_fields, snapshot,
|
||||
actor_user_id, actor_username, source, request_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
assetId,
|
||||
versionNumber,
|
||||
changeType,
|
||||
changedFields,
|
||||
snapshot,
|
||||
principal.userId,
|
||||
principal.username,
|
||||
source,
|
||||
request.requestId,
|
||||
],
|
||||
[assetId, versionNumber, changeType, changedFields, snapshot,
|
||||
principal.userId, principal.username, source, request.requestId],
|
||||
);
|
||||
return versionNumber;
|
||||
}
|
||||
@@ -123,38 +114,21 @@ export class AssetHistoryService {
|
||||
parameters.push(value);
|
||||
return `$${parameters.length}`;
|
||||
};
|
||||
|
||||
if (query.search?.trim()) {
|
||||
const search = add(`%${query.search.trim()}%`);
|
||||
conditions.push(`(
|
||||
version.snapshot->>'code' ILIKE ${search}
|
||||
OR version.snapshot->>'name' ILIKE ${search}
|
||||
OR version.actor_username ILIKE ${search}
|
||||
)`);
|
||||
}
|
||||
if (query.typeId) {
|
||||
conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
|
||||
}
|
||||
if (query.status) {
|
||||
conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
|
||||
}
|
||||
if (query.changeType) {
|
||||
conditions.push(`version.change_type = ${add(query.changeType)}`);
|
||||
conditions.push(`(version.snapshot->>'code' ILIKE ${search} OR version.snapshot->>'name' ILIKE ${search} OR version.actor_username ILIKE ${search})`);
|
||||
}
|
||||
if (query.typeId) conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
|
||||
if (query.status) conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
|
||||
if (query.changeType) conditions.push(`version.change_type = ${add(query.changeType)}`);
|
||||
if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`);
|
||||
if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`);
|
||||
|
||||
return this.listWithConditions(query.page, query.pageSize, conditions, parameters);
|
||||
}
|
||||
|
||||
async listForAsset(assetId: string, query: AssetVersionPageQueryDto) {
|
||||
await this.requireAsset(assetId);
|
||||
return this.listWithConditions(
|
||||
query.page,
|
||||
query.pageSize,
|
||||
['version.asset_id = $1'],
|
||||
[assetId],
|
||||
);
|
||||
return this.listWithConditions(query.page, query.pageSize, ['version.asset_id = $1'], [assetId]);
|
||||
}
|
||||
|
||||
async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> {
|
||||
@@ -170,17 +144,9 @@ export class AssetHistoryService {
|
||||
return row;
|
||||
}
|
||||
|
||||
private async listWithConditions(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
conditions: string[],
|
||||
parameters: unknown[],
|
||||
) {
|
||||
private async listWithConditions(page: number, pageSize: number, conditions: string[], parameters: unknown[]) {
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const [countRow] = (await this.dataSource.query(
|
||||
`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`,
|
||||
parameters,
|
||||
)) as Array<{ total: number }>;
|
||||
const [countRow] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`, parameters)) as Array<{ total: number }>;
|
||||
const total = Number(countRow?.total ?? 0);
|
||||
const paginated = [...parameters, pageSize, (page - 1) * pageSize];
|
||||
const limit = `$${parameters.length + 1}`;
|
||||
@@ -194,51 +160,29 @@ export class AssetHistoryService {
|
||||
LIMIT ${limit} OFFSET ${offset}`,
|
||||
paginated,
|
||||
)) as AssetVersionSummary[];
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: total === 0 ? 0 : Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
return { data, meta: { page, pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) } };
|
||||
}
|
||||
|
||||
private selectSummary(): string {
|
||||
return `SELECT
|
||||
version.id,
|
||||
version.asset_id AS "assetId",
|
||||
version.snapshot->>'code' AS "assetCode",
|
||||
version.snapshot->>'name' AS "assetName",
|
||||
version.snapshot #>> '{type,id}' AS "typeId",
|
||||
version.snapshot #>> '{type,name}' AS "typeName",
|
||||
version.id, version.asset_id AS "assetId",
|
||||
version.snapshot->>'code' AS "assetCode", version.snapshot->>'name' AS "assetName",
|
||||
version.snapshot #>> '{type,id}' AS "typeId", version.snapshot #>> '{type,name}' AS "typeName",
|
||||
version.snapshot->>'informationStatus' AS "informationStatus",
|
||||
version.snapshot->>'operationalStatus' AS "operationalStatus",
|
||||
version.version_number AS "versionNumber",
|
||||
version.change_type AS "changeType",
|
||||
version.changed_fields AS "changedFields",
|
||||
version.occurred_at AS "occurredAt",
|
||||
version.actor_user_id AS "actorUserId",
|
||||
version.actor_username AS "actorUsername",
|
||||
version.source,
|
||||
version.request_id AS "requestId",
|
||||
version.version_number AS "versionNumber", version.change_type AS "changeType",
|
||||
version.changed_fields AS "changedFields", version.occurred_at AS "occurredAt",
|
||||
version.actor_user_id AS "actorUserId", version.actor_username AS "actorUsername",
|
||||
version.source, version.request_id AS "requestId",
|
||||
(version.version_number = current_asset.current_version) AS "isCurrent"`;
|
||||
}
|
||||
|
||||
private async requireAsset(assetId: string): Promise<void> {
|
||||
const [row] = (await this.dataSource.query(
|
||||
'SELECT 1 FROM assets WHERE id = $1',
|
||||
[assetId],
|
||||
)) as unknown[];
|
||||
const [row] = (await this.dataSource.query('SELECT 1 FROM assets WHERE id = $1', [assetId])) as unknown[];
|
||||
if (!row) throw assetNotFound();
|
||||
}
|
||||
|
||||
private async loadCurrentSnapshot(
|
||||
manager: EntityManager,
|
||||
assetId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
private async loadCurrentSnapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
|
||||
const [row] = (await manager.query(
|
||||
`SELECT JSONB_BUILD_OBJECT(
|
||||
'id', asset.id,
|
||||
@@ -246,82 +190,49 @@ export class AssetHistoryService {
|
||||
'name', asset.name,
|
||||
'commonName', asset.common_name,
|
||||
'description', asset.description,
|
||||
'type', JSONB_BUILD_OBJECT(
|
||||
'id', asset_type.id,
|
||||
'code', asset_type.code,
|
||||
'name', asset_type.name,
|
||||
'operationalRole', asset_type.operational_role
|
||||
),
|
||||
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', parent.id,
|
||||
'code', parent.code,
|
||||
'name', parent.name
|
||||
) END,
|
||||
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', operational_area.id,
|
||||
'code', operational_area.code,
|
||||
'name', operational_area.name
|
||||
) END,
|
||||
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', operator_company.id,
|
||||
'code', operator_company.code,
|
||||
'name', operator_company.name
|
||||
) END,
|
||||
'type', JSONB_BUILD_OBJECT('id', asset_type.id,'code', asset_type.code,'name', asset_type.name,'operationalRole', asset_type.operational_role),
|
||||
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', parent.id,'code', parent.code,'name', parent.name) END,
|
||||
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operational_area.id,'code', operational_area.code,'name', operational_area.name) END,
|
||||
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operator_company.id,'code', operator_company.code,'name', operator_company.name) END,
|
||||
'informationStatus', asset.information_status,
|
||||
'operationalStatus', asset.operational_status,
|
||||
'currentFunction', (
|
||||
SELECT JSONB_BUILD_OBJECT(
|
||||
'assignmentId', assignment.id,
|
||||
'id', fn.id,
|
||||
'code', fn.code,
|
||||
'name', fn.name,
|
||||
'validFrom', assignment.valid_from,
|
||||
'reason', assignment.change_reason
|
||||
)
|
||||
FROM inventory_function_assignments assignment
|
||||
JOIN inventory_functions fn ON fn.id=assignment.function_id
|
||||
WHERE assignment.asset_id=asset.id AND assignment.valid_until IS NULL
|
||||
ORDER BY assignment.valid_from DESC LIMIT 1
|
||||
),
|
||||
'attributes', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'definitionId', definition.id,
|
||||
'code', definition.code,
|
||||
'name', definition.name,
|
||||
'dataType', definition.data_type,
|
||||
'isRequired', definition.is_required,
|
||||
'unit', definition.unit,
|
||||
'options', definition.options,
|
||||
'sortOrder', definition.sort_order,
|
||||
'value', value.value
|
||||
'definitionId', definition.id,'code', definition.code,'name', definition.name,
|
||||
'dataType', definition.data_type,'isRequired', definition.is_required,
|
||||
'unit', definition.unit,'options', definition.options,'sortOrder', definition.sort_order,'value', value.value
|
||||
) ORDER BY definition.sort_order, definition.name)
|
||||
FROM asset_attribute_definitions definition
|
||||
LEFT JOIN asset_attribute_values value
|
||||
ON value.definition_id = definition.id
|
||||
AND value.asset_id = asset.id
|
||||
WHERE definition.asset_type_id = asset.asset_type_id
|
||||
AND definition.is_active = true
|
||||
LEFT JOIN asset_attribute_values value ON value.definition_id = definition.id AND value.asset_id = asset.id
|
||||
WHERE definition.asset_type_id = asset.asset_type_id AND definition.is_active = true
|
||||
), '[]'::jsonb),
|
||||
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'assetId', geometry.asset_id,
|
||||
'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,
|
||||
'geometryType', geometry.geometry_type,
|
||||
'source', geometry.source,
|
||||
'accuracyM', geometry.accuracy_m::double precision,
|
||||
'capturedAt', geometry.captured_at,
|
||||
'deviceLabel', geometry.device_label,
|
||||
'createdAt', geometry.created_at,
|
||||
'updatedAt', geometry.updated_at,
|
||||
'updatedBy', geometry.updated_by
|
||||
'assetId', geometry.asset_id,'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,'geometryType', geometry.geometry_type,
|
||||
'source', geometry.source,'accuracyM', geometry.accuracy_m::double precision,'capturedAt', geometry.captured_at,
|
||||
'deviceLabel', geometry.device_label,'createdAt', geometry.created_at,'updatedAt', geometry.updated_at,'updatedBy', geometry.updated_by
|
||||
) END,
|
||||
'media', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'id', media.id,
|
||||
'kind', media.kind,
|
||||
'originalName', media.original_name,
|
||||
'mimeType', media.mime_type,
|
||||
'sizeBytes', media.size_bytes,
|
||||
'sha256', media.sha256,
|
||||
'title', media.title,
|
||||
'description', media.description,
|
||||
'capturedAt', media.captured_at,
|
||||
'latitude', media.latitude,
|
||||
'longitude', media.longitude,
|
||||
'accuracyM', media.accuracy_m,
|
||||
'source', media.source,
|
||||
'uploadedBy', media.uploaded_by,
|
||||
'createdAt', media.created_at,
|
||||
'updatedAt', media.updated_at
|
||||
'id', media.id,'kind', media.kind,'originalName', media.original_name,'mimeType', media.mime_type,
|
||||
'sizeBytes', media.size_bytes,'sha256', media.sha256,'title', media.title,'description', media.description,
|
||||
'capturedAt', media.captured_at,'latitude', media.latitude,'longitude', media.longitude,'accuracyM', media.accuracy_m,
|
||||
'source', media.source,'uploadedBy', media.uploaded_by,'createdAt', media.created_at,'updatedAt', media.updated_at
|
||||
) ORDER BY media.created_at, media.id)
|
||||
FROM asset_media media
|
||||
WHERE media.asset_id = asset.id
|
||||
AND media.deleted_at IS NULL
|
||||
FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL
|
||||
), '[]'::jsonb),
|
||||
'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id),
|
||||
'organizationMemberships', COALESCE((
|
||||
@@ -337,22 +248,8 @@ export class AssetHistoryService {
|
||||
'legalRights', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id
|
||||
),'[]'::jsonb),
|
||||
'provenance', JSONB_BUILD_OBJECT(
|
||||
'origin', asset.data_origin,
|
||||
'sourceName', asset.source_name,
|
||||
'sourceReference', asset.source_reference,
|
||||
'observedAt', asset.source_observed_at,
|
||||
'notes', asset.source_notes,
|
||||
'verifiedAt', asset.provenance_verified_at,
|
||||
'verifiedBy', asset.provenance_verified_by,
|
||||
'updatedAt', asset.provenance_updated_at,
|
||||
'updatedBy', asset.provenance_updated_by
|
||||
),
|
||||
'createdAt', asset.created_at,
|
||||
'updatedAt', asset.updated_at,
|
||||
'createdBy', asset.created_by,
|
||||
'updatedBy', asset.updated_by,
|
||||
'currentVersion', asset.current_version
|
||||
'provenance', JSONB_BUILD_OBJECT('origin', asset.data_origin,'sourceName', asset.source_name,'sourceReference', asset.source_reference,'observedAt', asset.source_observed_at,'notes', asset.source_notes,'verifiedAt', asset.provenance_verified_at,'verifiedBy', asset.provenance_verified_by,'updatedAt', asset.provenance_updated_at,'updatedBy', asset.provenance_updated_by),
|
||||
'createdAt', asset.created_at,'updatedAt', asset.updated_at,'createdBy', asset.created_by,'updatedBy', asset.updated_by,'currentVersion', asset.current_version
|
||||
) AS snapshot
|
||||
FROM assets asset
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
|
||||
@@ -29,6 +29,8 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic
|
||||
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
||||
import { InventoryMergeService } from './inventory-merge.service';
|
||||
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
||||
import { InventoryFunctionController } from './inventory-function.controller';
|
||||
import { InventoryFunctionService } from './inventory-function.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
@@ -37,6 +39,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
|
||||
AssetsController,
|
||||
InventoryStructureController,
|
||||
InventoryFamilyCatalogController,
|
||||
InventoryFunctionController,
|
||||
InventoryMergeController,
|
||||
FieldInventoryMergeController,
|
||||
AssetGeometriesController,
|
||||
@@ -53,6 +56,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
|
||||
AssetsService,
|
||||
InventoryStructureService,
|
||||
InventoryFamilyCatalogService,
|
||||
InventoryFunctionService,
|
||||
InventoryMergeService,
|
||||
MergedInventoryDossierService,
|
||||
AssetGeometriesService,
|
||||
@@ -67,6 +71,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
|
||||
exports: [
|
||||
AssetHistoryService,
|
||||
AssetsService,
|
||||
InventoryFunctionService,
|
||||
InventoryMergeService,
|
||||
MergedInventoryDossierService,
|
||||
AssetGeometriesService,
|
||||
|
||||
@@ -298,7 +298,7 @@ export class AssetsService {
|
||||
|
||||
const [visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT DISTINCT visit.id, visit.code, visit.title, visit.status,
|
||||
`SELECT DISTINCT visit.id, visit.code, visit.status,
|
||||
visit.planned_start_at AS "plannedStartAt",
|
||||
visit.actual_started_at AS "actualStartedAt",
|
||||
visit.actual_closed_at AS "actualClosedAt",
|
||||
@@ -322,7 +322,7 @@ export class AssetsService {
|
||||
`SELECT DISTINCT act.id, act.visit_id AS "visitId", act.code, act.status,
|
||||
act.occurred_at AS "occurredAt", act.title, act.summary,
|
||||
act.closed_at AS "closedAt", act.current_version AS "currentVersion",
|
||||
visit.code AS "visitCode", visit.title AS "visitTitle"
|
||||
visit.code AS "visitCode"
|
||||
FROM inspection_acts act
|
||||
JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
LEFT JOIN inspection_act_assets act_asset
|
||||
@@ -342,7 +342,7 @@ export class AssetsService {
|
||||
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
|
||||
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
|
||||
act.code AS "actCode", act.occurred_at AS "actOccurredAt",
|
||||
visit.id AS "visitId", visit.code AS "visitCode", visit.title AS "visitTitle"
|
||||
visit.id AS "visitId", visit.code AS "visitCode"
|
||||
FROM inspection_findings finding
|
||||
JOIN inspection_acts act ON act.id = finding.act_id
|
||||
JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
@@ -389,7 +389,7 @@ export class AssetsService {
|
||||
verification_link.result_recorded_at AS "resultRecordedAt",
|
||||
verification_link.rescheduled_control_on AS "rescheduledControlOn",
|
||||
finding.code AS "findingCode", finding.title AS "findingTitle",
|
||||
visit.code AS "visitCode", visit.title AS "visitTitle", visit.status AS "visitStatus",
|
||||
visit.code AS "visitCode", visit.status AS "visitStatus",
|
||||
(SELECT COUNT(*)::integer
|
||||
FROM inspection_finding_evidence verification_evidence
|
||||
WHERE verification_evidence.finding_id = finding.id
|
||||
@@ -484,7 +484,7 @@ export class AssetsService {
|
||||
kind: 'INSPECTION',
|
||||
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
|
||||
title: `Inspección ${String(visit.code)}`,
|
||||
description: visit.title,
|
||||
description: null,
|
||||
href: `/inspecciones/${String(visit.id)}`,
|
||||
meta: { status: visit.status },
|
||||
}));
|
||||
@@ -867,7 +867,7 @@ export class AssetsService {
|
||||
private fieldDiscoverySelect(): string {
|
||||
return `SELECT discovery.id, discovery.status, discovery.discovery_notes AS "discoveryNotes", discovery.observed_at AS "observedAt", discovery.reviewed_at AS "reviewedAt", discovery.review_notes AS "reviewNotes", discovery.created_at AS "createdAt",
|
||||
JSONB_BUILD_OBJECT('id',asset.id,'code',asset.code,'name',asset.name,'commonName',asset.common_name,'informationStatus',asset.information_status,'typeId',asset.asset_type_id,'typeName',asset_type.name,'parentId',asset.parent_id,'operationalAreaId',asset.operational_area_id,'operatorCompanyId',asset.operator_company_id) AS asset,
|
||||
JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'title',visit.title,'status',visit.status) AS visit,
|
||||
JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'status',visit.status) AS visit,
|
||||
JSONB_BUILD_OBJECT('id',creator.id,'username',creator.username,'firstName',creator.first_name,'lastName',creator.last_name) AS creator,
|
||||
CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',reviewer.id,'username',reviewer.username,'firstName',reviewer.first_name,'lastName',reviewer.last_name) END AS reviewer,
|
||||
CASE WHEN matched.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',matched.id,'code',matched.code,'name',matched.name,'commonName',matched.common_name) END AS "matchedAsset"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { InventoryFunctionService } from './inventory-function.service';
|
||||
import { InventoryMergeService } from './inventory-merge.service';
|
||||
|
||||
type LooseRecord = Record<string, any>;
|
||||
@@ -29,6 +30,7 @@ export class MergedInventoryDossierService {
|
||||
constructor(
|
||||
private readonly assets: AssetsService,
|
||||
private readonly merges: InventoryMergeService,
|
||||
private readonly functions: InventoryFunctionService,
|
||||
) {}
|
||||
|
||||
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
|
||||
@@ -44,7 +46,8 @@ export class MergedInventoryDossierService {
|
||||
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
|
||||
const dossier = await this.assets.dossier(assetId) as LooseRecord;
|
||||
const identity = dossier.asset as LooseRecord;
|
||||
return { assetId, identity, dossier };
|
||||
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
|
||||
return { assetId, identity, dossier, functionDossier };
|
||||
}));
|
||||
|
||||
const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({
|
||||
@@ -75,6 +78,15 @@ export class MergedInventoryDossierService {
|
||||
const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']);
|
||||
const versions = sortDesc(collect('versions'), ['occurredAt']);
|
||||
|
||||
const functionHistory = sortDesc(
|
||||
dossiers.flatMap(({ identity, functionDossier }) =>
|
||||
(((functionDossier?.history ?? []) as LooseRecord[]).map((entry) => enrich(entry, identity))),
|
||||
),
|
||||
['validFrom', 'createdAt'],
|
||||
);
|
||||
const canonicalFunctionDossier = dossiers.find((item) => item.assetId === canonical.id)?.functionDossier ?? null;
|
||||
const currentFunction = canonicalFunctionDossier?.currentFunction ?? null;
|
||||
|
||||
const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) =>
|
||||
((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({
|
||||
...event,
|
||||
@@ -91,6 +103,25 @@ export class MergedInventoryDossierService {
|
||||
);
|
||||
const timeline: LooseRecord[] = dedupeById<LooseRecord>(timelineSource);
|
||||
|
||||
for (const assignment of functionHistory) {
|
||||
timeline.push({
|
||||
id: `function:${String(assignment.id)}`,
|
||||
kind: 'FUNCTION_CHANGED',
|
||||
occurredAt: assignment.validFrom,
|
||||
title: `Cambio de función · ${String(assignment.functionName)}`,
|
||||
description: assignment.reason ?? null,
|
||||
meta: {
|
||||
functionId: assignment.functionId,
|
||||
functionCode: assignment.functionCode,
|
||||
functionName: assignment.functionName,
|
||||
validFrom: assignment.validFrom,
|
||||
validUntil: assignment.validUntil,
|
||||
changedByUsername: assignment.changedByUsername,
|
||||
historicalInventory: assignment.historicalInventory,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const alias of aliases) {
|
||||
timeline.push({
|
||||
id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`,
|
||||
@@ -121,6 +152,7 @@ export class MergedInventoryDossierService {
|
||||
code: canonical.code,
|
||||
name: canonical.name,
|
||||
commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null,
|
||||
currentFunction,
|
||||
},
|
||||
requestedAsset: {
|
||||
id: requested.id,
|
||||
@@ -146,7 +178,10 @@ export class MergedInventoryDossierService {
|
||||
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
|
||||
photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length,
|
||||
reports: reports.length + inspectionReports.length,
|
||||
functionChanges: functionHistory.length,
|
||||
},
|
||||
currentFunction,
|
||||
functionHistory,
|
||||
visits,
|
||||
acts,
|
||||
findings,
|
||||
|
||||
@@ -19,12 +19,12 @@ interface DashboardCountsRow {
|
||||
inactiveUsers: number | string;
|
||||
activeSessions: number | string;
|
||||
openFindings: number | string;
|
||||
awaitingCompanyResponse: number | string;
|
||||
overdueCompanyResponses: number | string;
|
||||
companyResponsesDueNext7Days: number | string;
|
||||
awaitingVerificationSchedule: number | string;
|
||||
findingsWithoutControlDate: number | string;
|
||||
overdueControls: number | string;
|
||||
controlsNext30Days: number | string;
|
||||
reportsWorking: number | string;
|
||||
reportsOfficialized: number | string;
|
||||
sealedActsWithoutReport: number | string;
|
||||
}
|
||||
|
||||
export interface DashboardUpcomingControl {
|
||||
@@ -65,43 +65,54 @@ export class DashboardService {
|
||||
SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN'
|
||||
) AS "openFindings",
|
||||
(
|
||||
SELECT COUNT(*) FROM inspection_findings
|
||||
WHERE status = 'OPEN' AND company_response_received_on IS NULL
|
||||
) AS "awaitingCompanyResponse",
|
||||
SELECT COUNT(*)
|
||||
FROM inspection_findings finding
|
||||
WHERE finding.status = 'OPEN'
|
||||
AND finding.next_control_on IS NULL
|
||||
AND COALESCE((
|
||||
SELECT verification.outcome
|
||||
FROM inspection_finding_verification_visits verification
|
||||
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
|
||||
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
|
||||
LIMIT 1
|
||||
), '') <> 'RESOLVED'
|
||||
) AS "findingsWithoutControlDate",
|
||||
(
|
||||
SELECT COUNT(*) FROM inspection_findings
|
||||
WHERE status = 'OPEN'
|
||||
AND company_response_received_on IS NULL
|
||||
AND correction_due_on IS NOT NULL
|
||||
AND correction_due_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
||||
) AS "overdueCompanyResponses",
|
||||
(
|
||||
SELECT COUNT(*) FROM inspection_findings
|
||||
WHERE status = 'OPEN'
|
||||
AND company_response_received_on IS NULL
|
||||
AND correction_due_on BETWEEN
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
||||
AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 7
|
||||
) AS "companyResponsesDueNext7Days",
|
||||
(
|
||||
SELECT COUNT(*) FROM inspection_findings
|
||||
WHERE status = 'OPEN'
|
||||
AND company_response_received_on IS NOT NULL
|
||||
AND next_control_on IS NULL
|
||||
) AS "awaitingVerificationSchedule",
|
||||
(
|
||||
SELECT COUNT(*) FROM inspection_findings
|
||||
WHERE status = 'OPEN'
|
||||
AND company_response_received_on IS NOT NULL
|
||||
AND next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
||||
SELECT COUNT(*)
|
||||
FROM inspection_findings finding
|
||||
WHERE finding.status = 'OPEN'
|
||||
AND finding.next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
||||
AND COALESCE((
|
||||
SELECT verification.outcome
|
||||
FROM inspection_finding_verification_visits verification
|
||||
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
|
||||
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
|
||||
LIMIT 1
|
||||
), '') <> 'RESOLVED'
|
||||
) AS "overdueControls",
|
||||
(
|
||||
SELECT COUNT(*) FROM inspection_findings
|
||||
WHERE status = 'OPEN'
|
||||
AND next_control_on BETWEEN
|
||||
SELECT COUNT(*)
|
||||
FROM inspection_findings finding
|
||||
WHERE finding.status = 'OPEN'
|
||||
AND finding.next_control_on BETWEEN
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
||||
AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 30
|
||||
) AS "controlsNext30Days"
|
||||
AND COALESCE((
|
||||
SELECT verification.outcome
|
||||
FROM inspection_finding_verification_visits verification
|
||||
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
|
||||
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
|
||||
LIMIT 1
|
||||
), '') <> 'RESOLVED'
|
||||
) AS "controlsNext30Days",
|
||||
(SELECT COUNT(*) FROM inspection_reports WHERE status='WORKING') AS "reportsWorking",
|
||||
(SELECT COUNT(*) FROM inspection_reports WHERE status='OFFICIALIZED') AS "reportsOfficialized",
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM inspection_acts act
|
||||
WHERE act.status='SEALED'
|
||||
AND NOT EXISTS (SELECT 1 FROM inspection_reports report WHERE report.act_id=act.id)
|
||||
) AS "sealedActsWithoutReport"
|
||||
`)) as DashboardCountsRow[];
|
||||
|
||||
const recentAudit = (await manager.query(`
|
||||
@@ -135,8 +146,14 @@ export class DashboardService {
|
||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
INNER JOIN assets asset ON asset.id = finding.asset_id
|
||||
WHERE finding.status = 'OPEN'
|
||||
AND finding.company_response_received_on IS NOT NULL
|
||||
AND finding.next_control_on IS NOT NULL
|
||||
AND COALESCE((
|
||||
SELECT verification.outcome
|
||||
FROM inspection_finding_verification_visits verification
|
||||
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
|
||||
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
|
||||
LIMIT 1
|
||||
), '') <> 'RESOLVED'
|
||||
ORDER BY finding.next_control_on, finding.code
|
||||
LIMIT 8
|
||||
`)) as DashboardUpcomingControl[];
|
||||
@@ -151,12 +168,12 @@ export class DashboardService {
|
||||
inactiveUsers: Number(counts?.inactiveUsers ?? 0),
|
||||
activeSessions: Number(counts?.activeSessions ?? 0),
|
||||
openFindings: Number(counts?.openFindings ?? 0),
|
||||
awaitingCompanyResponse: Number(counts?.awaitingCompanyResponse ?? 0),
|
||||
overdueCompanyResponses: Number(counts?.overdueCompanyResponses ?? 0),
|
||||
companyResponsesDueNext7Days: Number(counts?.companyResponsesDueNext7Days ?? 0),
|
||||
awaitingVerificationSchedule: Number(counts?.awaitingVerificationSchedule ?? 0),
|
||||
findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0),
|
||||
overdueControls: Number(counts?.overdueControls ?? 0),
|
||||
controlsNext30Days: Number(counts?.controlsNext30Days ?? 0),
|
||||
reportsWorking: Number(counts?.reportsWorking ?? 0),
|
||||
reportsOfficialized: Number(counts?.reportsOfficialized ?? 0),
|
||||
sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0),
|
||||
},
|
||||
recentAudit,
|
||||
upcomingControls,
|
||||
|
||||
@@ -5,6 +5,7 @@ export enum AssetVersionChangeType {
|
||||
CREATED = 'CREATED',
|
||||
UPDATED = 'UPDATED',
|
||||
CONTEXT_CHANGED = 'CONTEXT_CHANGED',
|
||||
FUNCTION_CHANGED = 'FUNCTION_CHANGED',
|
||||
STATUS_CHANGED = 'STATUS_CHANGED',
|
||||
OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED',
|
||||
REGISTRY_UPDATED = 'REGISTRY_UPDATED',
|
||||
|
||||
@@ -35,6 +35,9 @@ export enum AuditAction {
|
||||
ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED',
|
||||
ASSET_UPDATED = 'ASSET_UPDATED',
|
||||
ASSET_CONTEXT_CHANGED = 'ASSET_CONTEXT_CHANGED',
|
||||
ASSET_FUNCTION_CHANGED = 'ASSET_FUNCTION_CHANGED',
|
||||
INVENTORY_FUNCTION_CREATED = 'INVENTORY_FUNCTION_CREATED',
|
||||
INVENTORY_FUNCTION_UPDATED = 'INVENTORY_FUNCTION_UPDATED',
|
||||
ASSET_INFORMATION_STATUS_CHANGED = 'ASSET_INFORMATION_STATUS_CHANGED',
|
||||
ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED',
|
||||
ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED',
|
||||
@@ -82,15 +85,24 @@ export enum AuditAction {
|
||||
INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED',
|
||||
INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED',
|
||||
INSPECTION_ACT_RESPONSIBLE_UPDATED = 'INSPECTION_ACT_RESPONSIBLE_UPDATED',
|
||||
INSPECTION_ACT_LOCKED = 'INSPECTION_ACT_LOCKED',
|
||||
INSPECTION_ACT_SEALED = 'INSPECTION_ACT_SEALED',
|
||||
INSPECTION_ACT_READY = 'INSPECTION_ACT_READY',
|
||||
INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED',
|
||||
INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED',
|
||||
INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED',
|
||||
INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED',
|
||||
INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED',
|
||||
INSPECTION_REPORT_UPDATED = 'INSPECTION_REPORT_UPDATED',
|
||||
INSPECTION_REPORT_REVISION_ADDED = 'INSPECTION_REPORT_REVISION_ADDED',
|
||||
INSPECTION_REPORT_OFFICIALIZED = 'INSPECTION_REPORT_OFFICIALIZED',
|
||||
INSPECTION_REPORT_FOLLOW_UP_ADDED = 'INSPECTION_REPORT_FOLLOW_UP_ADDED',
|
||||
INSPECTION_REPORT_APPROVED = 'INSPECTION_REPORT_APPROVED',
|
||||
INSPECTION_REPORT_SIGNED = 'INSPECTION_REPORT_SIGNED',
|
||||
INSPECTION_DEADLINE_POLICY_UPDATED = 'INSPECTION_DEADLINE_POLICY_UPDATED',
|
||||
INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED',
|
||||
SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED',
|
||||
SMTP_TEST_SENT = 'SMTP_TEST_SENT',
|
||||
DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED',
|
||||
DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
|
||||
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
|
||||
|
||||
@@ -25,17 +25,38 @@ export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entit
|
||||
export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
|
||||
export { InspectionVisitMember } from './inspection-visit-member.entity';
|
||||
export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity';
|
||||
export { InspectionAct, InspectionActStatus, InspectionActUrgency, InspectionDeadlineBasis, InspectionDeadlineDayType } from './inspection-act.entity';
|
||||
export {
|
||||
InspectionAct,
|
||||
InspectionActStatus,
|
||||
InspectionActUrgency,
|
||||
InspectionDeadlineBasis,
|
||||
InspectionDeadlineDayType,
|
||||
} from './inspection-act.entity';
|
||||
export { InspectionActAsset } from './inspection-act-asset.entity';
|
||||
export { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
|
||||
export { InspectionNonWorkingDay } from './inspection-non-working-day.entity';
|
||||
export { InspectionReport, InspectionReportPdfStatus, InspectionReportReviewStatus, InspectionReportStatus, InspectionReportWordStatus } from './inspection-report.entity';
|
||||
export { InspectionReportFollowUp, InspectionReportFollowUpType } from './inspection-report-follow-up.entity';
|
||||
export { InspectionReportFollowUpFile } from './inspection-report-follow-up-file.entity';
|
||||
export {
|
||||
InspectionReport,
|
||||
InspectionReportPdfStatus,
|
||||
InspectionReportStatus,
|
||||
InspectionReportWordStatus,
|
||||
} from './inspection-report.entity';
|
||||
export { InspectionActVersion, InspectionActVersionEvent } from './inspection-act-version.entity';
|
||||
export { InspectionActResponsible, InspectionResponsibleAttendanceStatus, InspectionResponsibleDocumentType } from './inspection-act-responsible.entity';
|
||||
export {
|
||||
InspectionActResponsible,
|
||||
InspectionResponsibleAttendanceStatus,
|
||||
InspectionResponsibleDocumentType,
|
||||
} from './inspection-act-responsible.entity';
|
||||
export { InspectionActClosure, InspectionActUploadMode } from './inspection-act-closure.entity';
|
||||
export { InspectionActSignature, InspectionActSignatureSource, InspectionActSignatureStatus, InspectionActSignerType, InspectionCompanySignatureManifestation } from './inspection-act-signature.entity';
|
||||
export {
|
||||
InspectionActSignature,
|
||||
InspectionActSignatureSource,
|
||||
InspectionActSignatureStatus,
|
||||
InspectionActSignerType,
|
||||
InspectionCompanySignatureManifestation,
|
||||
} from './inspection-act-signature.entity';
|
||||
export { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
|
||||
export { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity';
|
||||
export { InspectionReportFollowUp, InspectionReportFollowUpType } from './inspection-report-follow-up.entity';
|
||||
export { SystemSmtpSettings, SmtpSecurityMode } from './system-smtp-settings.entity';
|
||||
export { FindingCategory } from './finding-category.entity';
|
||||
export { FindingCatalogItem } from './finding-catalog-item.entity';
|
||||
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
|
||||
@@ -44,8 +65,18 @@ export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.en
|
||||
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
|
||||
export { InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus } from './inspection-finding.entity';
|
||||
export { InspectionFindingVersion, InspectionFindingVersionEvent } from './inspection-finding-version.entity';
|
||||
export { InspectionCommunicationChannel, InspectionCommunicationDirection, InspectionCommunicationType, InspectionFindingCommunication } from './inspection-finding-communication.entity';
|
||||
export { InspectionEvidenceKind, InspectionEvidencePurpose, InspectionEvidenceSource, InspectionFindingEvidence } from './inspection-finding-evidence.entity';
|
||||
export {
|
||||
InspectionCommunicationChannel,
|
||||
InspectionCommunicationDirection,
|
||||
InspectionCommunicationType,
|
||||
InspectionFindingCommunication,
|
||||
} from './inspection-finding-communication.entity';
|
||||
export {
|
||||
InspectionEvidenceKind,
|
||||
InspectionEvidencePurpose,
|
||||
InspectionEvidenceSource,
|
||||
InspectionFindingEvidence,
|
||||
} from './inspection-finding-evidence.entity';
|
||||
export { InspectionFindingVerificationVisit, InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity';
|
||||
export { InspectionFindingVerificationEvent, InspectionFindingVerificationEventType } from './inspection-finding-verification-event.entity';
|
||||
|
||||
@@ -78,15 +109,15 @@ import { InspectionVisitMember } from './inspection-visit-member.entity';
|
||||
import { DocumentAnnualSequence } from './document-annual-sequence.entity';
|
||||
import { InspectionAct } from './inspection-act.entity';
|
||||
import { InspectionActAsset } from './inspection-act-asset.entity';
|
||||
import { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
|
||||
import { InspectionNonWorkingDay } from './inspection-non-working-day.entity';
|
||||
import { InspectionReport } from './inspection-report.entity';
|
||||
import { InspectionReportFollowUp } from './inspection-report-follow-up.entity';
|
||||
import { InspectionReportFollowUpFile } from './inspection-report-follow-up-file.entity';
|
||||
import { InspectionActVersion } from './inspection-act-version.entity';
|
||||
import { InspectionActResponsible } from './inspection-act-responsible.entity';
|
||||
import { InspectionActClosure } from './inspection-act-closure.entity';
|
||||
import { InspectionActSignature } from './inspection-act-signature.entity';
|
||||
import { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
|
||||
import { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity';
|
||||
import { InspectionReportFollowUp } from './inspection-report-follow-up.entity';
|
||||
import { SystemSmtpSettings } from './system-smtp-settings.entity';
|
||||
import { FindingCategory } from './finding-category.entity';
|
||||
import { FindingCatalogItem } from './finding-catalog-item.entity';
|
||||
import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
|
||||
@@ -101,17 +132,54 @@ import { InspectionFindingVerificationVisit } from './inspection-finding-verific
|
||||
import { InspectionFindingVerificationEvent } from './inspection-finding-verification-event.entity';
|
||||
|
||||
export const PHASE_A_ENTITIES = [
|
||||
User, Role, Permission, UserRole, RolePermission, AuthSession, AuditEvent,
|
||||
AssetType, AssetTypeParentRule, AssetAttributeDefinition, Asset, AssetAttributeValue,
|
||||
AreaCompanyRelation, OrganizationProfile, OrganizationMembership, SourceDocument,
|
||||
AssetSourceDocument, AssetExternalIdentifier, AreaLegalRight, AreaLegalRightOrganization,
|
||||
AssetGeometry, AssetVersion, AssetMedia, InspectionVisit, InspectionVisitAsset,
|
||||
InspectionVisitMember, DocumentAnnualSequence, InspectionAct, InspectionActAsset,
|
||||
InspectionDeadlinePolicy, InspectionNonWorkingDay, InspectionReport,
|
||||
InspectionReportFollowUp, InspectionReportFollowUpFile, InspectionActVersion,
|
||||
InspectionActResponsible, InspectionActClosure, InspectionActSignature, FindingCategory,
|
||||
FindingCatalogItem, FindingCatalogItemAssetType, FindingCatalogAssetTypeProfile,
|
||||
FindingCatalogAssetOverride, FindingCatalogProposal, InspectionFinding,
|
||||
InspectionFindingVersion, InspectionFindingCommunication, InspectionFindingEvidence,
|
||||
InspectionFindingVerificationVisit, InspectionFindingVerificationEvent,
|
||||
User,
|
||||
Role,
|
||||
Permission,
|
||||
UserRole,
|
||||
RolePermission,
|
||||
AuthSession,
|
||||
AuditEvent,
|
||||
AssetType,
|
||||
AssetTypeParentRule,
|
||||
AssetAttributeDefinition,
|
||||
Asset,
|
||||
AssetAttributeValue,
|
||||
AreaCompanyRelation,
|
||||
OrganizationProfile,
|
||||
OrganizationMembership,
|
||||
SourceDocument,
|
||||
AssetSourceDocument,
|
||||
AssetExternalIdentifier,
|
||||
AreaLegalRight,
|
||||
AreaLegalRightOrganization,
|
||||
AssetGeometry,
|
||||
AssetVersion,
|
||||
AssetMedia,
|
||||
InspectionVisit,
|
||||
InspectionVisitAsset,
|
||||
InspectionVisitMember,
|
||||
DocumentAnnualSequence,
|
||||
InspectionAct,
|
||||
InspectionActAsset,
|
||||
InspectionReport,
|
||||
InspectionActVersion,
|
||||
InspectionActResponsible,
|
||||
InspectionActClosure,
|
||||
InspectionActSignature,
|
||||
InspectionDeadlinePolicy,
|
||||
InspectionBusinessCalendarDay,
|
||||
InspectionReportFollowUp,
|
||||
SystemSmtpSettings,
|
||||
FindingCategory,
|
||||
FindingCatalogItem,
|
||||
FindingCatalogItemAssetType,
|
||||
FindingCatalogAssetTypeProfile,
|
||||
FindingCatalogAssetOverride,
|
||||
FindingCatalogProposal,
|
||||
InspectionFinding,
|
||||
InspectionFindingVersion,
|
||||
InspectionFindingCommunication,
|
||||
InspectionFindingEvidence,
|
||||
InspectionFindingVerificationVisit,
|
||||
InspectionFindingVerificationEvent,
|
||||
];
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
export enum InspectionActVersionEvent {
|
||||
CREATED = 'CREATED',
|
||||
UPDATED = 'UPDATED',
|
||||
LOCKED = 'LOCKED',
|
||||
SEALED = 'SEALED',
|
||||
READY = 'READY',
|
||||
REOPENED = 'REOPENED',
|
||||
CLOSED = 'CLOSED',
|
||||
|
||||
@@ -3,6 +3,8 @@ import { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
export enum InspectionActStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
LOCKED = 'LOCKED',
|
||||
SEALED = 'SEALED',
|
||||
READY = 'READY',
|
||||
CLOSED = 'CLOSED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
@@ -21,7 +23,11 @@ export enum InspectionDeadlineDayType {
|
||||
|
||||
export enum InspectionDeadlineBasis {
|
||||
ACT_DATE = 'ACT_DATE',
|
||||
GEDO_LOAD_DATE = 'GEDO_LOAD_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' })
|
||||
@@ -31,6 +37,7 @@ export enum InspectionDeadlineBasis {
|
||||
@Index('idx_inspection_acts_visit_status', ['visitId', 'status'])
|
||||
@Index('idx_inspection_acts_occurred_at', ['occurredAt'])
|
||||
@Index('idx_inspection_acts_created_by', ['createdBy'])
|
||||
@Index('idx_inspection_acts_deadline', ['deadlineAt'])
|
||||
export class InspectionAct extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -44,7 +51,7 @@ export class InspectionAct extends TimestampedEntity {
|
||||
@Column({ name: 'act_number', type: 'integer' })
|
||||
actNumber!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 24 })
|
||||
@Column({ type: 'varchar', length: 40 })
|
||||
code!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT })
|
||||
@@ -62,26 +69,23 @@ export class InspectionAct extends TimestampedEntity {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
observations!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
urgency!: InspectionActUrgency | 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: 20, nullable: true })
|
||||
@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_on', type: 'date', nullable: true })
|
||||
deadlineBaseOn!: string | null;
|
||||
@Column({ name: 'deadline_base_at', type: 'timestamptz', nullable: true })
|
||||
deadlineBaseAt!: Date | null;
|
||||
|
||||
@Column({ name: 'deadline_due_on', type: 'date', nullable: true })
|
||||
deadlineDueOn!: string | null;
|
||||
|
||||
@Column({ name: 'deadline_policy_snapshot', type: 'jsonb', nullable: true })
|
||||
deadlinePolicySnapshot!: Record<string, unknown> | null;
|
||||
@Column({ name: 'deadline_at', type: 'timestamptz', nullable: true })
|
||||
deadlineAt!: Date | null;
|
||||
|
||||
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
|
||||
lockedAt!: Date | null;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,24 +1,23 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { InspectionDeadlineDayType } from './inspection-act.entity';
|
||||
import { TimestampedEntity } from './timestamped.entity';
|
||||
import {
|
||||
InspectionActUrgency,
|
||||
InspectionDeadlineBasis,
|
||||
InspectionDeadlineDayType,
|
||||
} from './inspection-act.entity';
|
||||
|
||||
@Entity({ name: 'inspection_deadline_policies' })
|
||||
export class InspectionDeadlinePolicy extends TimestampedEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 20 })
|
||||
urgency!: InspectionActUrgency;
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
days!: number;
|
||||
@Column({ name: 'urgent_days', type: 'integer', default: 5 })
|
||||
urgentDays!: number;
|
||||
|
||||
@Column({ name: 'day_type', type: 'varchar', length: 20 })
|
||||
dayType!: InspectionDeadlineDayType;
|
||||
@Column({ name: 'urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS })
|
||||
urgentDayType!: InspectionDeadlineDayType;
|
||||
|
||||
@Column({ type: 'varchar', length: 24 })
|
||||
basis!: InspectionDeadlineBasis;
|
||||
@Column({ name: 'non_urgent_days', type: 'integer', default: 10 })
|
||||
nonUrgentDays!: number;
|
||||
|
||||
@Column({ name: 'non_urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS })
|
||||
nonUrgentDayType!: InspectionDeadlineDayType;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy!: string | null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
/** @deprecated Los plazos de respuesta pasan al nivel Acta/Informe en F4. */
|
||||
export enum InspectionFindingResponseDueBasis {
|
||||
FINDING_DATE = 'FINDING_DATE',
|
||||
REPORT_NOTIFICATION = 'REPORT_NOTIFICATION',
|
||||
@@ -18,7 +19,7 @@ export enum InspectionFindingStatus {
|
||||
@Index('idx_inspection_findings_status_control', ['status', 'nextControlOn'])
|
||||
@Index('idx_inspection_findings_asset_status', ['assetId', 'status'])
|
||||
@Index('idx_inspection_findings_catalog_item', ['catalogItemId'])
|
||||
@Index('idx_inspection_findings_antecedent', ['antecedentFindingId'])
|
||||
@Index('idx_inspection_findings_recurrence_of', ['recurrenceOfFindingId'])
|
||||
export class InspectionFinding extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -65,12 +66,13 @@ export class InspectionFinding extends TimestampedEntity {
|
||||
@Column({ name: 'is_recurrence', type: 'boolean', default: false })
|
||||
isRecurrence!: boolean;
|
||||
|
||||
@Column({ name: 'antecedent_finding_id', type: 'uuid', nullable: true })
|
||||
antecedentFindingId!: string | null;
|
||||
@Column({ name: 'recurrence_of_finding_id', type: 'uuid', nullable: true })
|
||||
recurrenceOfFindingId!: string | null;
|
||||
|
||||
@Column({ name: 'correction_due_on', type: 'date', nullable: true })
|
||||
correctionDueOn!: string | null;
|
||||
|
||||
// Campos legacy conservados temporalmente para migrar datos sin pérdida.
|
||||
@Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true })
|
||||
responseDueBasis!: InspectionFindingResponseDueBasis | null;
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
import { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
@Entity({ name: 'inspection_non_working_days' })
|
||||
export class InspectionNonWorkingDay extends TimestampedEntity {
|
||||
@PrimaryColumn({ type: 'date' })
|
||||
day!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
label!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled!: boolean;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy!: string | null;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy!: string | null;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
@Entity({ name: 'inspection_report_follow_up_files' })
|
||||
@Index('idx_inspection_report_follow_up_files_follow_up', ['followUpId'])
|
||||
export class InspectionReportFollowUpFile extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'follow_up_id', type: 'uuid' })
|
||||
followUpId!: string;
|
||||
|
||||
@Column({ name: 'original_name', type: 'varchar', length: 255 })
|
||||
originalName!: string;
|
||||
|
||||
@Column({ name: 'stored_name', type: 'varchar', length: 255 })
|
||||
storedName!: string;
|
||||
|
||||
@Column({ name: 'mime_type', type: 'varchar', length: 120 })
|
||||
mimeType!: string;
|
||||
|
||||
@Column({ name: 'size_bytes', type: 'integer' })
|
||||
sizeBytes!: number;
|
||||
|
||||
@Column({ type: 'char', length: 64 })
|
||||
sha256!: string;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy!: string | null;
|
||||
}
|
||||
@@ -10,8 +10,7 @@ export enum InspectionReportFollowUpType {
|
||||
}
|
||||
|
||||
@Entity({ name: 'inspection_report_follow_ups' })
|
||||
@Index('idx_inspection_report_follow_ups_report_occurred', ['reportId', 'occurredOn'])
|
||||
@Index('idx_inspection_report_follow_ups_type', ['eventType'])
|
||||
@Index('idx_inspection_report_follow_ups_report_created', ['reportId', 'createdAt'])
|
||||
export class InspectionReportFollowUp extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -19,18 +18,33 @@ export class InspectionReportFollowUp extends TimestampedEntity {
|
||||
@Column({ name: 'report_id', type: 'uuid' })
|
||||
reportId!: string;
|
||||
|
||||
@Column({ name: 'event_type', type: 'varchar', length: 32 })
|
||||
eventType!: InspectionReportFollowUpType;
|
||||
@Column({ type: 'varchar', length: 40 })
|
||||
type!: InspectionReportFollowUpType;
|
||||
|
||||
@Column({ name: 'reference_number', type: 'varchar', length: 255, nullable: true })
|
||||
referenceNumber!: string | null;
|
||||
@Column({ name: 'external_reference', type: 'varchar', length: 255, nullable: true })
|
||||
externalReference!: string | null;
|
||||
|
||||
@Column({ name: 'occurred_on', type: 'date' })
|
||||
occurredOn!: string;
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true })
|
||||
originalName!: string | null;
|
||||
|
||||
@Column({ name: 'stored_name', type: 'varchar', length: 255, nullable: true })
|
||||
storedName!: string | null;
|
||||
|
||||
@Column({ name: 'mime_type', type: 'varchar', length: 120, nullable: true })
|
||||
mimeType!: string | null;
|
||||
|
||||
@Column({ name: 'size_bytes', type: 'integer', nullable: true })
|
||||
sizeBytes!: number | null;
|
||||
|
||||
@Column({ name: 'sha256', type: 'char', length: 64, nullable: true })
|
||||
sha256!: string | null;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy!: string | null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
export enum InspectionReportStatus {
|
||||
WORKING = 'WORKING',
|
||||
OFFICIALIZED = 'OFFICIALIZED',
|
||||
FROZEN = 'FROZEN',
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
@@ -18,12 +20,6 @@ export enum InspectionReportWordStatus {
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
export enum InspectionReportReviewStatus {
|
||||
PENDING_REVIEW = 'PENDING_REVIEW',
|
||||
APPROVED = 'APPROVED',
|
||||
SIGNED = 'SIGNED',
|
||||
}
|
||||
|
||||
@Entity({ name: 'inspection_reports' })
|
||||
@Index('idx_inspection_reports_visit_id', ['visitId'])
|
||||
@Index('uq_inspection_reports_act', ['actId'], { unique: true })
|
||||
@@ -31,7 +27,7 @@ export enum InspectionReportReviewStatus {
|
||||
@Index('uq_inspection_reports_code', ['code'], { unique: true })
|
||||
@Index('idx_inspection_reports_generated_at', ['generatedAt'])
|
||||
@Index('idx_inspection_reports_status', ['status', 'pdfStatus'])
|
||||
@Index('idx_inspection_reports_gedo_if_identifier', ['gedoIfIdentifier'])
|
||||
@Index('idx_inspection_reports_gedo_officialized_at', ['gedoOfficializedAt'])
|
||||
export class InspectionReport extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -48,12 +44,39 @@ export class InspectionReport extends TimestampedEntity {
|
||||
@Column({ name: 'report_number', type: 'integer' })
|
||||
reportNumber!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 24 })
|
||||
@Column({ type: 'varchar', length: 40 })
|
||||
code!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 24, default: InspectionReportStatus.FROZEN })
|
||||
@Column({ type: 'varchar', length: 24, default: InspectionReportStatus.WORKING })
|
||||
status!: InspectionReportStatus;
|
||||
|
||||
@Column({ name: 'executive_summary', type: 'text', nullable: true })
|
||||
executiveSummary!: string | null;
|
||||
|
||||
@Column({ name: 'report_description', type: 'text', nullable: true })
|
||||
reportDescription!: string | null;
|
||||
|
||||
@Column({ name: 'gedo_if_identifier', type: 'varchar', length: 255, nullable: true })
|
||||
gedoIfIdentifier!: string | null;
|
||||
|
||||
@Column({ name: 'gedo_officialized_at', type: 'timestamptz', nullable: true })
|
||||
gedoOfficializedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'gedo_pdf_original_name', type: 'varchar', length: 255, nullable: true })
|
||||
gedoPdfOriginalName!: string | null;
|
||||
|
||||
@Column({ name: 'gedo_pdf_stored_name', type: 'varchar', length: 255, nullable: true })
|
||||
gedoPdfStoredName!: string | null;
|
||||
|
||||
@Column({ name: 'gedo_pdf_mime_type', type: 'varchar', length: 120, nullable: true })
|
||||
gedoPdfMimeType!: string | null;
|
||||
|
||||
@Column({ name: 'gedo_pdf_size_bytes', type: 'integer', nullable: true })
|
||||
gedoPdfSizeBytes!: number | null;
|
||||
|
||||
@Column({ name: 'gedo_pdf_sha256', type: 'char', length: 64, nullable: true })
|
||||
gedoPdfSha256!: string | null;
|
||||
|
||||
@Column({ name: 'pdf_status', type: 'varchar', length: 24, default: InspectionReportPdfStatus.PENDING })
|
||||
pdfStatus!: InspectionReportPdfStatus;
|
||||
|
||||
@@ -81,78 +104,9 @@ export class InspectionReport extends TimestampedEntity {
|
||||
@Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true })
|
||||
wordError!: string | null;
|
||||
|
||||
@Column({ name: 'reference_text', type: 'text', nullable: true })
|
||||
referenceText!: string | null;
|
||||
|
||||
@Column({ name: 'general_objective', type: 'text', nullable: true })
|
||||
generalObjective!: string | null;
|
||||
|
||||
@Column({ name: 'specific_objective', type: 'text', nullable: true })
|
||||
specificObjective!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
background!: string | null;
|
||||
|
||||
@Column({ name: 'legal_framework', type: 'text', nullable: true })
|
||||
legalFramework!: string | null;
|
||||
|
||||
@Column({ name: 'executive_summary', type: 'text', nullable: true })
|
||||
executiveSummary!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
conclusion!: string | null;
|
||||
|
||||
@Column({ name: 'gedo_if_identifier', type: 'varchar', length: 255, nullable: true })
|
||||
gedoIfIdentifier!: string | null;
|
||||
|
||||
@Column({ name: 'gedo_officialized_on', type: 'date', nullable: true })
|
||||
gedoOfficializedOn!: string | 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: 'gedo_pdf_uploaded_at', type: 'timestamptz', nullable: true })
|
||||
gedoPdfUploadedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'gedo_pdf_uploaded_by', type: 'uuid', nullable: true })
|
||||
gedoPdfUploadedBy!: string | null;
|
||||
|
||||
@Column({ name: 'review_status', type: 'varchar', length: 32, default: InspectionReportReviewStatus.PENDING_REVIEW })
|
||||
reviewStatus!: InspectionReportReviewStatus;
|
||||
|
||||
@Column({ name: 'current_revision_number', type: 'integer', default: 0 })
|
||||
currentRevisionNumber!: number;
|
||||
|
||||
@Column({ name: 'approved_revision_id', type: 'uuid', nullable: true })
|
||||
approvedRevisionId!: string | null;
|
||||
|
||||
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
|
||||
approvedBy!: string | null;
|
||||
|
||||
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
||||
approvedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'review_note', type: 'varchar', length: 1000, nullable: true })
|
||||
reviewNote!: string | null;
|
||||
|
||||
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
|
||||
signedAt!: Date | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 220 })
|
||||
title!: string;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export enum InspectionVisitStatus {
|
||||
@Index('idx_inspection_visits_status', ['status'])
|
||||
@Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId'])
|
||||
@Index('idx_inspection_visits_lead_inspector_user_id', ['leadInspectorUserId'])
|
||||
@Index('idx_inspection_visits_planned_dates', ['plannedStartAt', 'plannedEndAt'])
|
||||
@Index('idx_inspection_visits_planned_start_at', ['plannedStartAt'])
|
||||
@Index('idx_inspection_visits_operational_context', ['operationalAreaId', 'operatorCompanyId'])
|
||||
export class InspectionVisit extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -23,9 +23,6 @@ export class InspectionVisit extends TimestampedEntity {
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
code!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
objective!: string | null;
|
||||
|
||||
@@ -47,9 +44,6 @@ export class InspectionVisit extends TimestampedEntity {
|
||||
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
|
||||
plannedStartAt!: Date | null;
|
||||
|
||||
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
|
||||
plannedEndAt!: Date | null;
|
||||
|
||||
@Column({ name: 'actual_started_at', type: 'timestamptz', nullable: true })
|
||||
actualStartedAt!: Date | null;
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
+274
@@ -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
|
||||
$$
|
||||
`);
|
||||
}
|
||||
}
|
||||
+44
@@ -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'
|
||||
))
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PhaseF4DocumentFlowFoundation1790035200000 implements MigrationInterface {
|
||||
name = 'PhaseF4DocumentFlowFoundation1790035200000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_acts
|
||||
ADD COLUMN urgency varchar(20),
|
||||
ADD COLUMN deadline_days integer,
|
||||
ADD COLUMN deadline_day_type varchar(20),
|
||||
ADD COLUMN deadline_basis varchar(24),
|
||||
ADD COLUMN deadline_base_on date,
|
||||
ADD COLUMN deadline_due_on date,
|
||||
ADD COLUMN deadline_policy_snapshot jsonb,
|
||||
ADD COLUMN locked_at timestamptz,
|
||||
ADD COLUMN locked_by uuid,
|
||||
ADD COLUMN locked_sha256 char(64),
|
||||
ADD COLUMN sealed_at timestamptz,
|
||||
ADD COLUMN sealed_by uuid
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_acts
|
||||
ADD CONSTRAINT chk_inspection_acts_urgency
|
||||
CHECK (urgency IS NULL OR urgency IN ('URGENT','NON_URGENT')),
|
||||
ADD CONSTRAINT chk_inspection_acts_deadline_days
|
||||
CHECK (deadline_days IS NULL OR deadline_days > 0),
|
||||
ADD CONSTRAINT chk_inspection_acts_deadline_day_type
|
||||
CHECK (deadline_day_type IS NULL OR deadline_day_type IN ('BUSINESS','CALENDAR')),
|
||||
ADD CONSTRAINT chk_inspection_acts_deadline_basis
|
||||
CHECK (deadline_basis IS NULL OR deadline_basis IN ('ACT_DATE','GEDO_LOAD_DATE')),
|
||||
ADD CONSTRAINT fk_inspection_acts_locked_by
|
||||
FOREIGN KEY (locked_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
ADD CONSTRAINT fk_inspection_acts_sealed_by
|
||||
FOREIGN KEY (sealed_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_inspection_acts_deadline_due_on
|
||||
ON inspection_acts(deadline_due_on)
|
||||
WHERE deadline_due_on IS NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE inspection_deadline_policies (
|
||||
urgency varchar(20) PRIMARY KEY,
|
||||
days integer NOT NULL,
|
||||
day_type varchar(20) NOT NULL,
|
||||
basis varchar(24) NOT NULL,
|
||||
updated_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_inspection_deadline_policies_urgency
|
||||
CHECK (urgency IN ('URGENT','NON_URGENT')),
|
||||
CONSTRAINT chk_inspection_deadline_policies_days
|
||||
CHECK (days > 0),
|
||||
CONSTRAINT chk_inspection_deadline_policies_day_type
|
||||
CHECK (day_type IN ('BUSINESS','CALENDAR')),
|
||||
CONSTRAINT chk_inspection_deadline_policies_basis
|
||||
CHECK (basis IN ('ACT_DATE','GEDO_LOAD_DATE')),
|
||||
CONSTRAINT fk_inspection_deadline_policies_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO inspection_deadline_policies (urgency, days, day_type, basis)
|
||||
VALUES
|
||||
('URGENT', 5, 'BUSINESS', 'ACT_DATE'),
|
||||
('NON_URGENT', 10, 'BUSINESS', 'GEDO_LOAD_DATE')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE inspection_non_working_days (
|
||||
day date PRIMARY KEY,
|
||||
label varchar(200) NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_by uuid,
|
||||
updated_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_inspection_non_working_days_label
|
||||
CHECK (LENGTH(TRIM(label)) > 0),
|
||||
CONSTRAINT fk_inspection_non_working_days_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_inspection_non_working_days_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_findings
|
||||
ADD COLUMN is_recurrence boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN antecedent_finding_id uuid
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_findings
|
||||
ADD CONSTRAINT fk_inspection_findings_antecedent
|
||||
FOREIGN KEY (antecedent_finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT,
|
||||
ADD CONSTRAINT chk_inspection_findings_recurrence_link
|
||||
CHECK (
|
||||
(is_recurrence = false AND antecedent_finding_id IS NULL)
|
||||
OR (is_recurrence = true AND antecedent_finding_id IS NOT NULL)
|
||||
),
|
||||
ADD CONSTRAINT chk_inspection_findings_not_self_antecedent
|
||||
CHECK (antecedent_finding_id IS NULL OR antecedent_finding_id <> id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_inspection_findings_antecedent
|
||||
ON inspection_findings(antecedent_finding_id)
|
||||
WHERE antecedent_finding_id IS NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_reports
|
||||
ADD COLUMN reference_text text,
|
||||
ADD COLUMN general_objective text,
|
||||
ADD COLUMN specific_objective text,
|
||||
ADD COLUMN background text,
|
||||
ADD COLUMN legal_framework text,
|
||||
ADD COLUMN executive_summary text,
|
||||
ADD COLUMN description text,
|
||||
ADD COLUMN conclusion text,
|
||||
ADD COLUMN gedo_if_identifier varchar(255),
|
||||
ADD COLUMN gedo_officialized_on date,
|
||||
ADD COLUMN gedo_pdf_original_name varchar(255),
|
||||
ADD COLUMN gedo_pdf_stored_name varchar(255),
|
||||
ADD COLUMN gedo_pdf_mime_type varchar(120),
|
||||
ADD COLUMN gedo_pdf_size_bytes integer,
|
||||
ADD COLUMN gedo_pdf_sha256 char(64),
|
||||
ADD COLUMN gedo_pdf_uploaded_at timestamptz,
|
||||
ADD COLUMN gedo_pdf_uploaded_by uuid
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_reports
|
||||
ADD CONSTRAINT chk_inspection_reports_gedo_pdf_size
|
||||
CHECK (gedo_pdf_size_bytes IS NULL OR gedo_pdf_size_bytes >= 0),
|
||||
ADD CONSTRAINT fk_inspection_reports_gedo_pdf_uploaded_by
|
||||
FOREIGN KEY (gedo_pdf_uploaded_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_inspection_reports_gedo_if_identifier
|
||||
ON inspection_reports(gedo_if_identifier)
|
||||
WHERE gedo_if_identifier IS NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE inspection_report_follow_ups (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
report_id uuid NOT NULL,
|
||||
event_type varchar(32) NOT NULL,
|
||||
reference_number varchar(255),
|
||||
occurred_on date NOT NULL,
|
||||
description text,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_inspection_report_follow_ups_type
|
||||
CHECK (event_type IN ('COMPANY_NOTE','COMPANY_DOCUMENT','INTERNAL_NOTE','VERIFICATION','OTHER')),
|
||||
CONSTRAINT fk_inspection_report_follow_ups_report
|
||||
FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_inspection_report_follow_ups_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_inspection_report_follow_ups_report_occurred
|
||||
ON inspection_report_follow_ups(report_id, occurred_on, created_at)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_inspection_report_follow_ups_type
|
||||
ON inspection_report_follow_ups(event_type)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE inspection_report_follow_up_files (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
follow_up_id uuid NOT NULL,
|
||||
original_name varchar(255) NOT NULL,
|
||||
stored_name varchar(255) NOT NULL,
|
||||
mime_type varchar(120) NOT NULL,
|
||||
size_bytes integer NOT NULL,
|
||||
sha256 char(64) NOT NULL,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_inspection_report_follow_up_files_size
|
||||
CHECK (size_bytes >= 0),
|
||||
CONSTRAINT fk_inspection_report_follow_up_files_follow_up
|
||||
FOREIGN KEY (follow_up_id) REFERENCES inspection_report_follow_ups(id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_inspection_report_follow_up_files_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_inspection_report_follow_up_files_follow_up
|
||||
ON inspection_report_follow_up_files(follow_up_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_up_files`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_ups`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_reports_gedo_if_identifier`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_reports
|
||||
DROP CONSTRAINT IF EXISTS fk_inspection_reports_gedo_pdf_uploaded_by,
|
||||
DROP CONSTRAINT IF EXISTS chk_inspection_reports_gedo_pdf_size,
|
||||
DROP COLUMN IF EXISTS gedo_pdf_uploaded_by,
|
||||
DROP COLUMN IF EXISTS gedo_pdf_uploaded_at,
|
||||
DROP COLUMN IF EXISTS gedo_pdf_sha256,
|
||||
DROP COLUMN IF EXISTS gedo_pdf_size_bytes,
|
||||
DROP COLUMN IF EXISTS gedo_pdf_mime_type,
|
||||
DROP COLUMN IF EXISTS gedo_pdf_stored_name,
|
||||
DROP COLUMN IF EXISTS gedo_pdf_original_name,
|
||||
DROP COLUMN IF EXISTS gedo_officialized_on,
|
||||
DROP COLUMN IF EXISTS gedo_if_identifier,
|
||||
DROP COLUMN IF EXISTS conclusion,
|
||||
DROP COLUMN IF EXISTS description,
|
||||
DROP COLUMN IF EXISTS executive_summary,
|
||||
DROP COLUMN IF EXISTS legal_framework,
|
||||
DROP COLUMN IF EXISTS background,
|
||||
DROP COLUMN IF EXISTS specific_objective,
|
||||
DROP COLUMN IF EXISTS general_objective,
|
||||
DROP COLUMN IF EXISTS reference_text
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_findings_antecedent`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_findings
|
||||
DROP CONSTRAINT IF EXISTS chk_inspection_findings_not_self_antecedent,
|
||||
DROP CONSTRAINT IF EXISTS chk_inspection_findings_recurrence_link,
|
||||
DROP CONSTRAINT IF EXISTS fk_inspection_findings_antecedent,
|
||||
DROP COLUMN IF EXISTS antecedent_finding_id,
|
||||
DROP COLUMN IF EXISTS is_recurrence
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS inspection_non_working_days`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS inspection_deadline_policies`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_acts_deadline_due_on`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inspection_acts
|
||||
DROP CONSTRAINT IF EXISTS fk_inspection_acts_sealed_by,
|
||||
DROP CONSTRAINT IF EXISTS fk_inspection_acts_locked_by,
|
||||
DROP CONSTRAINT IF EXISTS chk_inspection_acts_deadline_basis,
|
||||
DROP CONSTRAINT IF EXISTS chk_inspection_acts_deadline_day_type,
|
||||
DROP CONSTRAINT IF EXISTS chk_inspection_acts_deadline_days,
|
||||
DROP CONSTRAINT IF EXISTS chk_inspection_acts_urgency,
|
||||
DROP COLUMN IF EXISTS sealed_by,
|
||||
DROP COLUMN IF EXISTS sealed_at,
|
||||
DROP COLUMN IF EXISTS locked_sha256,
|
||||
DROP COLUMN IF EXISTS locked_by,
|
||||
DROP COLUMN IF EXISTS locked_at,
|
||||
DROP COLUMN IF EXISTS deadline_policy_snapshot,
|
||||
DROP COLUMN IF EXISTS deadline_due_on,
|
||||
DROP COLUMN IF EXISTS deadline_base_on,
|
||||
DROP COLUMN IF EXISTS deadline_basis,
|
||||
DROP COLUMN IF EXISTS deadline_day_type,
|
||||
DROP COLUMN IF EXISTS deadline_days,
|
||||
DROP COLUMN IF EXISTS urgency
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PhaseF4DeadlineAdministration1790038800000 implements MigrationInterface {
|
||||
name = 'PhaseF4DeadlineAdministration1790038800000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO permissions (code, description)
|
||||
VALUES ('inspection_deadlines.manage', 'Administrar plazos institucionales y calendario no laborable de Actas')
|
||||
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
WITH mapping(role_code, permission_code) AS (
|
||||
VALUES
|
||||
('admin', 'inspection_deadlines.manage'),
|
||||
('supervisor', 'inspection_deadlines.manage')
|
||||
)
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM mapping
|
||||
INNER JOIN roles role ON role.code = mapping.role_code
|
||||
INNER JOIN permissions permission ON permission.code = mapping.permission_code
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE code = 'inspection_deadlines.manage'
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM permissions WHERE code = 'inspection_deadlines.manage'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PhaseF4ReportDossierPermissions1790042400000 implements MigrationInterface {
|
||||
name = 'PhaseF4ReportDossierPermissions1790042400000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO permissions (code, description)
|
||||
VALUES
|
||||
('inspection_reports.edit', 'Editar el contenido de trabajo del Informe de inspección'),
|
||||
('inspection_reports.officialize', 'Registrar el IF y PDF oficial generado por GEDO'),
|
||||
('inspection_reports.follow_up', 'Agregar respuestas, notas y documentos al seguimiento del Informe')
|
||||
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
WITH mapping(role_code, permission_code) AS (
|
||||
VALUES
|
||||
('admin', 'inspection_reports.edit'),
|
||||
('admin', 'inspection_reports.officialize'),
|
||||
('admin', 'inspection_reports.follow_up'),
|
||||
('supervisor', 'inspection_reports.edit'),
|
||||
('supervisor', 'inspection_reports.officialize'),
|
||||
('supervisor', 'inspection_reports.follow_up'),
|
||||
('inspector', 'inspection_reports.edit'),
|
||||
('inspector', 'inspection_reports.officialize'),
|
||||
('inspector', 'inspection_reports.follow_up')
|
||||
)
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM mapping
|
||||
INNER JOIN roles role ON role.code = mapping.role_code
|
||||
INNER JOIN permissions permission ON permission.code = mapping.permission_code
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_inspection_reports_gedo_if_identifier
|
||||
ON inspection_reports(gedo_if_identifier)
|
||||
WHERE gedo_if_identifier IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS uq_inspection_reports_gedo_if_identifier`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions
|
||||
WHERE code IN (
|
||||
'inspection_reports.edit',
|
||||
'inspection_reports.officialize',
|
||||
'inspection_reports.follow_up'
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM permissions
|
||||
WHERE code IN (
|
||||
'inspection_reports.edit',
|
||||
'inspection_reports.officialize',
|
||||
'inspection_reports.follow_up'
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PhaseF4DocumentNumbering1790046000000 implements MigrationInterface {
|
||||
name = 'PhaseF4DocumentNumbering1790046000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM inspection_acts WHERE act_number > 99999) THEN
|
||||
RAISE EXCEPTION 'F4 numbering requires inspection act numbers <= 99999';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_visits
|
||||
WHERE code ~ '^INS-[0-9]{4}-[0-9]{6}$'
|
||||
AND RIGHT(code, 6)::integer > 99999
|
||||
) THEN
|
||||
RAISE EXCEPTION 'F4 numbering requires inspection visit numbers <= 99999';
|
||||
END IF;
|
||||
END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE inspection_visits
|
||||
SET code = 'INSP-'
|
||||
|| LPAD(RIGHT(code, 6)::integer::text, 5, '0')
|
||||
|| '-'
|
||||
|| TO_CHAR(
|
||||
COALESCE(planned_start_at, created_at) AT TIME ZONE 'America/Argentina/Mendoza',
|
||||
'DD-MM-YY'
|
||||
),
|
||||
title = CASE
|
||||
WHEN title = inspection_visits.code THEN 'INSP-'
|
||||
|| LPAD(RIGHT(inspection_visits.code, 6)::integer::text, 5, '0')
|
||||
|| '-'
|
||||
|| TO_CHAR(
|
||||
COALESCE(planned_start_at, created_at) AT TIME ZONE 'America/Argentina/Mendoza',
|
||||
'DD-MM-YY'
|
||||
)
|
||||
ELSE title
|
||||
END
|
||||
WHERE code ~ '^INS-[0-9]{4}-[0-9]{6}$'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE inspection_acts
|
||||
SET code = 'ACT-'
|
||||
|| LPAD(act_number::text, 5, '0')
|
||||
|| '-'
|
||||
|| TO_CHAR(occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS uq_inspection_reports_year_number`);
|
||||
await queryRunner.query(`
|
||||
UPDATE inspection_reports report
|
||||
SET report_year = act.act_year,
|
||||
report_number = act.act_number,
|
||||
code = 'INF-'
|
||||
|| LPAD(act.act_number::text, 5, '0')
|
||||
|| '-'
|
||||
|| TO_CHAR(act.occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY')
|
||||
FROM inspection_acts act
|
||||
WHERE act.id = report.act_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX uq_inspection_reports_year_number
|
||||
ON inspection_reports(report_year, report_number)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION dhv2_f4_set_act_code()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.act_number > 99999 THEN
|
||||
RAISE EXCEPTION 'Inspection Act sequence exhausted for F4 institutional format';
|
||||
END IF;
|
||||
NEW.code := 'ACT-'
|
||||
|| LPAD(NEW.act_number::text, 5, '0')
|
||||
|| '-'
|
||||
|| TO_CHAR(NEW.occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP TRIGGER IF EXISTS trg_dhv2_f4_set_act_code ON inspection_acts
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE TRIGGER trg_dhv2_f4_set_act_code
|
||||
BEFORE INSERT OR UPDATE OF act_number, occurred_at
|
||||
ON inspection_acts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION dhv2_f4_set_act_code()
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION dhv2_f4_set_report_code()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
source_act inspection_acts%ROWTYPE;
|
||||
BEGIN
|
||||
SELECT * INTO source_act
|
||||
FROM inspection_acts
|
||||
WHERE id = NEW.act_id;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Inspection Act not found for report %', NEW.act_id;
|
||||
END IF;
|
||||
IF source_act.act_number > 99999 THEN
|
||||
RAISE EXCEPTION 'Inspection Report sequence exhausted for F4 institutional format';
|
||||
END IF;
|
||||
NEW.report_year := source_act.act_year;
|
||||
NEW.report_number := source_act.act_number;
|
||||
NEW.code := 'INF-'
|
||||
|| LPAD(source_act.act_number::text, 5, '0')
|
||||
|| '-'
|
||||
|| TO_CHAR(source_act.occurred_at AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP TRIGGER IF EXISTS trg_dhv2_f4_set_report_code ON inspection_reports
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE TRIGGER trg_dhv2_f4_set_report_code
|
||||
BEFORE INSERT OR UPDATE OF act_id
|
||||
ON inspection_reports
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION dhv2_f4_set_report_code()
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_dhv2_f4_set_report_code ON inspection_reports`);
|
||||
await queryRunner.query(`DROP FUNCTION IF EXISTS dhv2_f4_set_report_code()`);
|
||||
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_dhv2_f4_set_act_code ON inspection_acts`);
|
||||
await queryRunner.query(`DROP FUNCTION IF EXISTS dhv2_f4_set_act_code()`);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PhaseF4SmtpSuperadmin1790049600000 implements MigrationInterface {
|
||||
name = 'PhaseF4SmtpSuperadmin1790049600000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE smtp_settings (
|
||||
id smallint PRIMARY KEY,
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
host varchar(255),
|
||||
port integer,
|
||||
security_mode varchar(20),
|
||||
username varchar(255),
|
||||
password_encrypted text,
|
||||
from_name varchar(160),
|
||||
from_email varchar(255),
|
||||
reply_to varchar(255),
|
||||
updated_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_smtp_settings_singleton CHECK (id = 1),
|
||||
CONSTRAINT chk_smtp_settings_port CHECK (port IS NULL OR port BETWEEN 1 AND 65535),
|
||||
CONSTRAINT chk_smtp_settings_security CHECK (
|
||||
security_mode IS NULL OR security_mode IN ('TLS','STARTTLS')
|
||||
),
|
||||
CONSTRAINT fk_smtp_settings_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO smtp_settings (id, enabled)
|
||||
VALUES (1, false)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO permissions (code, description)
|
||||
VALUES ('system_mail.manage', 'Configurar la salida SMTP del sistema y probar el transporte')
|
||||
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM roles role
|
||||
CROSS JOIN permissions permission
|
||||
WHERE role.code = 'admin'
|
||||
AND permission.code = 'system_mail.manage'
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE inspection_document_deliveries delivery
|
||||
SET recipient_kind = 'INSPECTOR',
|
||||
recipient_user_id = visit.lead_inspector_user_id,
|
||||
recipient_key = visit.lead_inspector_user_id,
|
||||
recipient_email = inspector.email,
|
||||
status = CASE WHEN inspector.email IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
|
||||
last_error = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
FROM inspection_acts act
|
||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
INNER JOIN users inspector ON inspector.id = visit.lead_inspector_user_id
|
||||
WHERE delivery.act_id = act.id
|
||||
AND delivery.document_kind = 'REPORT_WORD'
|
||||
AND delivery.recipient_kind = 'DIRECTOR'
|
||||
AND delivery.status <> 'SENT'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_document_deliveries existing
|
||||
WHERE existing.act_id = delivery.act_id
|
||||
AND existing.document_kind = 'REPORT_WORD'
|
||||
AND existing.recipient_kind = 'INSPECTOR'
|
||||
AND existing.recipient_key = visit.lead_inspector_user_id
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE code = 'system_mail.manage'
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`DELETE FROM permissions WHERE code = 'system_mail.manage'`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS smtp_settings`);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PhaseF4RemoveSurveySubsystem1790053200000 implements MigrationInterface {
|
||||
name = 'PhaseF4RemoveSurveySubsystem1790053200000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM role_permissions
|
||||
WHERE permission_id IN (
|
||||
SELECT id FROM permissions WHERE code LIKE 'surveys.%'
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM permissions WHERE code LIKE 'surveys.%'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_media`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_versions`);
|
||||
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> {
|
||||
// Intentionally irreversible: historical migrations still document the old
|
||||
// Survey schema, but restoring dropped campaign/report data would be unsafe.
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PhaseF4SealedActs1790056800000 implements MigrationInterface {
|
||||
name = 'PhaseF4SealedActs1790056800000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE inspection_acts
|
||||
SET sealed_at = COALESCE(closed_at, updated_at, CURRENT_TIMESTAMP),
|
||||
sealed_by = closed_by
|
||||
WHERE status = 'CLOSED'
|
||||
AND sealed_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION dhv2_f4_stamp_act_seal()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'CLOSED' AND OLD.status IS DISTINCT FROM 'CLOSED' THEN
|
||||
NEW.sealed_at := COALESCE(NEW.closed_at, CURRENT_TIMESTAMP);
|
||||
NEW.sealed_by := NEW.closed_by;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP TRIGGER IF EXISTS trg_dhv2_f4_stamp_act_seal ON inspection_acts
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE TRIGGER trg_dhv2_f4_stamp_act_seal
|
||||
BEFORE UPDATE OF status ON inspection_acts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION dhv2_f4_stamp_act_seal()
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_dhv2_f4_stamp_act_seal ON inspection_acts`);
|
||||
await queryRunner.query(`DROP FUNCTION IF EXISTS dhv2_f4_stamp_act_seal()`);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -11,11 +12,15 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { InspectionActUrgency } from '../../database/entities';
|
||||
|
||||
export class CreateInspectionActDto {
|
||||
@IsISO8601({ strict: true })
|
||||
occurredAt!: string;
|
||||
|
||||
@IsEnum(InspectionActUrgency)
|
||||
urgency!: InspectionActUrgency;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -11,12 +12,17 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { InspectionActUrgency } from '../../database/entities';
|
||||
|
||||
export class UpdateInspectionActDto {
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
occurredAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(InspectionActUrgency)
|
||||
urgency?: InspectionActUrgency;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
DocumentSequenceType,
|
||||
InspectionAct,
|
||||
InspectionActStatus,
|
||||
InspectionActUrgency,
|
||||
InspectionDeadlineBasis,
|
||||
InspectionDeadlineDayType,
|
||||
InspectionActVersionEvent,
|
||||
InspectionVisit,
|
||||
InspectionVisitStatus,
|
||||
@@ -41,7 +44,6 @@ interface ActAsset {
|
||||
interface ActVisitSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: InspectionVisitStatus;
|
||||
actualStartedAt: Date | null;
|
||||
}
|
||||
@@ -72,6 +74,17 @@ export interface InspectionActListItem {
|
||||
title: string;
|
||||
summary: string;
|
||||
observations: string | null;
|
||||
urgency: InspectionActUrgency;
|
||||
deadlineDays: number | null;
|
||||
deadlineDayType: InspectionDeadlineDayType | null;
|
||||
deadlineBasis: InspectionDeadlineBasis | null;
|
||||
deadlineBaseAt: Date | null;
|
||||
deadlineAt: Date | null;
|
||||
lockedAt: Date | null;
|
||||
lockedBy: string | null;
|
||||
lockedSha256: string | null;
|
||||
sealedAt: Date | null;
|
||||
sealedBy: string | null;
|
||||
currentVersion: number;
|
||||
cancellationReason: string | null;
|
||||
closedAt: Date | null;
|
||||
@@ -137,7 +150,6 @@ export class InspectionActsService {
|
||||
act.code ILIKE ${search}
|
||||
OR act.title ILIKE ${search}
|
||||
OR visit.code ILIKE ${search}
|
||||
OR visit.title ILIKE ${search}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_act_assets search_link
|
||||
@@ -313,13 +325,16 @@ export class InspectionActsService {
|
||||
const occurredAt = new Date(dto.occurredAt);
|
||||
const actYear = await this.yearAtProjectTimezone(manager, occurredAt);
|
||||
const actNumber = await this.allocateNumber(manager, actYear);
|
||||
if (actNumber > 999999) {
|
||||
if (actNumber > 99999) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED',
|
||||
message: 'La numeración anual de actas agotó su rango disponible',
|
||||
});
|
||||
}
|
||||
const code = `ACTA-${actYear}-${String(actNumber).padStart(6, '0')}`;
|
||||
const [dateRow] = (await manager.query(`
|
||||
SELECT TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS date_part
|
||||
`, [occurredAt])) as Array<{ date_part: string }>;
|
||||
const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`;
|
||||
const act = manager.getRepository(InspectionAct).create({
|
||||
visitId,
|
||||
actYear,
|
||||
@@ -330,6 +345,17 @@ export class InspectionActsService {
|
||||
title: dto.title,
|
||||
summary: dto.summary,
|
||||
observations: dto.observations ?? null,
|
||||
urgency: dto.urgency,
|
||||
deadlineDays: null,
|
||||
deadlineDayType: null,
|
||||
deadlineBasis: null,
|
||||
deadlineBaseAt: null,
|
||||
deadlineAt: null,
|
||||
lockedAt: null,
|
||||
lockedBy: null,
|
||||
lockedSha256: null,
|
||||
sealedAt: null,
|
||||
sealedBy: null,
|
||||
currentVersion: 0,
|
||||
cancellationReason: null,
|
||||
createdBy: principal.userId,
|
||||
@@ -384,6 +410,7 @@ export class InspectionActsService {
|
||||
await this.assertVisitAssets(manager, visit.id, nextAssetIds);
|
||||
const before = await this.loadView(manager, id);
|
||||
if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt;
|
||||
if (dto.urgency !== undefined) act.urgency = dto.urgency;
|
||||
if (dto.title !== undefined) act.title = dto.title;
|
||||
if (dto.summary !== undefined) act.summary = dto.summary;
|
||||
if (dto.observations !== undefined) act.observations = dto.observations;
|
||||
@@ -462,7 +489,6 @@ export class InspectionActsService {
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', visit.id,
|
||||
'code', visit.code,
|
||||
'title', visit.title,
|
||||
'status', visit.status,
|
||||
'actualStartedAt', visit.actual_started_at
|
||||
) AS visit,
|
||||
@@ -474,6 +500,17 @@ export class InspectionActsService {
|
||||
act.title,
|
||||
act.summary,
|
||||
act.observations,
|
||||
act.urgency,
|
||||
act.deadline_days AS "deadlineDays",
|
||||
act.deadline_day_type AS "deadlineDayType",
|
||||
act.deadline_basis AS "deadlineBasis",
|
||||
act.deadline_base_at AS "deadlineBaseAt",
|
||||
act.deadline_at AS "deadlineAt",
|
||||
act.locked_at AS "lockedAt",
|
||||
act.locked_by AS "lockedBy",
|
||||
act.locked_sha256 AS "lockedSha256",
|
||||
act.sealed_at AS "sealedAt",
|
||||
act.sealed_by AS "sealedBy",
|
||||
act.current_version AS "currentVersion",
|
||||
act.cancellation_reason AS "cancellationReason",
|
||||
act.closed_at AS "closedAt",
|
||||
@@ -645,7 +682,7 @@ export class InspectionActsService {
|
||||
if (rows.length > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS',
|
||||
message: 'La inspección ya tiene un acta en borrador; preparala o cancelala antes de crear otra',
|
||||
message: 'La inspección ya tiene un acta en borrador; finalizala o cancelala antes de crear otra',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -665,7 +702,7 @@ export class InspectionActsService {
|
||||
if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_ACT_ASSET_INVALID',
|
||||
message: 'Cada activo del acta debe estar incluido en la visita',
|
||||
message: 'Cada inventario del acta debe estar incluido en la inspección',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -758,6 +795,15 @@ export class InspectionActsService {
|
||||
'title', act.title,
|
||||
'summary', act.summary,
|
||||
'observations', act.observations,
|
||||
'urgency', act.urgency,
|
||||
'deadlineDays', act.deadline_days,
|
||||
'deadlineDayType', act.deadline_day_type,
|
||||
'deadlineBasis', act.deadline_basis,
|
||||
'deadlineBaseAt', act.deadline_base_at,
|
||||
'deadlineAt', act.deadline_at,
|
||||
'lockedAt', act.locked_at,
|
||||
'lockedSha256', act.locked_sha256,
|
||||
'sealedAt', act.sealed_at,
|
||||
'currentVersion', act.current_version,
|
||||
'cancellationReason', act.cancellation_reason,
|
||||
'createdBy', act.created_by,
|
||||
@@ -765,7 +811,6 @@ export class InspectionActsService {
|
||||
'visit', JSONB_BUILD_OBJECT(
|
||||
'id', visit.id,
|
||||
'code', visit.code,
|
||||
'title', visit.title,
|
||||
'status', visit.status,
|
||||
'scopeAssetId', visit.scope_asset_id,
|
||||
'actualStartedAt', visit.actual_started_at
|
||||
@@ -804,6 +849,15 @@ export class InspectionActsService {
|
||||
title: act.title,
|
||||
summary: act.summary,
|
||||
observations: act.observations,
|
||||
urgency: act.urgency,
|
||||
deadlineDays: act.deadlineDays,
|
||||
deadlineDayType: act.deadlineDayType,
|
||||
deadlineBasis: act.deadlineBasis,
|
||||
deadlineBaseAt: act.deadlineBaseAt,
|
||||
deadlineAt: act.deadlineAt,
|
||||
lockedAt: act.lockedAt,
|
||||
lockedSha256: act.lockedSha256,
|
||||
sealedAt: act.sealedAt,
|
||||
currentVersion: act.currentVersion,
|
||||
cancellationReason: act.cancellationReason,
|
||||
closedAt: act.closedAt,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { InspectionActUrgency } from '../../database/entities';
|
||||
|
||||
export class LockInspectionActDto {
|
||||
@IsEnum(InspectionActUrgency)
|
||||
urgency!: InspectionActUrgency;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Body, Controller, 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 { LockInspectionActDto } from './dto/lock-inspection-act.dto';
|
||||
import { InspectionActLifecycleService } from './inspection-act-lifecycle.service';
|
||||
|
||||
@Controller('inspection-acts/:actId')
|
||||
export class InspectionActLifecycleController {
|
||||
constructor(private readonly lifecycle: InspectionActLifecycleService) {}
|
||||
|
||||
@Post('lock')
|
||||
@RequirePermissions('inspection_closure.prepare')
|
||||
lock(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Body() dto: LockInspectionActDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.lifecycle.lock(actId, dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
import {
|
||||
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,
|
||||
InspectionActStatus,
|
||||
InspectionActVersionEvent,
|
||||
InspectionVisitStatus,
|
||||
} from '../database/entities';
|
||||
import { InspectionDeadlinesService } from '../inspection-deadlines/inspection-deadlines.service';
|
||||
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
||||
import { sha256CanonicalJson } from './canonical-json';
|
||||
import type { LockInspectionActDto } from './dto/lock-inspection-act.dto';
|
||||
|
||||
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-CLOSURE-V2';
|
||||
|
||||
interface LockContext {
|
||||
id: string;
|
||||
code: string;
|
||||
visitId: string;
|
||||
status: InspectionActStatus;
|
||||
occurredAt: Date;
|
||||
currentVersion: number;
|
||||
visitStatus: InspectionVisitStatus;
|
||||
leadInspectorUserId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InspectionActLifecycleService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly deadlines: InspectionDeadlinesService,
|
||||
) {}
|
||||
|
||||
async lock(
|
||||
actId: string,
|
||||
dto: LockInspectionActDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const context = await this.lockContext(manager, actId);
|
||||
if (context.status !== InspectionActStatus.DRAFT) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_ACT_NOT_DRAFT',
|
||||
message: 'Sólo puede finalizarse y bloquearse un Acta que esté en borrador',
|
||||
});
|
||||
}
|
||||
if (context.visitStatus !== InspectionVisitStatus.IN_PROGRESS) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_VISIT_NOT_IN_PROGRESS',
|
||||
message: 'El Acta sólo puede bloquearse durante una inspección en curso',
|
||||
});
|
||||
}
|
||||
await this.assertActorAssigned(manager, context, principal);
|
||||
await this.requireResponsible(manager, actId);
|
||||
await this.requireVerificationResults(manager, context.visitId);
|
||||
|
||||
const [signatureCount] = (await manager.query(`
|
||||
SELECT COUNT(*)::integer AS total
|
||||
FROM inspection_act_signatures
|
||||
WHERE act_id = $1
|
||||
`, [actId])) as Array<{ total: number }>;
|
||||
if (Number(signatureCount?.total ?? 0) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_ACT_ALREADY_SIGNED',
|
||||
message: 'El Acta ya tiene una manifestación de firma y no puede volver a bloquearse',
|
||||
});
|
||||
}
|
||||
|
||||
const deadline = await this.deadlines.snapshotForLock(
|
||||
manager,
|
||||
dto.urgency,
|
||||
new Date(context.occurredAt),
|
||||
);
|
||||
const lockedAt = new Date();
|
||||
const [updated] = (await manager.query(`
|
||||
UPDATE inspection_acts
|
||||
SET status = 'READY',
|
||||
urgency = $2,
|
||||
deadline_days = $3,
|
||||
deadline_day_type = $4,
|
||||
deadline_basis = $5,
|
||||
deadline_base_on = $6::date,
|
||||
deadline_due_on = $7::date,
|
||||
deadline_policy_snapshot = $8::jsonb,
|
||||
locked_at = $9,
|
||||
locked_by = $10,
|
||||
current_version = current_version + 1,
|
||||
updated_by = $10,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING current_version AS "versionNumber"
|
||||
`, [
|
||||
actId,
|
||||
dto.urgency,
|
||||
deadline.snapshot.days,
|
||||
deadline.snapshot.dayType,
|
||||
deadline.snapshot.basis,
|
||||
deadline.baseOn,
|
||||
deadline.dueOn,
|
||||
deadline.snapshot,
|
||||
lockedAt,
|
||||
principal.userId,
|
||||
])) as Array<{ versionNumber: number }>;
|
||||
|
||||
const preparedSnapshot = await this.buildPreparedSnapshot(manager, actId, lockedAt);
|
||||
const preparedSha256 = sha256CanonicalJson(preparedSnapshot);
|
||||
await manager.query(`
|
||||
UPDATE inspection_acts
|
||||
SET locked_sha256 = $2
|
||||
WHERE id = $1
|
||||
`, [actId, preparedSha256]);
|
||||
await manager.query(`
|
||||
INSERT INTO inspection_act_closures (
|
||||
act_id, schema_version, prepared_snapshot, prepared_sha256,
|
||||
prepared_at, prepared_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (act_id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
prepared_snapshot = EXCLUDED.prepared_snapshot,
|
||||
prepared_sha256 = EXCLUDED.prepared_sha256,
|
||||
prepared_at = EXCLUDED.prepared_at,
|
||||
prepared_by = EXCLUDED.prepared_by
|
||||
`, [
|
||||
actId,
|
||||
CLOSURE_SCHEMA_VERSION,
|
||||
preparedSnapshot,
|
||||
preparedSha256,
|
||||
lockedAt,
|
||||
principal.userId,
|
||||
]);
|
||||
await manager.query(`
|
||||
INSERT INTO inspection_act_versions (
|
||||
act_id, version_number, event, snapshot, actor_user_id, actor_username
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, [
|
||||
actId,
|
||||
Number(updated.versionNumber),
|
||||
InspectionActVersionEvent.READY,
|
||||
preparedSnapshot,
|
||||
principal.userId,
|
||||
principal.username,
|
||||
]);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.INSPECTION_ACT_READY,
|
||||
entityType: 'inspection_act',
|
||||
entityId: actId,
|
||||
afterData: {
|
||||
status: 'LOCKED_PENDING_SIGNATURE',
|
||||
physicalStatus: InspectionActStatus.READY,
|
||||
urgency: dto.urgency,
|
||||
deadlineDays: deadline.snapshot.days,
|
||||
deadlineDayType: deadline.snapshot.dayType,
|
||||
deadlineBasis: deadline.snapshot.basis,
|
||||
deadlineBaseOn: deadline.baseOn,
|
||||
deadlineDueOn: deadline.dueOn,
|
||||
lockedAt: lockedAt.toISOString(),
|
||||
preparedSha256,
|
||||
},
|
||||
metadata: {
|
||||
actId,
|
||||
visitId: context.visitId,
|
||||
versionNumber: Number(updated.versionNumber),
|
||||
immutable: true,
|
||||
},
|
||||
}, manager);
|
||||
|
||||
return {
|
||||
id: actId,
|
||||
code: context.code,
|
||||
status: 'LOCKED_PENDING_SIGNATURE' as const,
|
||||
physicalStatus: InspectionActStatus.READY,
|
||||
urgency: dto.urgency,
|
||||
deadline: {
|
||||
days: deadline.snapshot.days,
|
||||
dayType: deadline.snapshot.dayType,
|
||||
basis: deadline.snapshot.basis,
|
||||
baseOn: deadline.baseOn,
|
||||
dueOn: deadline.dueOn,
|
||||
},
|
||||
lockedAt,
|
||||
preparedSha256,
|
||||
currentVersion: Number(updated.versionNumber),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async lockContext(manager: EntityManager, actId: string): Promise<LockContext> {
|
||||
const [row] = (await manager.query(`
|
||||
SELECT
|
||||
act.id,
|
||||
act.code,
|
||||
act.visit_id AS "visitId",
|
||||
act.status,
|
||||
act.occurred_at AS "occurredAt",
|
||||
act.current_version AS "currentVersion",
|
||||
visit.status AS "visitStatus",
|
||||
visit.lead_inspector_user_id AS "leadInspectorUserId"
|
||||
FROM inspection_acts act
|
||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
WHERE act.id = $1
|
||||
FOR UPDATE OF act, visit
|
||||
`, [actId])) as LockContext[];
|
||||
if (!row) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_ACT_NOT_FOUND',
|
||||
message: 'Acta de inspección no encontrada',
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private async assertActorAssigned(
|
||||
manager: EntityManager,
|
||||
context: LockContext,
|
||||
principal: AuthPrincipal,
|
||||
): Promise<void> {
|
||||
if (context.leadInspectorUserId === principal.userId) return;
|
||||
const [membership] = (await manager.query(`
|
||||
SELECT 1 AS found
|
||||
FROM inspection_visit_members
|
||||
WHERE visit_id = $1
|
||||
AND user_id = $2
|
||||
AND included = true
|
||||
LIMIT 1
|
||||
`, [context.visitId, principal.userId])) as Array<{ found: number }>;
|
||||
if (!membership) {
|
||||
throw new ForbiddenException({
|
||||
code: 'INSPECTION_ACT_ACTOR_NOT_ASSIGNED',
|
||||
message: 'Sólo un inspector asignado a la inspección puede finalizar el Acta',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async requireResponsible(manager: EntityManager, actId: string): Promise<void> {
|
||||
const [row] = (await manager.query(`
|
||||
SELECT act_id
|
||||
FROM inspection_act_responsibles
|
||||
WHERE act_id = $1
|
||||
`, [actId])) as Array<{ act_id: string }>;
|
||||
if (!row) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED',
|
||||
message: 'Antes de finalizar el Acta debe identificarse al responsable de la empresa o documentar su ausencia',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async requireVerificationResults(manager: EntityManager, visitId: string): Promise<void> {
|
||||
const [verification] = (await manager.query(`
|
||||
SELECT
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE outcome IS NOT NULL)::integer AS completed
|
||||
FROM inspection_finding_verification_visits
|
||||
WHERE visit_id = $1
|
||||
`, [visitId])) as Array<{ total: number; completed: number }>;
|
||||
const total = Number(verification?.total ?? 0);
|
||||
const completed = Number(verification?.completed ?? 0);
|
||||
if (total > 0 && completed !== total) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED',
|
||||
message: 'Registrá el resultado de todas las verificaciones antes de finalizar el Acta',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async buildPreparedSnapshot(
|
||||
manager: EntityManager,
|
||||
actId: string,
|
||||
preparedAt: Date,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const [act] = (await manager.query(`
|
||||
SELECT
|
||||
act.id,
|
||||
act.code,
|
||||
act.act_year AS "actYear",
|
||||
act.act_number AS "actNumber",
|
||||
act.status,
|
||||
act.occurred_at AS "occurredAt",
|
||||
act.title,
|
||||
act.summary,
|
||||
act.observations,
|
||||
act.urgency,
|
||||
act.deadline_days AS "deadlineDays",
|
||||
act.deadline_day_type AS "deadlineDayType",
|
||||
act.deadline_basis AS "deadlineBasis",
|
||||
act.deadline_base_on AS "deadlineBaseOn",
|
||||
act.deadline_due_on AS "deadlineDueOn",
|
||||
act.deadline_policy_snapshot AS "deadlinePolicySnapshot",
|
||||
act.locked_at AS "lockedAt",
|
||||
act.current_version AS "currentVersion",
|
||||
act.created_at AS "createdAt",
|
||||
act.updated_at AS "updatedAt",
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', visit.id,
|
||||
'code', visit.code,
|
||||
'title', visit.title,
|
||||
'objective', visit.objective,
|
||||
'status', visit.status,
|
||||
'scopeAssetId', visit.scope_asset_id,
|
||||
'leadInspectorUserId', visit.lead_inspector_user_id,
|
||||
'plannedStartAt', visit.planned_start_at,
|
||||
'plannedEndAt', visit.planned_end_at,
|
||||
'actualStartedAt', visit.actual_started_at,
|
||||
'instructions', visit.instructions
|
||||
) AS visit
|
||||
FROM inspection_acts act
|
||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
WHERE act.id = $1
|
||||
`, [actId])) as Array<Record<string, unknown>>;
|
||||
if (!act) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_ACT_NOT_FOUND',
|
||||
message: 'Acta de inspección no encontrada',
|
||||
});
|
||||
}
|
||||
const [responsible] = (await manager.query(`
|
||||
SELECT
|
||||
act_id AS "actId",
|
||||
attendance_status AS "attendanceStatus",
|
||||
full_name AS "fullName",
|
||||
document_type AS "documentType",
|
||||
document_number AS "documentNumber",
|
||||
position,
|
||||
email,
|
||||
phone,
|
||||
absence_reason AS "absenceReason",
|
||||
updated_by AS "updatedBy",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM inspection_act_responsibles
|
||||
WHERE act_id = $1
|
||||
`, [actId])) as Array<Record<string, unknown>>;
|
||||
const team = await manager.query(`
|
||||
SELECT
|
||||
member.user_id AS "userId",
|
||||
user_account.username,
|
||||
user_account.first_name AS "firstName",
|
||||
user_account.last_name AS "lastName",
|
||||
user_account.email,
|
||||
(visit.lead_inspector_user_id = member.user_id) AS "isLead"
|
||||
FROM inspection_visit_members member
|
||||
INNER JOIN inspection_visits visit ON visit.id = member.visit_id
|
||||
INNER JOIN users user_account ON user_account.id = member.user_id
|
||||
WHERE member.visit_id = $1 AND member.included = true
|
||||
ORDER BY "isLead" DESC, user_account.username, member.user_id
|
||||
`, [(act.visit as { id: string }).id]) as Array<Record<string, unknown>>;
|
||||
const assets = await manager.query(`
|
||||
SELECT
|
||||
asset.id,
|
||||
asset.code,
|
||||
asset.name,
|
||||
asset.common_name AS "commonName",
|
||||
asset.description,
|
||||
asset.parent_id AS "parentId",
|
||||
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', company.id,
|
||||
'code', company.code,
|
||||
'name', company.name,
|
||||
'commonName', company.common_name
|
||||
) END AS "operatorCompany",
|
||||
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', area.id,
|
||||
'code', area.code,
|
||||
'name', area.name,
|
||||
'commonName', area.common_name
|
||||
) END AS "operationalArea",
|
||||
asset.information_status AS "informationStatus",
|
||||
asset.current_version AS "currentVersion",
|
||||
asset.data_origin AS "dataOrigin",
|
||||
asset.source_name AS "sourceName",
|
||||
asset.source_reference AS "sourceReference",
|
||||
asset.source_observed_at AS "sourceObservedAt",
|
||||
asset_type.id AS "typeId",
|
||||
asset_type.code AS "typeCode",
|
||||
asset_type.name AS "typeName",
|
||||
COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'definitionId', definition.id,
|
||||
'code', definition.code,
|
||||
'name', definition.name,
|
||||
'dataType', definition.data_type,
|
||||
'value', attribute_value.value
|
||||
) ORDER BY definition.sort_order, definition.code, definition.id)
|
||||
FROM asset_attribute_values attribute_value
|
||||
INNER JOIN asset_attribute_definitions definition
|
||||
ON definition.id = attribute_value.definition_id
|
||||
WHERE attribute_value.asset_id = asset.id
|
||||
), '[]'::jsonb) AS attributes,
|
||||
CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'type', geometry.geometry_type,
|
||||
'geojson', ST_AsGeoJSON(geometry.geometry)::jsonb,
|
||||
'source', geometry.source,
|
||||
'accuracyM', geometry.accuracy_m,
|
||||
'capturedAt', geometry.captured_at,
|
||||
'deviceLabel', geometry.device_label
|
||||
) END AS geometry
|
||||
FROM inspection_act_assets link
|
||||
INNER JOIN assets asset ON asset.id = link.asset_id
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
LEFT JOIN assets company ON company.id = asset.operator_company_id
|
||||
LEFT JOIN assets area ON area.id = asset.operational_area_id
|
||||
LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id
|
||||
WHERE link.act_id = $1 AND link.included = true
|
||||
ORDER BY asset.code, asset.id
|
||||
`, [actId]) as Array<Record<string, unknown>>;
|
||||
const findings = await manager.query(`
|
||||
SELECT
|
||||
finding.id,
|
||||
finding.finding_number AS "findingNumber",
|
||||
finding.code,
|
||||
finding.status,
|
||||
finding.asset_id AS "assetId",
|
||||
finding.catalog_item_id AS "catalogItemId",
|
||||
finding.title,
|
||||
finding.description,
|
||||
finding.legal_basis AS "legalBasis",
|
||||
finding.glossary,
|
||||
finding.catalog_revision AS "catalogRevision",
|
||||
finding.suggested_severity AS "suggestedSeverity",
|
||||
finding.severity,
|
||||
finding.is_recurrence AS "isRecurrence",
|
||||
finding.antecedent_finding_id AS "antecedentFindingId",
|
||||
finding.correction_due_on AS "correctionDueOn",
|
||||
finding.next_control_on AS "nextControlOn",
|
||||
finding.current_version AS "currentVersion",
|
||||
JSONB_BUILD_OBJECT(
|
||||
'code', catalog.code,
|
||||
'sourceNumber', catalog.source_number,
|
||||
'title', catalog.title,
|
||||
'revision', catalog.revision,
|
||||
'suggestedSeverity', finding.suggested_severity,
|
||||
'categoryCode', category.code,
|
||||
'categoryName', category.name
|
||||
) AS catalog,
|
||||
COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'id', evidence.id,
|
||||
'communicationId', evidence.communication_id,
|
||||
'kind', evidence.kind,
|
||||
'purpose', evidence.purpose,
|
||||
'originalName', evidence.original_name,
|
||||
'mimeType', evidence.mime_type,
|
||||
'sizeBytes', evidence.size_bytes,
|
||||
'sha256', evidence.sha256,
|
||||
'title', evidence.title,
|
||||
'description', evidence.description,
|
||||
'capturedAt', evidence.captured_at,
|
||||
'latitude', evidence.latitude,
|
||||
'longitude', evidence.longitude,
|
||||
'accuracyM', evidence.accuracy_m,
|
||||
'deviceLabel', evidence.device_label,
|
||||
'source', evidence.source,
|
||||
'uploadedBy', evidence.uploaded_by,
|
||||
'createdAt', evidence.created_at
|
||||
) ORDER BY evidence.created_at, evidence.id)
|
||||
FROM inspection_finding_evidence evidence
|
||||
WHERE evidence.finding_id = finding.id
|
||||
), '[]'::jsonb) AS evidence
|
||||
FROM inspection_findings finding
|
||||
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
|
||||
LEFT JOIN finding_categories category ON category.id = catalog.category_id
|
||||
WHERE finding.act_id = $1 AND finding.status <> 'VOIDED'
|
||||
ORDER BY finding.finding_number, finding.id
|
||||
`, [actId]) as Array<Record<string, unknown>>;
|
||||
const verificationResults = await manager.query(`
|
||||
SELECT
|
||||
verification_link.finding_id AS "findingId",
|
||||
finding.code AS "findingCode",
|
||||
finding.title AS "findingTitle",
|
||||
finding.description AS "findingDescription",
|
||||
finding.asset_id AS "assetId",
|
||||
asset.code AS "assetCode",
|
||||
asset.name AS "assetName",
|
||||
verification_link.target_control_on AS "targetControlOn",
|
||||
verification_link.outcome,
|
||||
verification_link.result_notes AS "resultNotes",
|
||||
verification_link.verified_at AS "verifiedAt",
|
||||
verification_link.result_recorded_at AS "resultRecordedAt",
|
||||
verification_link.rescheduled_control_on AS "rescheduledControlOn"
|
||||
FROM inspection_finding_verification_visits verification_link
|
||||
INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id
|
||||
INNER JOIN assets asset ON asset.id = finding.asset_id
|
||||
WHERE verification_link.visit_id = $1
|
||||
ORDER BY finding.code
|
||||
`, [(act.visit as { id: string }).id]) as Array<Record<string, unknown>>;
|
||||
return {
|
||||
schemaVersion: CLOSURE_SCHEMA_VERSION,
|
||||
preparedAt: preparedAt.toISOString(),
|
||||
act,
|
||||
responsible: responsible ?? null,
|
||||
team,
|
||||
assets,
|
||||
findings,
|
||||
verificationResults,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,16 @@ export class InspectionClosingController {
|
||||
return this.closing.upsertResponsible(actId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('lock')
|
||||
@RequirePermissions('inspection_closure.prepare')
|
||||
lock(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.prepare(actId, principal, request);
|
||||
}
|
||||
|
||||
@Post('signatures/inspector')
|
||||
@RequirePermissions('inspection_closure.sign')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
@@ -89,9 +99,9 @@ export class InspectionClosingController {
|
||||
return this.closing.recordCompanyOutcome(actId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('close')
|
||||
@Post('seal')
|
||||
@RequirePermissions('inspection_closure.close')
|
||||
close(
|
||||
seal(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Body() dto: CloseInspectionActDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
|
||||
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
|
||||
import { InspectionActLifecycleController } from './inspection-act-lifecycle.controller';
|
||||
import { InspectionActLifecycleService } from './inspection-act-lifecycle.service';
|
||||
import {
|
||||
CompanySignatureInviteController,
|
||||
PublicCompanySignatureController,
|
||||
} from './company-signature-invite.controller';
|
||||
import { CompanySignatureInviteService } from './company-signature-invite.service';
|
||||
import {
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
@@ -11,12 +13,13 @@ import {
|
||||
import { InspectionClosingService } from './inspection-closing.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, InspectionDeadlinesModule, InspectionReportsModule],
|
||||
imports: [AuditModule, InspectionReportsModule],
|
||||
controllers: [
|
||||
InspectionActLifecycleController,
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
CompanySignatureInviteController,
|
||||
PublicCompanySignatureController,
|
||||
],
|
||||
providers: [InspectionActLifecycleService, InspectionClosingService],
|
||||
providers: [InspectionClosingService, CompanySignatureInviteService],
|
||||
})
|
||||
export class InspectionClosingModule {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEnum, IsInt, Max, Min } from 'class-validator';
|
||||
import { InspectionDeadlineDayType } from '../../database/entities';
|
||||
|
||||
export class UpdateInspectionDeadlinePolicyDto {
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(365)
|
||||
days!: number;
|
||||
|
||||
@IsEnum(InspectionDeadlineDayType)
|
||||
dayType!: InspectionDeadlineDayType;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UpsertInspectionNonWorkingDayDto {
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
day!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === true || value === 'true')
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Body, Controller, Get, Param, ParseEnumPipe, 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 { InspectionActUrgency } from '../database/entities';
|
||||
import { UpdateInspectionDeadlinePolicyDto } from './dto/update-inspection-deadline-policy.dto';
|
||||
import { UpsertInspectionNonWorkingDayDto } from './dto/upsert-inspection-non-working-day.dto';
|
||||
import { InspectionDeadlinesService } from './inspection-deadlines.service';
|
||||
|
||||
@Controller('inspection-deadlines')
|
||||
export class InspectionDeadlinesController {
|
||||
constructor(private readonly deadlines: InspectionDeadlinesService) {}
|
||||
|
||||
@Get('policies')
|
||||
@RequirePermissions('inspection_deadlines.manage')
|
||||
policies() {
|
||||
return this.deadlines.listPolicies();
|
||||
}
|
||||
|
||||
@Post('policies/:urgency')
|
||||
@RequirePermissions('inspection_deadlines.manage')
|
||||
updatePolicy(
|
||||
@Param('urgency', new ParseEnumPipe(InspectionActUrgency)) urgency: InspectionActUrgency,
|
||||
@Body() dto: UpdateInspectionDeadlinePolicyDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.deadlines.updatePolicy(urgency, dto, principal, request);
|
||||
}
|
||||
|
||||
@Get('non-working-days')
|
||||
@RequirePermissions('inspection_deadlines.manage')
|
||||
nonWorkingDays(@Query('year') year?: string) {
|
||||
return this.deadlines.listNonWorkingDays(year);
|
||||
}
|
||||
|
||||
@Post('non-working-days')
|
||||
@RequirePermissions('inspection_deadlines.manage')
|
||||
upsertNonWorkingDay(
|
||||
@Body() dto: UpsertInspectionNonWorkingDayDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.deadlines.upsertNonWorkingDay(dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { InspectionDeadlinesController } from './inspection-deadlines.controller';
|
||||
import { InspectionDeadlinesService } from './inspection-deadlines.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [InspectionDeadlinesController],
|
||||
providers: [InspectionDeadlinesService],
|
||||
exports: [InspectionDeadlinesService],
|
||||
})
|
||||
export class InspectionDeadlinesModule {}
|
||||
@@ -1,302 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import {
|
||||
InspectionActUrgency,
|
||||
InspectionDeadlineBasis,
|
||||
InspectionDeadlineDayType,
|
||||
InspectionDeadlinePolicy,
|
||||
InspectionNonWorkingDay,
|
||||
} from '../database/entities';
|
||||
import type { UpdateInspectionDeadlinePolicyDto } from './dto/update-inspection-deadline-policy.dto';
|
||||
import type { UpsertInspectionNonWorkingDayDto } from './dto/upsert-inspection-non-working-day.dto';
|
||||
|
||||
export interface InspectionDeadlineSnapshot {
|
||||
urgency: InspectionActUrgency;
|
||||
days: number;
|
||||
dayType: InspectionDeadlineDayType;
|
||||
basis: InspectionDeadlineBasis;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
export interface InspectionDeadlineLockResult {
|
||||
snapshot: InspectionDeadlineSnapshot;
|
||||
baseOn: string | null;
|
||||
dueOn: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InspectionDeadlinesService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async listPolicies() {
|
||||
const data = await this.dataSource.getRepository(InspectionDeadlinePolicy).find({
|
||||
order: { urgency: 'ASC' },
|
||||
});
|
||||
return { data };
|
||||
}
|
||||
|
||||
async updatePolicy(
|
||||
urgency: InspectionActUrgency,
|
||||
dto: UpdateInspectionDeadlinePolicyDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const repository = manager.getRepository(InspectionDeadlinePolicy);
|
||||
const policy = await repository.findOne({ where: { urgency } });
|
||||
if (!policy) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_DEADLINE_POLICY_NOT_FOUND',
|
||||
message: 'No se encontró la política de plazo solicitada',
|
||||
});
|
||||
}
|
||||
const before = {
|
||||
urgency: policy.urgency,
|
||||
days: policy.days,
|
||||
dayType: policy.dayType,
|
||||
basis: policy.basis,
|
||||
};
|
||||
policy.days = dto.days;
|
||||
policy.dayType = dto.dayType;
|
||||
policy.updatedBy = principal.userId;
|
||||
await repository.save(policy);
|
||||
const after = {
|
||||
urgency: policy.urgency,
|
||||
days: policy.days,
|
||||
dayType: policy.dayType,
|
||||
basis: policy.basis,
|
||||
};
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: 'INSPECTION_DEADLINE_POLICY_UPDATED',
|
||||
entityType: 'inspection_deadline_policy',
|
||||
entityId: urgency,
|
||||
beforeData: before,
|
||||
afterData: after,
|
||||
}, manager);
|
||||
return policy;
|
||||
});
|
||||
}
|
||||
|
||||
async snapshotForLock(
|
||||
manager: EntityManager,
|
||||
urgency: InspectionActUrgency,
|
||||
occurredAt: Date,
|
||||
): Promise<InspectionDeadlineLockResult> {
|
||||
const [policy] = (await manager.query(`
|
||||
SELECT
|
||||
urgency,
|
||||
days,
|
||||
day_type AS "dayType",
|
||||
basis
|
||||
FROM inspection_deadline_policies
|
||||
WHERE urgency = $1
|
||||
FOR SHARE
|
||||
`, [urgency])) as Array<{
|
||||
urgency: InspectionActUrgency;
|
||||
days: number;
|
||||
dayType: InspectionDeadlineDayType;
|
||||
basis: InspectionDeadlineBasis;
|
||||
}>;
|
||||
if (!policy) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_DEADLINE_POLICY_NOT_FOUND',
|
||||
message: 'No existe una política de plazo para la urgencia seleccionada',
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot: InspectionDeadlineSnapshot = {
|
||||
urgency: policy.urgency,
|
||||
days: Number(policy.days),
|
||||
dayType: policy.dayType,
|
||||
basis: policy.basis,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (policy.basis === InspectionDeadlineBasis.GEDO_LOAD_DATE) {
|
||||
return { snapshot, baseOn: null, dueOn: null };
|
||||
}
|
||||
|
||||
const [base] = (await manager.query(`
|
||||
SELECT TO_CHAR(
|
||||
$1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza',
|
||||
'YYYY-MM-DD'
|
||||
) AS day
|
||||
`, [occurredAt])) as Array<{ day: string }>;
|
||||
if (!base?.day) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_ACT_DATE_INVALID',
|
||||
message: 'No se pudo determinar la fecha del Acta para calcular el plazo',
|
||||
});
|
||||
}
|
||||
const dueOn = await this.calculateDueOn(
|
||||
manager,
|
||||
base.day,
|
||||
snapshot.days,
|
||||
snapshot.dayType,
|
||||
);
|
||||
return { snapshot, baseOn: base.day, dueOn };
|
||||
}
|
||||
|
||||
async calculateDueOn(
|
||||
manager: EntityManager,
|
||||
baseOn: string,
|
||||
days: number,
|
||||
dayType: InspectionDeadlineDayType,
|
||||
): Promise<string> {
|
||||
if (!Number.isInteger(days) || days < 1 || days > 365) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_DEADLINE_DAYS_INVALID',
|
||||
message: 'La cantidad de días del plazo no es válida',
|
||||
});
|
||||
}
|
||||
|
||||
if (dayType === InspectionDeadlineDayType.CALENDAR) {
|
||||
const [row] = (await manager.query(`
|
||||
SELECT ($1::date + $2::integer)::text AS "dueOn"
|
||||
`, [baseOn, days])) as Array<{ dueOn: string }>;
|
||||
return row.dueOn;
|
||||
}
|
||||
|
||||
const [row] = (await manager.query(`
|
||||
SELECT candidate.day::text AS "dueOn"
|
||||
FROM (
|
||||
SELECT generated::date AS day
|
||||
FROM generate_series(
|
||||
$1::date + 1,
|
||||
$1::date + (($2::integer * 3) + 31),
|
||||
interval '1 day'
|
||||
) generated
|
||||
WHERE EXTRACT(ISODOW FROM generated) BETWEEN 1 AND 5
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_non_working_days holiday
|
||||
WHERE holiday.day = generated::date
|
||||
AND holiday.enabled = true
|
||||
)
|
||||
ORDER BY generated
|
||||
LIMIT 1 OFFSET ($2::integer - 1)
|
||||
) candidate
|
||||
`, [baseOn, days])) as Array<{ dueOn: string }>;
|
||||
if (!row?.dueOn) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_DEADLINE_CALCULATION_FAILED',
|
||||
message: 'No se pudo calcular el vencimiento con el calendario configurado',
|
||||
});
|
||||
}
|
||||
return row.dueOn;
|
||||
}
|
||||
|
||||
async applyGedoOfficialization(
|
||||
manager: EntityManager,
|
||||
actId: string,
|
||||
officializedOn: string,
|
||||
): Promise<string | null> {
|
||||
const [act] = (await manager.query(`
|
||||
SELECT
|
||||
deadline_days AS "days",
|
||||
deadline_day_type AS "dayType",
|
||||
deadline_basis AS "basis"
|
||||
FROM inspection_acts
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [actId])) as Array<{
|
||||
days: number | null;
|
||||
dayType: InspectionDeadlineDayType | null;
|
||||
basis: InspectionDeadlineBasis | null;
|
||||
}>;
|
||||
if (!act || act.basis !== InspectionDeadlineBasis.GEDO_LOAD_DATE) return null;
|
||||
if (!act.days || !act.dayType) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_ACT_DEADLINE_SNAPSHOT_MISSING',
|
||||
message: 'El Acta no conserva la política de plazo requerida',
|
||||
});
|
||||
}
|
||||
const dueOn = await this.calculateDueOn(manager, officializedOn, Number(act.days), act.dayType);
|
||||
await manager.query(`
|
||||
UPDATE inspection_acts
|
||||
SET deadline_base_on = $2::date,
|
||||
deadline_due_on = $3::date,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [actId, officializedOn, dueOn]);
|
||||
return dueOn;
|
||||
}
|
||||
|
||||
async listNonWorkingDays(year?: string) {
|
||||
const parsedYear = year === undefined ? null : Number(year);
|
||||
if (parsedYear !== null && (!Number.isInteger(parsedYear) || parsedYear < 2000 || parsedYear > 2200)) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVALID_NON_WORKING_DAY_YEAR',
|
||||
message: 'El año del calendario no es válido',
|
||||
});
|
||||
}
|
||||
const parameters: unknown[] = [];
|
||||
const where = parsedYear === null
|
||||
? ''
|
||||
: `WHERE EXTRACT(YEAR FROM day)::integer = $1`;
|
||||
if (parsedYear !== null) parameters.push(parsedYear);
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
day::text AS day,
|
||||
label,
|
||||
enabled,
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM inspection_non_working_days
|
||||
${where}
|
||||
ORDER BY day ASC
|
||||
`, parameters);
|
||||
return { data };
|
||||
}
|
||||
|
||||
async upsertNonWorkingDay(
|
||||
dto: UpsertInspectionNonWorkingDayDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const parsed = new Date(`${dto.day}T00:00:00Z`);
|
||||
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== dto.day) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVALID_NON_WORKING_DAY',
|
||||
message: 'La fecha no laborable no es válida',
|
||||
});
|
||||
}
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const repository = manager.getRepository(InspectionNonWorkingDay);
|
||||
const existing = await repository.findOne({ where: { day: dto.day } });
|
||||
const before = existing
|
||||
? { day: existing.day, label: existing.label, enabled: existing.enabled }
|
||||
: null;
|
||||
const row = existing ?? repository.create({
|
||||
day: dto.day,
|
||||
label: dto.label,
|
||||
enabled: dto.enabled ?? true,
|
||||
createdBy: principal.userId,
|
||||
updatedBy: principal.userId,
|
||||
});
|
||||
row.label = dto.label;
|
||||
row.enabled = dto.enabled ?? true;
|
||||
row.updatedBy = principal.userId;
|
||||
await repository.save(row);
|
||||
const after = { day: row.day, label: row.label, enabled: row.enabled };
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: existing
|
||||
? 'INSPECTION_NON_WORKING_DAY_UPDATED'
|
||||
: 'INSPECTION_NON_WORKING_DAY_CREATED',
|
||||
entityType: 'inspection_non_working_day',
|
||||
entityId: row.day,
|
||||
beforeData: before,
|
||||
afterData: after,
|
||||
}, manager);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
const optionalText = ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
||||
export class CreateRecurrentInspectionFindingDto {
|
||||
@IsUUID('4')
|
||||
antecedentFindingId!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(20000)
|
||||
description!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
severity?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(optionalText)
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
correctionDueOn?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class SetFindingRecurrenceDto {
|
||||
@IsUUID('4')
|
||||
recurrenceOfFindingId!: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, 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 { CreateRecurrentInspectionFindingDto } from './dto/create-recurrent-inspection-finding.dto';
|
||||
import { InspectionFindingRecurrenceService } from './inspection-finding-recurrence.service';
|
||||
|
||||
@Controller('inspection-acts/:actId/findings')
|
||||
export class InspectionFindingRecurrenceController {
|
||||
constructor(private readonly recurrence: InspectionFindingRecurrenceService) {}
|
||||
|
||||
@Get('recurrence-candidates')
|
||||
@RequirePermissions('inspection_findings.read')
|
||||
candidates(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Query('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||
) {
|
||||
return this.recurrence.candidates(actId, assetId);
|
||||
}
|
||||
|
||||
@Post('recurrence')
|
||||
@RequirePermissions('inspection_findings.create')
|
||||
create(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Body() dto: CreateRecurrentInspectionFindingDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.recurrence.create(actId, dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} 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 { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
||||
import type { CreateRecurrentInspectionFindingDto } from './dto/create-recurrent-inspection-finding.dto';
|
||||
|
||||
@Injectable()
|
||||
export class InspectionFindingRecurrenceService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async candidates(actId: string, assetId: string) {
|
||||
await this.requireDraftActAsset(actId, assetId);
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
finding.id,
|
||||
finding.code,
|
||||
finding.title,
|
||||
finding.description,
|
||||
finding.severity,
|
||||
finding.catalog_item_id AS "catalogItemId",
|
||||
finding.is_recurrence AS "isRecurrence",
|
||||
finding.antecedent_finding_id AS "antecedentFindingId",
|
||||
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"
|
||||
FROM inspection_findings finding
|
||||
INNER JOIN inspection_acts act ON act.id = finding.act_id
|
||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
WHERE finding.asset_id = $1
|
||||
AND finding.status = 'OPEN'
|
||||
AND finding.act_id <> $2
|
||||
ORDER BY act.occurred_at DESC, finding.created_at DESC, finding.id DESC
|
||||
`, [assetId, actId]);
|
||||
return { data };
|
||||
}
|
||||
|
||||
async create(
|
||||
actId: string,
|
||||
dto: CreateRecurrentInspectionFindingDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const [act] = await manager.query(`
|
||||
SELECT
|
||||
act.id,
|
||||
act.code,
|
||||
act.status,
|
||||
act.occurred_at AS "occurredAt",
|
||||
act.visit_id AS "visitId",
|
||||
visit.status AS "visitStatus"
|
||||
FROM inspection_acts act
|
||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
WHERE act.id = $1
|
||||
FOR UPDATE OF act, visit
|
||||
`, [actId]) as Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
status: string;
|
||||
occurredAt: Date;
|
||||
visitId: string;
|
||||
visitStatus: string;
|
||||
}>;
|
||||
if (!act) throw this.actNotFound();
|
||||
if (act.status !== 'DRAFT' || act.visitStatus !== 'IN_PROGRESS') {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_FINDING_ACT_NOT_EDITABLE',
|
||||
message: 'La reincidencia sólo puede registrarse mientras el Acta está en borrador y la inspección en curso',
|
||||
});
|
||||
}
|
||||
if (!principal.permissions.includes('inspections.manage')) {
|
||||
const [member] = await manager.query(`
|
||||
SELECT 1 AS found
|
||||
FROM inspection_visit_members
|
||||
WHERE visit_id = $1
|
||||
AND user_id = $2
|
||||
AND included = true
|
||||
LIMIT 1
|
||||
`, [act.visitId, principal.userId]) as Array<{ found: number }>;
|
||||
if (!member) {
|
||||
throw new ForbiddenException({
|
||||
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
|
||||
message: 'La inspección no está asignada al usuario actual',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [antecedent] = await manager.query(`
|
||||
SELECT
|
||||
finding.id,
|
||||
finding.act_id AS "actId",
|
||||
finding.asset_id AS "assetId",
|
||||
finding.catalog_item_id AS "catalogItemId",
|
||||
finding.title,
|
||||
finding.legal_basis AS "legalBasis",
|
||||
finding.glossary,
|
||||
finding.catalog_revision AS "catalogRevision",
|
||||
finding.suggested_severity AS "suggestedSeverity",
|
||||
finding.severity,
|
||||
finding.status,
|
||||
previous_act.code AS "actCode",
|
||||
previous_act.occurred_at AS "occurredAt"
|
||||
FROM inspection_findings finding
|
||||
INNER JOIN inspection_acts previous_act ON previous_act.id = finding.act_id
|
||||
WHERE finding.id = $1
|
||||
FOR SHARE OF finding, previous_act
|
||||
`, [dto.antecedentFindingId]) as Array<{
|
||||
id: string;
|
||||
actId: string;
|
||||
assetId: string;
|
||||
catalogItemId: string | null;
|
||||
title: string;
|
||||
legalBasis: string | null;
|
||||
glossary: string | null;
|
||||
catalogRevision: number | null;
|
||||
suggestedSeverity: number | null;
|
||||
severity: number | null;
|
||||
status: string;
|
||||
actCode: string;
|
||||
occurredAt: Date;
|
||||
}>;
|
||||
if (!antecedent) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_FINDING_ANTECEDENT_NOT_FOUND',
|
||||
message: 'No se encontró el Hallazgo antecedente seleccionado',
|
||||
});
|
||||
}
|
||||
if (antecedent.status !== 'OPEN') {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_FINDING_ANTECEDENT_RESOLVED',
|
||||
message: 'Sólo un Hallazgo anterior todavía abierto puede originar una reincidencia',
|
||||
});
|
||||
}
|
||||
if (antecedent.actId === actId) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_FINDING_RECURRENCE_SAME_ACT',
|
||||
message: 'Una reincidencia debe referenciar un Hallazgo de un Acta anterior',
|
||||
});
|
||||
}
|
||||
if (new Date(antecedent.occurredAt).getTime() > new Date(act.occurredAt).getTime()) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_FINDING_ANTECEDENT_AFTER_ACT',
|
||||
message: 'El antecedente no puede pertenecer a un Acta posterior a la actual',
|
||||
});
|
||||
}
|
||||
const [assetLink] = await manager.query(`
|
||||
SELECT 1 AS found
|
||||
FROM inspection_act_assets
|
||||
WHERE act_id = $1 AND asset_id = $2 AND included = true
|
||||
LIMIT 1
|
||||
`, [actId, antecedent.assetId]) as Array<{ found: number }>;
|
||||
if (!assetLink) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_FINDING_RECURRENCE_ASSET_NOT_IN_ACT',
|
||||
message: 'El Inventario del Hallazgo antecedente no forma parte del Acta actual',
|
||||
});
|
||||
}
|
||||
|
||||
const [sequence] = await manager.query(`
|
||||
SELECT COALESCE(MAX(finding_number), 0)::integer + 1 AS number
|
||||
FROM inspection_findings
|
||||
WHERE act_id = $1
|
||||
`, [actId]) as Array<{ number: number }>;
|
||||
const findingNumber = Number(sequence?.number ?? 1);
|
||||
if (findingNumber > 999) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_FINDING_SEQUENCE_EXHAUSTED',
|
||||
message: 'El Acta alcanzó el máximo de 999 Hallazgos',
|
||||
});
|
||||
}
|
||||
const code = `${act.code}-H${String(findingNumber).padStart(3, '0')}`;
|
||||
const [created] = await manager.query(`
|
||||
INSERT INTO inspection_findings (
|
||||
act_id,
|
||||
asset_id,
|
||||
catalog_item_id,
|
||||
finding_number,
|
||||
code,
|
||||
status,
|
||||
title,
|
||||
description,
|
||||
legal_basis,
|
||||
glossary,
|
||||
catalog_revision,
|
||||
suggested_severity,
|
||||
severity,
|
||||
is_recurrence,
|
||||
antecedent_finding_id,
|
||||
correction_due_on,
|
||||
current_version,
|
||||
created_by,
|
||||
updated_by
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,'OPEN',$6,$7,$8,$9,$10,$11,$12,true,$13,$14,1,$15,$15
|
||||
)
|
||||
RETURNING id, created_at AS "createdAt", updated_at AS "updatedAt"
|
||||
`, [
|
||||
actId,
|
||||
antecedent.assetId,
|
||||
antecedent.catalogItemId,
|
||||
findingNumber,
|
||||
code,
|
||||
antecedent.title,
|
||||
dto.description,
|
||||
antecedent.legalBasis,
|
||||
antecedent.glossary,
|
||||
antecedent.catalogRevision,
|
||||
antecedent.suggestedSeverity,
|
||||
dto.severity ?? antecedent.severity ?? antecedent.suggestedSeverity,
|
||||
antecedent.id,
|
||||
dto.correctionDueOn ?? null,
|
||||
principal.userId,
|
||||
]) as Array<{ id: string; createdAt: Date; updatedAt: Date }>;
|
||||
|
||||
const snapshot = {
|
||||
id: created.id,
|
||||
actId,
|
||||
assetId: antecedent.assetId,
|
||||
catalogItemId: antecedent.catalogItemId,
|
||||
findingNumber,
|
||||
code,
|
||||
status: 'OPEN',
|
||||
title: antecedent.title,
|
||||
description: dto.description,
|
||||
legalBasis: antecedent.legalBasis,
|
||||
glossary: antecedent.glossary,
|
||||
catalogRevision: antecedent.catalogRevision,
|
||||
suggestedSeverity: antecedent.suggestedSeverity,
|
||||
severity: dto.severity ?? antecedent.severity ?? antecedent.suggestedSeverity,
|
||||
isRecurrence: true,
|
||||
antecedentFindingId: antecedent.id,
|
||||
antecedentCode: `${antecedent.actCode}`,
|
||||
correctionDueOn: dto.correctionDueOn ?? null,
|
||||
currentVersion: 1,
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
};
|
||||
await manager.query(`
|
||||
INSERT INTO inspection_finding_versions (
|
||||
finding_id, version_number, event, snapshot, actor_user_id, actor_username
|
||||
) VALUES ($1, 1, 'CREATED', $2::jsonb, $3, $4)
|
||||
`, [created.id, snapshot, principal.userId, principal.username]);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.INSPECTION_FINDING_CREATED,
|
||||
entityType: 'inspection_finding',
|
||||
entityId: created.id,
|
||||
afterData: snapshot,
|
||||
metadata: {
|
||||
actId,
|
||||
visitId: act.visitId,
|
||||
recurrence: true,
|
||||
antecedentFindingId: antecedent.id,
|
||||
},
|
||||
}, manager);
|
||||
return snapshot;
|
||||
});
|
||||
}
|
||||
|
||||
private async requireDraftActAsset(actId: string, assetId: string): Promise<void> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT 1 AS found
|
||||
FROM inspection_acts act
|
||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
INNER JOIN inspection_act_assets link
|
||||
ON link.act_id = act.id AND link.asset_id = $2 AND link.included = true
|
||||
WHERE act.id = $1
|
||||
AND act.status = 'DRAFT'
|
||||
AND visit.status = 'IN_PROGRESS'
|
||||
LIMIT 1
|
||||
`, [actId, assetId]) as Array<{ found: number }>;
|
||||
if (!row) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_FINDING_RECURRENCE_CONTEXT_INVALID',
|
||||
message: 'El Inventario debe estar incluido en un Acta en borrador de una inspección en curso',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private actNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'INSPECTION_ACT_NOT_FOUND',
|
||||
message: 'Acta de inspección no encontrada',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,11 @@ import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto';
|
||||
import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto';
|
||||
import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
|
||||
import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
|
||||
import { FindingRecurrenceService } from './finding-recurrence.service';
|
||||
import type { FindingRecurrenceState } from './finding-recurrence.service';
|
||||
import { InspectionFindingWorklistService } from './inspection-finding-worklist.service';
|
||||
import { InspectionFindingsService } from './inspection-findings.service';
|
||||
import type { InspectionFindingView } from './inspection-findings.service';
|
||||
|
||||
@Controller('inspection-acts/:actId/findings')
|
||||
export class InspectionActFindingsController {
|
||||
@@ -42,18 +46,28 @@ export class InspectionActFindingsController {
|
||||
|
||||
@Controller('inspection-findings')
|
||||
export class InspectionFindingsController {
|
||||
constructor(private readonly findings: InspectionFindingsService) {}
|
||||
constructor(
|
||||
private readonly findings: InspectionFindingsService,
|
||||
private readonly worklist: InspectionFindingWorklistService,
|
||||
private readonly recurrence: FindingRecurrenceService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspection_findings.read')
|
||||
list(@Query() query: ListInspectionFindingsQueryDto) {
|
||||
return this.findings.listGlobal(query);
|
||||
return this.worklist.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('inspection_findings.read')
|
||||
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.findings.getById(id);
|
||||
async get(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
): Promise<InspectionFindingView & FindingRecurrenceState> {
|
||||
const [finding, recurrence] = await Promise.all([
|
||||
this.findings.getById(id),
|
||||
this.recurrence.state(id),
|
||||
]);
|
||||
return { ...finding, ...recurrence };
|
||||
}
|
||||
|
||||
@Get(':id/verification-history')
|
||||
|
||||
@@ -4,8 +4,9 @@ import { FindingCatalogController } from './finding-catalog.controller';
|
||||
import { FindingCatalogMergeService } from './finding-catalog-merge.service';
|
||||
import { FindingCatalogService } from './finding-catalog.service';
|
||||
import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service';
|
||||
import { InspectionFindingRecurrenceController } from './inspection-finding-recurrence.controller';
|
||||
import { InspectionFindingRecurrenceService } from './inspection-finding-recurrence.service';
|
||||
import { FindingRecurrenceController } from './finding-recurrence.controller';
|
||||
import { FindingRecurrenceService } from './finding-recurrence.service';
|
||||
import { InspectionFindingWorklistService } from './inspection-finding-worklist.service';
|
||||
import {
|
||||
InspectionActFindingsController,
|
||||
InspectionFindingsController,
|
||||
@@ -23,8 +24,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
controllers: [
|
||||
FindingCatalogController,
|
||||
InspectionActFindingsController,
|
||||
InspectionFindingRecurrenceController,
|
||||
InspectionFindingsController,
|
||||
FindingRecurrenceController,
|
||||
InspectionFindingEvidenceController,
|
||||
InspectionFindingCommunicationsController,
|
||||
InspectionEvidenceContentController,
|
||||
@@ -34,7 +35,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
F3FindingCatalogResolverService,
|
||||
FindingCatalogMergeService,
|
||||
InspectionFindingsService,
|
||||
InspectionFindingRecurrenceService,
|
||||
InspectionFindingWorklistService,
|
||||
FindingRecurrenceService,
|
||||
InspectionEvidenceService,
|
||||
],
|
||||
exports: [
|
||||
@@ -42,7 +44,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
F3FindingCatalogResolverService,
|
||||
FindingCatalogMergeService,
|
||||
InspectionFindingsService,
|
||||
InspectionFindingRecurrenceService,
|
||||
InspectionFindingWorklistService,
|
||||
FindingRecurrenceService,
|
||||
],
|
||||
})
|
||||
export class InspectionFindingsModule {}
|
||||
|
||||
@@ -62,7 +62,6 @@ interface FindingDocumentView {
|
||||
actStatus: InspectionActStatus;
|
||||
visitId: string;
|
||||
visitCode: string;
|
||||
visitTitle: string;
|
||||
visitStatus: InspectionVisitStatus;
|
||||
}
|
||||
|
||||
@@ -99,7 +98,6 @@ export interface InspectionFindingListItem {
|
||||
verificationVisit: {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: InspectionVisitStatus;
|
||||
plannedStartAt: Date | null;
|
||||
} | null;
|
||||
@@ -143,7 +141,6 @@ export interface FindingVerificationHistoryEvent {
|
||||
verificationVisit: {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: InspectionVisitStatus;
|
||||
plannedStartAt: Date | null;
|
||||
} | null;
|
||||
@@ -811,13 +808,11 @@ export class InspectionFindingsService {
|
||||
'actStatus', act.status,
|
||||
'visitId', visit.id,
|
||||
'visitCode', visit.code,
|
||||
'visitTitle', visit.title,
|
||||
'visitStatus', visit.status
|
||||
) AS document,
|
||||
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', verification_visit.id,
|
||||
'code', verification_visit.code,
|
||||
'title', verification_visit.title,
|
||||
'status', verification_visit.status,
|
||||
'plannedStartAt', verification_visit.planned_start_at
|
||||
) END AS "verificationVisit",
|
||||
@@ -845,7 +840,7 @@ export class InspectionFindingsService {
|
||||
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
|
||||
LEFT JOIN finding_categories category ON category.id = catalog.category_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT verification_target.id, verification_target.code, verification_target.title,
|
||||
SELECT verification_target.id, verification_target.code,
|
||||
verification_target.status, verification_target.planned_start_at
|
||||
FROM inspection_finding_verification_visits verification_link
|
||||
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
|
||||
@@ -923,7 +918,6 @@ export class InspectionFindingsService {
|
||||
CASE WHEN visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', visit.id,
|
||||
'code', visit.code,
|
||||
'title', visit.title,
|
||||
'status', visit.status,
|
||||
'plannedStartAt', visit.planned_start_at
|
||||
) END AS "verificationVisit",
|
||||
|
||||
@@ -1,17 +1,128 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Req } from '@nestjs/common';
|
||||
import { Body, Controller, ForbiddenException, Get, Param, ParseUUIDPipe, Patch, Post, Put, Req } from '@nestjs/common';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../database/entities';
|
||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||
import type { DeliveryRow } from './inspection-document-delivery.service';
|
||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
import { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
|
||||
import { TestSmtpSettingsDto, UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto';
|
||||
|
||||
@Controller('document-delivery')
|
||||
export class DocumentDeliveryController {
|
||||
constructor(private readonly delivery:InspectionDocumentDeliveryService){}
|
||||
@Get('settings') @RequirePermissions('document_delivery.read') settings(){return this.delivery.settings();}
|
||||
@Patch('settings') @RequirePermissions('document_delivery.manage') update(@Body() dto:UpdateDocumentDeliverySettingsDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.updateSettings(dto,principal,request);}
|
||||
@Get('outbox') @RequirePermissions('document_delivery.read') outbox(){return this.delivery.list();}
|
||||
@Post('outbox/:id/retry') @RequirePermissions('document_delivery.manage') retry(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext):Promise<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);}
|
||||
constructor(
|
||||
private readonly delivery: InspectionDocumentDeliveryService,
|
||||
private readonly smtp: SmtpDeliveryService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@Get('settings')
|
||||
@RequirePermissions('document_delivery.read')
|
||||
settings() {
|
||||
return this.delivery.settings();
|
||||
}
|
||||
|
||||
@Patch('settings')
|
||||
@RequirePermissions('document_delivery.manage')
|
||||
update(
|
||||
@Body() dto: UpdateDocumentDeliverySettingsDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.delivery.updateSettings(dto, principal, request);
|
||||
}
|
||||
|
||||
@Get('smtp')
|
||||
@RequirePermissions('document_delivery.manage')
|
||||
smtpSettings(@CurrentAuth() principal: AuthPrincipal) {
|
||||
this.assertSystemAdmin(principal);
|
||||
return this.smtp.publicSettings();
|
||||
}
|
||||
|
||||
@Put('smtp')
|
||||
@RequirePermissions('document_delivery.manage')
|
||||
async updateSmtp(
|
||||
@Body() dto: UpdateSmtpSettingsDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
this.assertSystemAdmin(principal);
|
||||
const before = await this.smtp.publicSettings();
|
||||
const after = await this.smtp.saveSettings(dto, principal.userId);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.SMTP_SETTINGS_UPDATED,
|
||||
entityType: 'system_smtp_settings',
|
||||
entityId: 'singleton',
|
||||
beforeData: before as Record<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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEnum, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { IsEnum, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { InspectionReportFollowUpType } from '../../database/entities';
|
||||
|
||||
export class CreateInspectionReportFollowUpDto {
|
||||
@IsEnum(InspectionReportFollowUpType)
|
||||
eventType!: InspectionReportFollowUpType;
|
||||
type!: InspectionReportFollowUpType;
|
||||
|
||||
@IsISO8601({ strict: true })
|
||||
occurredAt!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
referenceNumber?: string | null;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
occurredOn!: string;
|
||||
externalReference?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
description?: string | null;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class OfficializeInspectionReportGedoDto {
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(255)
|
||||
ifIdentifier!: string;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
officializedOn!: string;
|
||||
}
|
||||
@@ -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,58 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
function optionalText(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
export class UpdateInspectionReportContentDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(10000)
|
||||
referenceText?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
generalObjective?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
specificObjective?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(40000)
|
||||
background?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(40000)
|
||||
legalFramework?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(40000)
|
||||
executiveSummary?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(80000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => optionalText(value))
|
||||
@IsString()
|
||||
@MaxLength(40000)
|
||||
conclusion?: 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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
@@ -11,69 +11,61 @@ import {
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export enum SmtpSecurityModeDto {
|
||||
TLS = 'TLS',
|
||||
STARTTLS = 'STARTTLS',
|
||||
}
|
||||
import { SmtpSecurityMode } from '../../database/entities';
|
||||
|
||||
export class UpdateSmtpSettingsDto {
|
||||
@Transform(({ value }) => value === true || value === 'true')
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
host!: string;
|
||||
|
||||
@Transform(({ value }) => Number(value))
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
port!: number;
|
||||
|
||||
@IsEnum(SmtpSecurityModeDto)
|
||||
securityMode!: SmtpSecurityModeDto;
|
||||
@IsEnum(SmtpSecurityMode)
|
||||
securityMode!: SmtpSecurityMode;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
|
||||
@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(2048)
|
||||
password?: string;
|
||||
@MaxLength(1000)
|
||||
password?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === true || value === 'true')
|
||||
@IsBoolean()
|
||||
clearPassword?: boolean;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(160)
|
||||
@MaxLength(200)
|
||||
fromName!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
|
||||
@IsEmail()
|
||||
@MaxLength(255)
|
||||
@MaxLength(320)
|
||||
fromEmail!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null))
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null)
|
||||
@IsEmail()
|
||||
@MaxLength(255)
|
||||
@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))
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
|
||||
@IsEmail()
|
||||
@MaxLength(255)
|
||||
to!: string;
|
||||
@MaxLength(320)
|
||||
email!: string;
|
||||
}
|
||||
|
||||
@@ -1,88 +1,212 @@
|
||||
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 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 d=new Date(String(value ?? '')); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('es-AR') : '-'; }
|
||||
function clean(value: string): string { return value.normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[–—]/g,'-').replace(/[“”]/g,'"').replace(/[‘’]/g,"'").replace(/[^\x20-\xFF]/g,'?'); }
|
||||
function escapePdf(value: string): string { return clean(value).replaceAll('\\','\\\\').replaceAll('(','\\(').replaceAll(')','\\)'); }
|
||||
function wrap(value: string, max=92): string[] { const words=clean(value).split(/\s+/).filter(Boolean); const out:string[]=[]; let line=''; for(const word of words){ const next=line?`${line} ${word}`:word; if(next.length>max&&line){out.push(line);line=word;}else line=next;} if(line)out.push(line); return out.length?out:['-']; }
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
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[] {
|
||||
const prepared=asRecord(asRecord(snapshot).preparedSnapshot);
|
||||
const act=asRecord(prepared.act);
|
||||
const visit=asRecord(act.visit);
|
||||
const responsible=asRecord(prepared.responsible);
|
||||
const team=asArray(prepared.team);
|
||||
const assets=asArray(prepared.assets);
|
||||
const findings=asArray(prepared.findings);
|
||||
const signatures=asArray(snapshot.signatures);
|
||||
const companySignature=signatures.find(item=>text(item.signerType,'')==='COMPANY_RESPONSIBLE');
|
||||
const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))];
|
||||
const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))];
|
||||
const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean);
|
||||
const out:string[]=[
|
||||
const source = lockedSnapshot(snapshot);
|
||||
const locked = source.locked;
|
||||
const act = asRecord(locked.act);
|
||||
const inspection = asRecord(act.inspection ?? asRecord(act).visit);
|
||||
const responsible = asRecord(locked.responsible);
|
||||
const inventories = asArray(locked.inventories ?? locked.assets);
|
||||
const findings = asArray(locked.findings);
|
||||
const signatures = source.signatures;
|
||||
const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE');
|
||||
const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR');
|
||||
|
||||
const deadlineText = act.deadlineAt
|
||||
? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})`
|
||||
: act.deadlineBasis === 'GEDO_DATE'
|
||||
? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO`
|
||||
: '-';
|
||||
|
||||
const out: string[] = [
|
||||
'ACTA DE INSPECCION',
|
||||
'Documento automatico - diseno institucional pendiente de definicion',
|
||||
'',
|
||||
`Acta: ${text(act.code)}`,
|
||||
`Inspeccion: ${text(visit.code)}`,
|
||||
`Inspeccion: ${text(inspection.code)}`,
|
||||
`Fecha: ${date(act.occurredAt)}`,
|
||||
`Empresa: ${companies.join(' / ') || 'Segun alcance del acta'}`,
|
||||
`Area: ${areas.join(' / ') || 'Segun alcance del acta'}`,
|
||||
`Inspector/es: ${inspectors.join(' / ') || '-'}`,
|
||||
`Urgencia: ${urgencyLabel(act.urgency)}`,
|
||||
`Plazo: ${deadlineText}`,
|
||||
`Responsable empresa: ${text(responsible.fullName)}`,
|
||||
`Cargo: ${text(responsible.position)}`,
|
||||
'',
|
||||
'OBJETIVO', ...wrap(text(visit.objective)), '',
|
||||
'RESUMEN', ...wrap(text(act.summary)), '',
|
||||
'OBSERVACIONES', ...wrap(text(act.observations)), '',
|
||||
'ELEMENTOS INSPECCIONADOS',
|
||||
'RESUMEN',
|
||||
...wrap(text(act.summary)),
|
||||
'',
|
||||
'OBSERVACIONES',
|
||||
...wrap(text(act.observations)),
|
||||
'',
|
||||
'INVENTARIO INSPECCIONADO',
|
||||
];
|
||||
if(!assets.length) out.push('-');
|
||||
for(const item of assets) out.push(...wrap(`${text(item.code)} | ${text(item.name)}${text(item.commonName,'') ? ` | ${text(item.commonName,'')}` : ''} | ${text(item.typeName)}`));
|
||||
out.push('', 'HALLAZGOS');
|
||||
if(!findings.length) out.push('Sin hallazgos registrados.');
|
||||
for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); }
|
||||
out.push('', 'CONSTANCIA DE LA EMPRESA');
|
||||
if(!companySignature) out.push('Firma o constancia pendiente.');
|
||||
else if(text(companySignature.status,'')==='SIGNED'){
|
||||
const manifestation=text(companySignature.companyManifestation,'CONFORMITY');
|
||||
out.push(manifestation==='DISSENT'?'Firma en disidencia':'Firma en conformidad');
|
||||
if(manifestation==='DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
|
||||
} else {
|
||||
out.push(...wrap(`${text(companySignature.status)}: ${text(companySignature.reason)}`));
|
||||
|
||||
if (!inventories.length) out.push('-');
|
||||
for (const item of inventories) {
|
||||
out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`));
|
||||
}
|
||||
out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`);
|
||||
|
||||
out.push('', 'HALLAZGOS');
|
||||
if (!findings.length) out.push('Sin hallazgos registrados.');
|
||||
for (const item of findings) {
|
||||
const recurrence = item.isRecurrence
|
||||
? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}`
|
||||
: '';
|
||||
out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`));
|
||||
out.push(...wrap(`Descripcion: ${text(item.description)}`));
|
||||
if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`));
|
||||
}
|
||||
|
||||
out.push('', 'FIRMAS Y CONSTANCIAS');
|
||||
if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.');
|
||||
for (const signature of inspectorSignatures) {
|
||||
out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`));
|
||||
}
|
||||
if (!companySignature) {
|
||||
out.push('Manifestacion de empresa: pendiente.');
|
||||
} else if (text(companySignature.status, '') === 'SIGNED') {
|
||||
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY');
|
||||
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disidencia' : 'Empresa: firma en conformidad');
|
||||
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
|
||||
} else {
|
||||
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
|
||||
}
|
||||
|
||||
out.push(
|
||||
'',
|
||||
'INTEGRIDAD',
|
||||
`Hash del Acta sellada: ${text(source.finalSha256)}`,
|
||||
`Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function objectBuffer(id: number, body: Buffer|string): Buffer { const data=Buffer.isBuffer(body)?body:Buffer.from(body,'latin1'); return Buffer.concat([Buffer.from(`${id} 0 obj\n`,'ascii'),data,Buffer.from('\nendobj\n','ascii')]); }
|
||||
function objectBuffer(id: number, body: Buffer | string): Buffer {
|
||||
const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
|
||||
return Buffer.concat([
|
||||
Buffer.from(`${id} 0 obj\n`, 'ascii'),
|
||||
data,
|
||||
Buffer.from('\nendobj\n', 'ascii'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } {
|
||||
const all=lines(snapshot);
|
||||
const chunks:Array<string[]>=[];
|
||||
for(let i=0;i<all.length;i+=56) chunks.push(all.slice(i,i+56));
|
||||
if(!chunks.length) chunks.push(['ACTA DE INSPECCION']);
|
||||
const pageCount=chunks.length;
|
||||
const pageIds=Array.from({length:pageCount},(_,i)=>4+i*2);
|
||||
const contentIds=Array.from({length:pageCount},(_,i)=>5+i*2);
|
||||
const objects:Buffer[]=[];
|
||||
objects.push(objectBuffer(1,'<< /Type /Catalog /Pages 2 0 R >>'));
|
||||
objects.push(objectBuffer(2,`<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map(id=>`${id} 0 R`).join(' ')}] >>`));
|
||||
objects.push(objectBuffer(3,'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
|
||||
chunks.forEach((chunk,index)=>{
|
||||
const content=chunk.map((line,i)=>`${i===0?'':'T* '}(${escapePdf(line)}) Tj`).join('\n');
|
||||
const stream=Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`,'latin1');
|
||||
objects.push(objectBuffer(pageIds[index]!,`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`));
|
||||
objects.push(objectBuffer(contentIds[index]!,Buffer.concat([Buffer.from(`<< /Length ${stream.length} >>\nstream\n`,'ascii'),stream,Buffer.from('\nendstream','ascii')])));
|
||||
const all = lines(snapshot);
|
||||
const chunks: Array<string[]> = [];
|
||||
for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56));
|
||||
if (!chunks.length) chunks.push(['ACTA DE INSPECCION']);
|
||||
|
||||
const pageCount = chunks.length;
|
||||
const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2);
|
||||
const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2);
|
||||
const objects: Buffer[] = [];
|
||||
objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>'));
|
||||
objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`));
|
||||
objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
const content = chunk
|
||||
.map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`)
|
||||
.join('\n');
|
||||
const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1');
|
||||
objects.push(objectBuffer(
|
||||
pageIds[index]!,
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`,
|
||||
));
|
||||
objects.push(objectBuffer(
|
||||
contentIds[index]!,
|
||||
Buffer.concat([
|
||||
Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'),
|
||||
stream,
|
||||
Buffer.from('\nendstream', 'ascii'),
|
||||
]),
|
||||
));
|
||||
});
|
||||
const header=Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n','binary');
|
||||
const offsets:number[]=[0]; let pos=header.length;
|
||||
for(const obj of objects){ offsets.push(pos); pos+=obj.length; }
|
||||
const xrefOffset=pos;
|
||||
const xref=[`xref\n0 ${objects.length+1}\n`,`0000000000 65535 f \n`,...objects.map((_,i)=>`${String(offsets[i+1]).padStart(10,'0')} 00000 n \n`)].join('');
|
||||
const trailer=`trailer\n<< /Size ${objects.length+1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
const buffer=Buffer.concat([header,...objects,Buffer.from(xref+trailer,'ascii')]);
|
||||
return { buffer, sha256:createHash('sha256').update(buffer).digest('hex') };
|
||||
|
||||
const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary');
|
||||
const offsets: number[] = [0];
|
||||
let position = header.length;
|
||||
for (const object of objects) {
|
||||
offsets.push(position);
|
||||
position += object.length;
|
||||
}
|
||||
const xrefOffset = position;
|
||||
const xref = [
|
||||
`xref\n0 ${objects.length + 1}\n`,
|
||||
'0000000000 65535 f \n',
|
||||
...objects.map((_, index) => `${String(offsets[index + 1]).padStart(10, '0')} 00000 n \n`),
|
||||
].join('');
|
||||
const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
const buffer = Buffer.concat([header, ...objects, Buffer.from(xref + trailer, 'ascii')]);
|
||||
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
|
||||
}
|
||||
|
||||
@@ -8,20 +8,112 @@ import { buildInspectionActPdf } from './inspection-act-pdf-builder';
|
||||
|
||||
@Injectable()
|
||||
export class InspectionActPdfService {
|
||||
private readonly root:string;
|
||||
constructor(private readonly dataSource:DataSource, config:ConfigService){ const configured=config.get<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'); }
|
||||
private readonly root: string;
|
||||
|
||||
async ensure(actId:string):Promise<void>{
|
||||
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}>;
|
||||
if(!row)throw new NotFoundException({code:'INSPECTION_ACT_NOT_CLOSED',message:'El acta cerrada no está disponible'});
|
||||
if(row.status==='READY'&&row.storedName){ try{ await this.content(actId); return; }catch{} }
|
||||
await this.dataSource.query(`INSERT INTO inspection_act_pdf_artifacts (act_id,status) VALUES ($1,'PENDING') ON CONFLICT (act_id) DO UPDATE SET status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP`,[actId]);
|
||||
try{
|
||||
const snapshot={...row.finalSnapshot,finalSha256:row.closureSha256}; const built=buildInspectionActPdf(snapshot); await mkdir(this.root,{recursive:true,mode:0o700}); const storedName=`${actId}.pdf`; const originalName=`${row.code}.pdf`; const filePath=resolve(this.root,storedName); await writeFile(filePath,built.buffer,{mode:0o600});
|
||||
await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf',size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,originalName,storedName,built.buffer.length,built.sha256]);
|
||||
}catch(error){ const message=error instanceof Error?error.message.slice(0,500):'Error desconocido'; await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,message]).catch(()=>undefined); }
|
||||
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
||||
const configured = config.get<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 content(actId:string):Promise<{buffer:Buffer;originalName:string;mimeType:string}>{ const [row]=await this.dataSource.query(`SELECT original_name AS "originalName",stored_name AS "storedName",mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 FROM inspection_act_pdf_artifacts WHERE act_id=$1 AND status='READY'`,[actId]) as Array<{originalName:string;storedName:string;mimeType:string;sizeBytes:number;sha256:string}>; if(!row)throw new NotFoundException({code:'INSPECTION_ACT_PDF_NOT_READY',message:'El PDF del acta todavía no está disponible'}); const filePath=resolve(this.root,row.storedName); if(!filePath.startsWith(`${this.root}/`))throw this.storageError(); const st=await stat(filePath).catch(()=>null); if(!st?.isFile()||st.size!==row.sizeBytes)throw this.storageError(); const buffer=await readFile(filePath); if(createHash('sha256').update(buffer).digest('hex')!==row.sha256)throw this.storageError(); return {buffer,originalName:row.originalName,mimeType:row.mimeType}; }
|
||||
private storageError(){return new InternalServerErrorException({code:'INSPECTION_ACT_PDF_STORAGE_ERROR',message:'El PDF del acta no está disponible o no supera la validación de integridad'});}
|
||||
async ensure(actId: string): Promise<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
@@ -9,7 +10,7 @@ import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
|
||||
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR' | 'DIRECTOR';
|
||||
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR';
|
||||
|
||||
export interface DeliveryRow {
|
||||
id: string;
|
||||
@@ -34,18 +35,19 @@ export class InspectionDocumentDeliveryService {
|
||||
private readonly word: InspectionReportWordService,
|
||||
private readonly smtp: SmtpDeliveryService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async settings() {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT office_email AS "officeEmail", updated_at AS "updatedAt"
|
||||
SELECT office_email AS "officeEmail",updated_at AS "updatedAt"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id = 1
|
||||
WHERE id=1
|
||||
`) as Array<{ officeEmail: string | null; updatedAt: Date }>;
|
||||
return {
|
||||
...row,
|
||||
smtpConfigured: this.smtp.configured(),
|
||||
smtpSource: this.smtp.activeSource(),
|
||||
smtpConfigured: await this.smtp.configured(),
|
||||
mailFrom: await this.smtp.fromAddress() ?? this.config.get<string>('MAIL_FROM') ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,10 +59,8 @@ export class InspectionDocumentDeliveryService {
|
||||
const before = await this.settings();
|
||||
await this.dataSource.query(`
|
||||
UPDATE institutional_delivery_settings
|
||||
SET office_email = $1,
|
||||
updated_by = $2,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
SET office_email=$1,updated_by=$2,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=1
|
||||
`, [dto.officeEmail ?? null, principal.userId]);
|
||||
const after = await this.settings();
|
||||
await this.audit.record({
|
||||
@@ -77,34 +77,24 @@ export class InspectionDocumentDeliveryService {
|
||||
async list() {
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
delivery.id,
|
||||
delivery.act_id AS "actId",
|
||||
delivery.report_id AS "reportId",
|
||||
delivery.document_kind AS "documentKind",
|
||||
delivery.recipient_kind AS "recipientKind",
|
||||
delivery.recipient_asset_id AS "recipientAssetId",
|
||||
delivery.recipient_user_id AS "recipientUserId",
|
||||
delivery.recipient_email AS "recipientEmail",
|
||||
delivery.status,
|
||||
delivery.attempts,
|
||||
delivery.last_attempt_at AS "lastAttemptAt",
|
||||
delivery.sent_at AS "sentAt",
|
||||
delivery.provider_message_id AS "providerMessageId",
|
||||
delivery.last_error AS "lastError",
|
||||
delivery.created_at AS "createdAt",
|
||||
act.code AS "actCode",
|
||||
report.code AS "reportCode",
|
||||
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||
d.last_attempt_at AS "lastAttemptAt",d.sent_at AS "sentAt",
|
||||
d.provider_message_id AS "providerMessageId",d.last_error AS "lastError",
|
||||
d.created_at AS "createdAt",a.code AS "actCode",r.code AS "reportCode",
|
||||
recipient.name AS "recipientAssetName",
|
||||
CASE
|
||||
WHEN recipient_user.id IS NULL THEN NULL
|
||||
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
|
||||
CASE WHEN recipient_user.id IS NULL THEN NULL
|
||||
ELSE btrim(concat_ws(' ',recipient_user.first_name,recipient_user.last_name))
|
||||
END AS "recipientUserName"
|
||||
FROM inspection_document_deliveries delivery
|
||||
JOIN inspection_acts act ON act.id = delivery.act_id
|
||||
LEFT JOIN inspection_reports report ON report.id = delivery.report_id
|
||||
LEFT JOIN assets recipient ON recipient.id = delivery.recipient_asset_id
|
||||
LEFT JOIN users recipient_user ON recipient_user.id = delivery.recipient_user_id
|
||||
ORDER BY delivery.created_at DESC
|
||||
FROM inspection_document_deliveries d
|
||||
JOIN inspection_acts a ON a.id=d.act_id
|
||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||
LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id
|
||||
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id
|
||||
WHERE d.recipient_kind<>'DIRECTOR'
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT 200
|
||||
`);
|
||||
return { data };
|
||||
@@ -113,14 +103,13 @@ export class InspectionDocumentDeliveryService {
|
||||
async dispatchForAct(actId: string): Promise<void> {
|
||||
await this.pdf.ensure(actId).catch(() => undefined);
|
||||
const [report] = await this.dataSource.query(
|
||||
`SELECT id FROM inspection_reports WHERE act_id = $1`,
|
||||
`SELECT id FROM inspection_reports WHERE act_id=$1`,
|
||||
[actId],
|
||||
) as Array<{ id: string }>;
|
||||
if (report) await this.word.ensure(report.id);
|
||||
await this.ensureRows(actId, report?.id ?? null);
|
||||
for (const row of await this.rowsForAct(actId)) {
|
||||
await this.attempt(row).catch(() => undefined);
|
||||
}
|
||||
const rows = await this.rowsForAct(actId);
|
||||
for (const row of rows) await this.attempt(row).catch(() => undefined);
|
||||
}
|
||||
|
||||
async retry(
|
||||
@@ -148,91 +137,67 @@ export class InspectionDocumentDeliveryService {
|
||||
|
||||
async retryPending(principal: AuthPrincipal, request: RequestWithContext) {
|
||||
const rows = await this.dataSource.query(`
|
||||
SELECT id
|
||||
FROM inspection_document_deliveries
|
||||
WHERE status <> 'SENT'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 100
|
||||
SELECT id FROM inspection_document_deliveries
|
||||
WHERE status<>'SENT' AND recipient_kind<>'DIRECTOR'
|
||||
ORDER BY created_at ASC LIMIT 100
|
||||
`) as Array<{ id: string }>;
|
||||
for (const item of rows) {
|
||||
await this.retry(item.id, principal, request).catch(() => undefined);
|
||||
}
|
||||
for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined);
|
||||
return { processed: rows.length };
|
||||
}
|
||||
|
||||
private async ensureRows(actId: string, reportId: string | null) {
|
||||
const [settings] = await this.dataSource.query(`
|
||||
SELECT office_email AS "officeEmail"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id = 1
|
||||
FROM institutional_delivery_settings WHERE id=1
|
||||
`) as Array<{ officeEmail: string | null }>;
|
||||
|
||||
const companies = await this.dataSource.query(`
|
||||
SELECT DISTINCT company.id, profile.notification_email AS email
|
||||
SELECT DISTINCT company.id,profile.notification_email AS email
|
||||
FROM inspection_act_assets link
|
||||
JOIN assets asset ON asset.id = link.asset_id
|
||||
JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
JOIN assets company ON company.id = COALESCE(
|
||||
JOIN assets asset ON asset.id=link.asset_id
|
||||
JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
|
||||
JOIN assets company ON company.id=COALESCE(
|
||||
asset.operator_company_id,
|
||||
CASE WHEN asset_type.operational_role = 'COMPANY' THEN asset.id END
|
||||
CASE WHEN asset_type.operational_role='COMPANY' THEN asset.id END
|
||||
)
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id = company.id
|
||||
WHERE link.act_id = $1 AND link.included = true
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
WHERE link.act_id=$1 AND link.included=true
|
||||
`, [actId]) as Array<{ id: string; email: string | null }>;
|
||||
|
||||
const [inspector] = await this.dataSource.query(`
|
||||
SELECT inspector.id, inspector.email
|
||||
SELECT inspector.id,inspector.email
|
||||
FROM inspection_acts act
|
||||
JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
JOIN users inspector ON inspector.id = visit.lead_inspector_user_id
|
||||
WHERE act.id = $1
|
||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id
|
||||
WHERE act.id=$1
|
||||
`, [actId]) as Array<{ id: string; email: string | null }>;
|
||||
|
||||
for (const company of companies) {
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'ACT_PDF',
|
||||
recipientKind: 'COMPANY',
|
||||
recipientAssetId: company.id,
|
||||
recipientUserId: null,
|
||||
recipientKey: company.id,
|
||||
recipientEmail: company.email,
|
||||
actId,reportId,documentKind:'ACT_PDF',recipientKind:'COMPANY',
|
||||
recipientAssetId:company.id,recipientUserId:null,recipientKey:company.id,
|
||||
recipientEmail:company.email,
|
||||
});
|
||||
}
|
||||
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'ACT_PDF',
|
||||
recipientKind: 'OFFICE',
|
||||
recipientAssetId: null,
|
||||
recipientUserId: null,
|
||||
recipientKey: '00000000-0000-0000-0000-000000000000',
|
||||
recipientEmail: settings?.officeEmail ?? null,
|
||||
actId,reportId,documentKind:'ACT_PDF',recipientKind:'OFFICE',
|
||||
recipientAssetId:null,recipientUserId:null,
|
||||
recipientKey:'00000000-0000-0000-0000-000000000000',
|
||||
recipientEmail:settings?.officeEmail ?? null,
|
||||
});
|
||||
|
||||
if (inspector) {
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'ACT_PDF',
|
||||
recipientKind: 'INSPECTOR',
|
||||
recipientAssetId: null,
|
||||
recipientUserId: inspector.id,
|
||||
recipientKey: inspector.id,
|
||||
recipientEmail: inspector.email,
|
||||
actId,reportId,documentKind:'ACT_PDF',recipientKind:'INSPECTOR',
|
||||
recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id,
|
||||
recipientEmail:inspector.email,
|
||||
});
|
||||
if (reportId) {
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'REPORT_WORD',
|
||||
recipientKind: 'INSPECTOR',
|
||||
recipientAssetId: null,
|
||||
recipientUserId: inspector.id,
|
||||
recipientKey: inspector.id,
|
||||
recipientEmail: inspector.email,
|
||||
actId,reportId,documentKind:'REPORT_WORD',recipientKind:'INSPECTOR',
|
||||
recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id,
|
||||
recipientEmail:inspector.email,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -242,7 +207,7 @@ export class InspectionDocumentDeliveryService {
|
||||
actId: string;
|
||||
reportId: string | null;
|
||||
documentKind: 'ACT_PDF' | 'REPORT_WORD';
|
||||
recipientKind: Exclude<DeliveryRecipientKind, 'DIRECTOR'>;
|
||||
recipientKind: DeliveryRecipientKind;
|
||||
recipientAssetId: string | null;
|
||||
recipientUserId: string | null;
|
||||
recipientKey: string;
|
||||
@@ -251,83 +216,57 @@ export class InspectionDocumentDeliveryService {
|
||||
const initialStatus = input.recipientEmail ? 'PENDING' : 'WAITING_RECIPIENT';
|
||||
await this.dataSource.query(`
|
||||
INSERT INTO inspection_document_deliveries (
|
||||
act_id, report_id, document_kind, recipient_kind,
|
||||
recipient_asset_id, recipient_user_id, recipient_key, recipient_email, status
|
||||
act_id,report_id,document_kind,recipient_kind,
|
||||
recipient_asset_id,recipient_user_id,recipient_key,recipient_email,status
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid,$8,$9)
|
||||
ON CONFLICT (act_id, document_kind, recipient_kind, recipient_key) DO UPDATE SET
|
||||
report_id = COALESCE(EXCLUDED.report_id, inspection_document_deliveries.report_id),
|
||||
recipient_asset_id = COALESCE(EXCLUDED.recipient_asset_id, inspection_document_deliveries.recipient_asset_id),
|
||||
recipient_user_id = COALESCE(EXCLUDED.recipient_user_id, inspection_document_deliveries.recipient_user_id),
|
||||
recipient_email = CASE
|
||||
WHEN inspection_document_deliveries.status = 'SENT' THEN inspection_document_deliveries.recipient_email
|
||||
ELSE EXCLUDED.recipient_email
|
||||
END,
|
||||
status = CASE
|
||||
WHEN inspection_document_deliveries.status = 'SENT' THEN 'SENT'
|
||||
ON CONFLICT (act_id,document_kind,recipient_kind,recipient_key) DO UPDATE SET
|
||||
report_id=COALESCE(EXCLUDED.report_id,inspection_document_deliveries.report_id),
|
||||
recipient_asset_id=COALESCE(EXCLUDED.recipient_asset_id,inspection_document_deliveries.recipient_asset_id),
|
||||
recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id),
|
||||
recipient_email=CASE
|
||||
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email
|
||||
ELSE EXCLUDED.recipient_email END,
|
||||
status=CASE
|
||||
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT'
|
||||
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
|
||||
ELSE inspection_document_deliveries.status
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
ELSE inspection_document_deliveries.status END,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
`, [
|
||||
input.actId,
|
||||
input.reportId,
|
||||
input.documentKind,
|
||||
input.recipientKind,
|
||||
input.recipientAssetId,
|
||||
input.recipientUserId,
|
||||
input.recipientKey,
|
||||
input.recipientEmail,
|
||||
initialStatus,
|
||||
input.actId,input.reportId,input.documentKind,input.recipientKind,
|
||||
input.recipientAssetId,input.recipientUserId,input.recipientKey,input.recipientEmail,initialStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
|
||||
return this.dataSource.query(`
|
||||
SELECT
|
||||
delivery.id,
|
||||
delivery.act_id AS "actId",
|
||||
delivery.report_id AS "reportId",
|
||||
delivery.document_kind AS "documentKind",
|
||||
delivery.recipient_kind AS "recipientKind",
|
||||
delivery.recipient_asset_id AS "recipientAssetId",
|
||||
delivery.recipient_user_id AS "recipientUserId",
|
||||
delivery.recipient_email AS "recipientEmail",
|
||||
delivery.status,
|
||||
delivery.attempts,
|
||||
act.code AS "actCode",
|
||||
report.code AS "reportCode"
|
||||
FROM inspection_document_deliveries delivery
|
||||
JOIN inspection_acts act ON act.id = delivery.act_id
|
||||
LEFT JOIN inspection_reports report ON report.id = delivery.report_id
|
||||
WHERE delivery.act_id = $1
|
||||
ORDER BY delivery.created_at
|
||||
SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||
a.code AS "actCode",r.code AS "reportCode"
|
||||
FROM inspection_document_deliveries d
|
||||
JOIN inspection_acts a ON a.id=d.act_id
|
||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||
WHERE d.act_id=$1 AND d.recipient_kind<>'DIRECTOR'
|
||||
ORDER BY d.created_at
|
||||
`, [actId]) as Promise<DeliveryRow[]>;
|
||||
}
|
||||
|
||||
private async load(id: string): Promise<DeliveryRow> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT
|
||||
delivery.id,
|
||||
delivery.act_id AS "actId",
|
||||
delivery.report_id AS "reportId",
|
||||
delivery.document_kind AS "documentKind",
|
||||
delivery.recipient_kind AS "recipientKind",
|
||||
delivery.recipient_asset_id AS "recipientAssetId",
|
||||
delivery.recipient_user_id AS "recipientUserId",
|
||||
delivery.recipient_email AS "recipientEmail",
|
||||
delivery.status,
|
||||
delivery.attempts,
|
||||
act.code AS "actCode",
|
||||
report.code AS "reportCode"
|
||||
FROM inspection_document_deliveries delivery
|
||||
JOIN inspection_acts act ON act.id = delivery.act_id
|
||||
LEFT JOIN inspection_reports report ON report.id = delivery.report_id
|
||||
WHERE delivery.id = $1
|
||||
SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||
a.code AS "actCode",r.code AS "reportCode"
|
||||
FROM inspection_document_deliveries d
|
||||
JOIN inspection_acts a ON a.id=d.act_id
|
||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||
WHERE d.id=$1 AND d.recipient_kind<>'DIRECTOR'
|
||||
`, [id]) as DeliveryRow[];
|
||||
if (!row) {
|
||||
throw new NotFoundException({
|
||||
code: 'DOCUMENT_DELIVERY_NOT_FOUND',
|
||||
message: 'Entrega documental no encontrada',
|
||||
code:'DOCUMENT_DELIVERY_NOT_FOUND',message:'Entrega documental no encontrada',
|
||||
});
|
||||
}
|
||||
return row;
|
||||
@@ -337,54 +276,37 @@ export class InspectionDocumentDeliveryService {
|
||||
let email: string | null = null;
|
||||
if (row.recipientKind === 'COMPANY' && row.recipientAssetId) {
|
||||
const [company] = await this.dataSource.query(`
|
||||
SELECT notification_email AS email
|
||||
FROM organization_profiles
|
||||
WHERE asset_id = $1
|
||||
SELECT notification_email AS email FROM organization_profiles WHERE asset_id=$1
|
||||
`, [row.recipientAssetId]) as Array<{ email: string | null }>;
|
||||
email = company?.email ?? null;
|
||||
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
|
||||
const [inspector] = await this.dataSource.query(`
|
||||
SELECT email
|
||||
FROM users
|
||||
WHERE id = $1 AND is_active = true
|
||||
SELECT email FROM users WHERE id=$1 AND is_active=true
|
||||
`, [row.recipientUserId]) as Array<{ email: string | null }>;
|
||||
email = inspector?.email ?? null;
|
||||
} else if (row.recipientKind === 'OFFICE') {
|
||||
} else {
|
||||
const [settings] = await this.dataSource.query(`
|
||||
SELECT office_email AS "officeEmail"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id = 1
|
||||
SELECT office_email AS "officeEmail" FROM institutional_delivery_settings WHERE id=1
|
||||
`) as Array<{ officeEmail: string | null }>;
|
||||
email = settings?.officeEmail ?? null;
|
||||
} else if (row.recipientKind === 'DIRECTOR' && row.documentKind === 'REPORT_WORD') {
|
||||
const [inspector] = await this.dataSource.query(`
|
||||
SELECT user_account.email
|
||||
FROM inspection_acts act
|
||||
JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
JOIN users user_account ON user_account.id = visit.lead_inspector_user_id
|
||||
WHERE act.id = $1
|
||||
`, [row.actId]) as Array<{ email: string | null }>;
|
||||
email = inspector?.email ?? null;
|
||||
}
|
||||
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_document_deliveries
|
||||
SET recipient_email = $2,
|
||||
status = CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
|
||||
last_error = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1 AND status <> 'SENT'
|
||||
`, [row.id, email]);
|
||||
SET recipient_email=$2,
|
||||
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
|
||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1 AND status<>'SENT'
|
||||
`, [row.id,email]);
|
||||
}
|
||||
|
||||
private async attempt(row: DeliveryRow) {
|
||||
if (row.status === 'SENT') return;
|
||||
if (!row.recipientEmail) {
|
||||
await this.setStatus(row.id, 'WAITING_RECIPIENT', 'Destinatario no configurado');
|
||||
await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');
|
||||
return;
|
||||
}
|
||||
if (!this.smtp.configured()) {
|
||||
await this.setStatus(row.id, 'WAITING_TRANSPORT', 'SMTP no configurado');
|
||||
if (!await this.smtp.configured()) {
|
||||
await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,100 +315,61 @@ export class InspectionDocumentDeliveryService {
|
||||
if (row.documentKind === 'ACT_PDF') {
|
||||
await this.pdf.ensure(row.actId);
|
||||
const file = await this.pdf.content(row.actId);
|
||||
attachment = {
|
||||
filename: file.originalName,
|
||||
mimeType: file.mimeType,
|
||||
content: file.buffer,
|
||||
};
|
||||
attachment = { filename:file.originalName,mimeType:file.mimeType,content:file.buffer };
|
||||
} else {
|
||||
if (!row.reportId) throw new Error('Informe no vinculado');
|
||||
await this.word.ensure(row.reportId);
|
||||
const file = await this.word.content(row.reportId);
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
attachment = {
|
||||
filename: file.originalName,
|
||||
mimeType: file.mimeType,
|
||||
content: await readFile(file.filePath),
|
||||
filename:file.originalName,mimeType:file.mimeType,content:await readFile(file.filePath),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
await this.setStatus(
|
||||
row.id,
|
||||
'WAITING_ARTIFACT',
|
||||
error instanceof Error ? error.message : 'Documento no disponible',
|
||||
);
|
||||
await this.setStatus(row.id,'WAITING_ARTIFACT',error instanceof Error ? error.message : 'Documento no disponible');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_document_deliveries
|
||||
SET attempts = attempts + 1,
|
||||
last_attempt_at = CURRENT_TIMESTAMP,
|
||||
status = 'PENDING',
|
||||
last_error = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [row.id]);
|
||||
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',
|
||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
`,[row.id]);
|
||||
|
||||
try {
|
||||
const label = row.documentKind === 'ACT_PDF'
|
||||
? `Acta ${row.actCode}`
|
||||
: `Informe ${row.reportCode ?? ''}`;
|
||||
const text = row.documentKind === 'REPORT_WORD'
|
||||
? `Se adjunta el Informe Word automático ${row.reportCode ?? ''} para revisión y edición del inspector responsable antes de su carga en GEDO.`
|
||||
? `Se adjunta el INF editable ${row.reportCode ?? ''} para su revisión y preparación antes de cargarlo en GEDO.`
|
||||
: row.recipientKind === 'INSPECTOR'
|
||||
? `Se adjunta copia del Acta ${row.actCode} correspondiente a tu inspección.`
|
||||
: `Se adjunta el Acta ${row.actCode}.`;
|
||||
? `Se adjunta copia del acta sellada e inmutable ${row.actCode} correspondiente a tu inspección.`
|
||||
: `Se adjunta el acta sellada e inmutable ${row.actCode}.`;
|
||||
const sent = await this.smtp.send({
|
||||
to: row.recipientEmail,
|
||||
subject: `DH Inspección · ${label}`,
|
||||
text,
|
||||
attachment,
|
||||
to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
|
||||
});
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_document_deliveries
|
||||
SET status = 'SENT',
|
||||
sent_at = CURRENT_TIMESTAMP,
|
||||
provider_message_id = $2,
|
||||
last_error = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [row.id, sent.messageId]);
|
||||
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
|
||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1
|
||||
`,[row.id,sent.messageId]);
|
||||
await this.audit.record({
|
||||
action: AuditAction.DOCUMENT_DELIVERY_SENT,
|
||||
entityType: 'inspection_document_delivery',
|
||||
entityId: row.id,
|
||||
source: AuditSource.SYSTEM,
|
||||
actorUserId: null,
|
||||
actorUsername: null,
|
||||
requestId: null,
|
||||
ip: null,
|
||||
userAgent: null,
|
||||
beforeData: null,
|
||||
afterData: {
|
||||
recipientKind: row.recipientKind,
|
||||
recipientUserId: row.recipientUserId,
|
||||
documentKind: row.documentKind,
|
||||
status: 'SENT',
|
||||
},
|
||||
metadata: { actId: row.actId, reportId: row.reportId },
|
||||
action:AuditAction.DOCUMENT_DELIVERY_SENT,
|
||||
entityType:'inspection_document_delivery',entityId:row.id,source:AuditSource.SYSTEM,
|
||||
actorUserId:null,actorUsername:null,requestId:null,ip:null,userAgent:null,beforeData:null,
|
||||
afterData:{recipientKind:row.recipientKind,recipientUserId:row.recipientUserId,documentKind:row.documentKind,status:'SENT'},
|
||||
metadata:{actId:row.actId,reportId:row.reportId},
|
||||
});
|
||||
} catch (error) {
|
||||
await this.setStatus(
|
||||
row.id,
|
||||
'FAILED',
|
||||
error instanceof Error ? error.message : 'Error de entrega',
|
||||
);
|
||||
await this.setStatus(row.id,'FAILED',error instanceof Error ? error.message : 'Error de entrega');
|
||||
}
|
||||
}
|
||||
|
||||
private async setStatus(id: string, status: string, error: string) {
|
||||
private async setStatus(id: string,status: string,error: string) {
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_document_deliveries
|
||||
SET status = $2,
|
||||
last_error = $3,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [id, status, error.slice(0, 500)]);
|
||||
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$1
|
||||
`,[id,status,error.slice(0,500)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export const MAX_REPORT_DOSSIER_PDF_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
export interface UploadedReportDossierFile {
|
||||
buffer: Buffer;
|
||||
originalname: string;
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface InspectedReportDossierPdf {
|
||||
originalName: string;
|
||||
mimeType: 'application/pdf';
|
||||
extension: '.pdf';
|
||||
}
|
||||
|
||||
export function inspectReportDossierPdf(
|
||||
file: UploadedReportDossierFile | undefined,
|
||||
): InspectedReportDossierPdf {
|
||||
if (!file?.buffer?.length || file.size <= 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_PDF_REQUIRED',
|
||||
message: 'Debés adjuntar un archivo PDF no vacío',
|
||||
});
|
||||
}
|
||||
if (file.size > MAX_REPORT_DOSSIER_PDF_BYTES || file.buffer.length > MAX_REPORT_DOSSIER_PDF_BYTES) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_PDF_TOO_LARGE',
|
||||
message: 'El PDF supera el máximo permitido de 25 MB',
|
||||
});
|
||||
}
|
||||
if (file.buffer.length < 5 || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_PDF_INVALID',
|
||||
message: 'El archivo adjunto no tiene una estructura PDF válida',
|
||||
});
|
||||
}
|
||||
const originalName = file.originalname
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!originalName) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_PDF_INVALID_NAME',
|
||||
message: 'El nombre original del PDF no es válido',
|
||||
});
|
||||
}
|
||||
return { originalName, mimeType: 'application/pdf', extension: '.pdf' };
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor, FilesInterceptor } 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 { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
||||
import { OfficializeInspectionReportGedoDto } from './dto/officialize-inspection-report-gedo.dto';
|
||||
import { UpdateInspectionReportContentDto } from './dto/update-inspection-report-content.dto';
|
||||
import {
|
||||
MAX_REPORT_DOSSIER_PDF_BYTES,
|
||||
type UploadedReportDossierFile,
|
||||
} from './inspection-report-dossier-file';
|
||||
import { InspectionReportDossierService } from './inspection-report-dossier.service';
|
||||
|
||||
@Controller('inspection-reports/:reportId/dossier')
|
||||
export class InspectionReportDossierController {
|
||||
constructor(private readonly dossier: InspectionReportDossierService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string) {
|
||||
return this.dossier.get(reportId);
|
||||
}
|
||||
|
||||
@Patch('content')
|
||||
@RequirePermissions('inspection_reports.edit')
|
||||
updateContent(
|
||||
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
|
||||
@Body() dto: UpdateInspectionReportContentDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.dossier.updateContent(reportId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('gedo')
|
||||
@RequirePermissions('inspection_reports.officialize')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_REPORT_DOSSIER_PDF_BYTES, files: 1 },
|
||||
}))
|
||||
officializeGedo(
|
||||
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
|
||||
@Body() dto: OfficializeInspectionReportGedoDto,
|
||||
@UploadedFile() file: UploadedReportDossierFile | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.dossier.officializeGedo(reportId, dto, file, principal, request);
|
||||
}
|
||||
|
||||
@Get('gedo/pdf')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
async gedoPdf(
|
||||
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
const content = await this.dossier.gedoPdfContent(reportId);
|
||||
response.setHeader('Content-Type', 'application/pdf');
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Get('follow-ups')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
followUps(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string) {
|
||||
return this.dossier.listFollowUps(reportId);
|
||||
}
|
||||
|
||||
@Post('follow-ups')
|
||||
@RequirePermissions('inspection_reports.follow_up')
|
||||
@UseInterceptors(FilesInterceptor('files', 10, {
|
||||
limits: { fileSize: MAX_REPORT_DOSSIER_PDF_BYTES, files: 10 },
|
||||
}))
|
||||
createFollowUp(
|
||||
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
|
||||
@Body() dto: CreateInspectionReportFollowUpDto,
|
||||
@UploadedFiles() files: UploadedReportDossierFile[] | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.dossier.createFollowUp(reportId, dto, files ?? [], principal, request);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('inspection-report-follow-up-files')
|
||||
export class InspectionReportFollowUpFileController {
|
||||
constructor(private readonly dossier: InspectionReportDossierService) {}
|
||||
|
||||
@Get(':fileId/content')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
async content(
|
||||
@Param('fileId', new ParseUUIDPipe({ version: '4' })) fileId: string,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
const content = await this.dossier.followUpFileContent(fileId);
|
||||
response.setHeader('Content-Type', 'application/pdf');
|
||||
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,516 +0,0 @@
|
||||
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,
|
||||
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 {
|
||||
InspectionReport,
|
||||
InspectionReportFollowUp,
|
||||
InspectionReportFollowUpFile,
|
||||
InspectionReportFollowUpType,
|
||||
} from '../database/entities';
|
||||
import { InspectionDeadlinesService } from '../inspection-deadlines/inspection-deadlines.service';
|
||||
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
||||
import type { OfficializeInspectionReportGedoDto } from './dto/officialize-inspection-report-gedo.dto';
|
||||
import type { UpdateInspectionReportContentDto } from './dto/update-inspection-report-content.dto';
|
||||
import {
|
||||
inspectReportDossierPdf,
|
||||
type UploadedReportDossierFile,
|
||||
} from './inspection-report-dossier-file';
|
||||
|
||||
interface ReportContext {
|
||||
id: string;
|
||||
code: string;
|
||||
actId: string;
|
||||
visitId: string;
|
||||
actCode: string;
|
||||
visitCode: string;
|
||||
generatedBy: string;
|
||||
gedoIfIdentifier: string | null;
|
||||
gedoOfficializedOn: string | null;
|
||||
gedoPdfStoredName: string | null;
|
||||
gedoPdfSizeBytes: number | null;
|
||||
gedoPdfSha256: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InspectionReportDossierService {
|
||||
private readonly root: string;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly deadlines: InspectionDeadlinesService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
const configured = config.get<string>('INSPECTION_REPORT_DOSSIER_ROOT')
|
||||
?? '/app/storage/asset-media/inspection-report-dossier';
|
||||
if (!isAbsolute(configured)) {
|
||||
throw new Error('INSPECTION_REPORT_DOSSIER_ROOT must be an absolute path');
|
||||
}
|
||||
this.root = resolve(configured);
|
||||
if (this.root === parse(this.root).root) {
|
||||
throw new Error('INSPECTION_REPORT_DOSSIER_ROOT cannot be the filesystem root');
|
||||
}
|
||||
}
|
||||
|
||||
async get(reportId: string) {
|
||||
await this.requireReport(this.dataSource.manager, reportId);
|
||||
const [report] = await this.dataSource.query(`
|
||||
SELECT
|
||||
report.id,
|
||||
report.code,
|
||||
report.reference_text AS "referenceText",
|
||||
report.general_objective AS "generalObjective",
|
||||
report.specific_objective AS "specificObjective",
|
||||
report.background,
|
||||
report.legal_framework AS "legalFramework",
|
||||
report.executive_summary AS "executiveSummary",
|
||||
report.description,
|
||||
report.conclusion,
|
||||
report.gedo_if_identifier AS "gedoIfIdentifier",
|
||||
report.gedo_officialized_on::text AS "gedoOfficializedOn",
|
||||
report.gedo_pdf_original_name AS "gedoPdfOriginalName",
|
||||
report.gedo_pdf_mime_type AS "gedoPdfMimeType",
|
||||
report.gedo_pdf_size_bytes AS "gedoPdfSizeBytes",
|
||||
report.gedo_pdf_sha256 AS "gedoPdfSha256",
|
||||
report.gedo_pdf_uploaded_at AS "gedoPdfUploadedAt",
|
||||
act.id AS "actId",
|
||||
act.code AS "actCode",
|
||||
act.urgency,
|
||||
act.deadline_days AS "deadlineDays",
|
||||
act.deadline_day_type AS "deadlineDayType",
|
||||
act.deadline_basis AS "deadlineBasis",
|
||||
act.deadline_base_on::text AS "deadlineBaseOn",
|
||||
act.deadline_due_on::text AS "deadlineDueOn",
|
||||
act.status AS "actStatus",
|
||||
visit.id AS "visitId",
|
||||
visit.code AS "visitCode"
|
||||
FROM inspection_reports report
|
||||
INNER JOIN inspection_acts act ON act.id = report.act_id
|
||||
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
|
||||
WHERE report.id = $1
|
||||
`, [reportId]) as Array<Record<string, unknown>>;
|
||||
const followUps = await this.listFollowUps(reportId);
|
||||
return { ...report, followUps: followUps.data };
|
||||
}
|
||||
|
||||
async updateContent(
|
||||
reportId: string,
|
||||
dto: UpdateInspectionReportContentDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const context = await this.requireReport(manager, reportId, true);
|
||||
await this.assertCanWorkReport(manager, context, principal);
|
||||
const repository = manager.getRepository(InspectionReport);
|
||||
const report = await repository.findOne({ where: { id: reportId } });
|
||||
if (!report) throw this.notFound();
|
||||
const before = this.contentSnapshot(report);
|
||||
const fields: Array<keyof UpdateInspectionReportContentDto> = [
|
||||
'referenceText',
|
||||
'generalObjective',
|
||||
'specificObjective',
|
||||
'background',
|
||||
'legalFramework',
|
||||
'executiveSummary',
|
||||
'description',
|
||||
'conclusion',
|
||||
];
|
||||
let changed = false;
|
||||
for (const field of fields) {
|
||||
if (dto[field] !== undefined) {
|
||||
(report as unknown as Record<string, unknown>)[field] = dto[field] ?? null;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_CONTENT_EMPTY_UPDATE',
|
||||
message: 'No se recibió ningún campo del Informe para actualizar',
|
||||
});
|
||||
}
|
||||
await repository.save(report);
|
||||
const after = this.contentSnapshot(report);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: 'INSPECTION_REPORT_CONTENT_UPDATED',
|
||||
entityType: 'inspection_report',
|
||||
entityId: reportId,
|
||||
beforeData: before,
|
||||
afterData: after,
|
||||
metadata: { actId: context.actId, visitId: context.visitId },
|
||||
}, manager);
|
||||
return after;
|
||||
});
|
||||
}
|
||||
|
||||
async officializeGedo(
|
||||
reportId: string,
|
||||
dto: OfficializeInspectionReportGedoDto,
|
||||
file: UploadedReportDossierFile | undefined,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const inspected = inspectReportDossierPdf(file);
|
||||
this.assertDate(dto.officializedOn, 'La fecha de carga en GEDO no es válida');
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
const storedName = `gedo-${reportId}-${randomUUID()}.pdf`;
|
||||
const filePath = this.safePath(storedName);
|
||||
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
||||
await writeFile(filePath, file!.buffer, { mode: 0o600 });
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
const context = await this.requireReport(manager, reportId, true);
|
||||
await this.assertCanWorkReport(manager, context, principal);
|
||||
if (context.gedoIfIdentifier || context.gedoOfficializedOn || context.gedoPdfStoredName) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
|
||||
message: 'Este Informe ya tiene un IF oficial de GEDO y no puede sobrescribirse',
|
||||
});
|
||||
}
|
||||
await manager.query(`
|
||||
UPDATE inspection_reports
|
||||
SET gedo_if_identifier = $2,
|
||||
gedo_officialized_on = $3::date,
|
||||
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,
|
||||
gedo_pdf_uploaded_at = CURRENT_TIMESTAMP,
|
||||
gedo_pdf_uploaded_by = $8,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [
|
||||
reportId,
|
||||
dto.ifIdentifier,
|
||||
dto.officializedOn,
|
||||
inspected.originalName,
|
||||
storedName,
|
||||
file!.buffer.length,
|
||||
sha256,
|
||||
principal.userId,
|
||||
]);
|
||||
const deadlineDueOn = await this.deadlines.applyGedoOfficialization(
|
||||
manager,
|
||||
context.actId,
|
||||
dto.officializedOn,
|
||||
);
|
||||
const after = {
|
||||
gedoIfIdentifier: dto.ifIdentifier,
|
||||
gedoOfficializedOn: dto.officializedOn,
|
||||
gedoPdfOriginalName: inspected.originalName,
|
||||
gedoPdfSizeBytes: file!.buffer.length,
|
||||
gedoPdfSha256: sha256,
|
||||
deadlineDueOn,
|
||||
};
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: 'INSPECTION_REPORT_GEDO_OFFICIALIZED',
|
||||
entityType: 'inspection_report',
|
||||
entityId: reportId,
|
||||
afterData: after,
|
||||
metadata: {
|
||||
actId: context.actId,
|
||||
visitId: context.visitId,
|
||||
officialLegalDocument: true,
|
||||
},
|
||||
}, manager);
|
||||
return after;
|
||||
});
|
||||
} catch (error) {
|
||||
await unlink(filePath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async gedoPdfContent(reportId: string) {
|
||||
const context = await this.requireReport(this.dataSource.manager, reportId);
|
||||
if (!context.gedoPdfStoredName || !context.gedoPdfSizeBytes || !context.gedoPdfSha256) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_REPORT_GEDO_PDF_NOT_FOUND',
|
||||
message: 'El Informe todavía no tiene un PDF oficial de GEDO',
|
||||
});
|
||||
}
|
||||
const filePath = this.safePath(context.gedoPdfStoredName);
|
||||
await this.verifyStoredFile(filePath, context.gedoPdfSizeBytes, context.gedoPdfSha256);
|
||||
return { filePath, originalName: `${context.gedoIfIdentifier ?? context.code}.pdf` };
|
||||
}
|
||||
|
||||
async listFollowUps(reportId: string) {
|
||||
await this.requireReport(this.dataSource.manager, reportId);
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
follow_up.id,
|
||||
follow_up.event_type AS "eventType",
|
||||
follow_up.reference_number AS "referenceNumber",
|
||||
follow_up.occurred_on::text AS "occurredOn",
|
||||
follow_up.description,
|
||||
follow_up.created_by AS "createdBy",
|
||||
follow_up.created_at AS "createdAt",
|
||||
COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'id', file.id,
|
||||
'originalName', file.original_name,
|
||||
'mimeType', file.mime_type,
|
||||
'sizeBytes', file.size_bytes,
|
||||
'sha256', file.sha256,
|
||||
'createdAt', file.created_at
|
||||
) ORDER BY file.created_at, file.id)
|
||||
FROM inspection_report_follow_up_files file
|
||||
WHERE file.follow_up_id = follow_up.id
|
||||
), '[]'::jsonb) AS files
|
||||
FROM inspection_report_follow_ups follow_up
|
||||
WHERE follow_up.report_id = $1
|
||||
ORDER BY follow_up.occurred_on ASC, follow_up.created_at ASC, follow_up.id ASC
|
||||
`, [reportId]);
|
||||
return { data };
|
||||
}
|
||||
|
||||
async createFollowUp(
|
||||
reportId: string,
|
||||
dto: CreateInspectionReportFollowUpDto,
|
||||
files: UploadedReportDossierFile[],
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
this.assertDate(dto.occurredOn, 'La fecha del seguimiento no es válida');
|
||||
if (files.length > 10) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_FOLLOW_UP_TOO_MANY_FILES',
|
||||
message: 'Se permiten hasta 10 archivos PDF por registro de seguimiento',
|
||||
});
|
||||
}
|
||||
if (dto.eventType === InspectionReportFollowUpType.COMPANY_NOTE && files.length < 1) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_REPORT_COMPANY_NOTE_FILE_REQUIRED',
|
||||
message: 'La respuesta formal de la empresa debe conservar al menos el PDF de la Nota recibida',
|
||||
});
|
||||
}
|
||||
const inspectedFiles = files.map((file) => ({ file, inspected: inspectReportDossierPdf(file) }));
|
||||
const followUpId = randomUUID();
|
||||
const stored: Array<{
|
||||
id: string;
|
||||
storedName: string;
|
||||
filePath: string;
|
||||
originalName: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}> = [];
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
for (const item of inspectedFiles) {
|
||||
const id = randomUUID();
|
||||
const storedName = `follow-up-${followUpId}-${id}.pdf`;
|
||||
const filePath = this.safePath(storedName);
|
||||
const sha256 = createHash('sha256').update(item.file.buffer).digest('hex');
|
||||
await writeFile(filePath, item.file.buffer, { mode: 0o600 });
|
||||
stored.push({
|
||||
id,
|
||||
storedName,
|
||||
filePath,
|
||||
originalName: item.inspected.originalName,
|
||||
sizeBytes: item.file.buffer.length,
|
||||
sha256,
|
||||
});
|
||||
}
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const context = await this.requireReport(manager, reportId, true);
|
||||
await this.assertCanWorkReport(manager, context, principal);
|
||||
await manager.getRepository(InspectionReportFollowUp).insert({
|
||||
id: followUpId,
|
||||
reportId,
|
||||
eventType: dto.eventType,
|
||||
referenceNumber: dto.referenceNumber ?? null,
|
||||
occurredOn: dto.occurredOn,
|
||||
description: dto.description ?? null,
|
||||
createdBy: principal.userId,
|
||||
});
|
||||
for (const file of stored) {
|
||||
await manager.getRepository(InspectionReportFollowUpFile).insert({
|
||||
id: file.id,
|
||||
followUpId,
|
||||
originalName: file.originalName,
|
||||
storedName: file.storedName,
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: file.sizeBytes,
|
||||
sha256: file.sha256,
|
||||
createdBy: principal.userId,
|
||||
});
|
||||
}
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: 'INSPECTION_REPORT_FOLLOW_UP_CREATED',
|
||||
entityType: 'inspection_report_follow_up',
|
||||
entityId: followUpId,
|
||||
afterData: {
|
||||
reportId,
|
||||
eventType: dto.eventType,
|
||||
referenceNumber: dto.referenceNumber ?? null,
|
||||
occurredOn: dto.occurredOn,
|
||||
description: dto.description ?? null,
|
||||
files: stored.map((file) => ({
|
||||
id: file.id,
|
||||
originalName: file.originalName,
|
||||
sizeBytes: file.sizeBytes,
|
||||
sha256: file.sha256,
|
||||
})),
|
||||
},
|
||||
metadata: {
|
||||
actId: context.actId,
|
||||
visitId: context.visitId,
|
||||
appendOnly: true,
|
||||
},
|
||||
}, manager);
|
||||
});
|
||||
return this.listFollowUps(reportId);
|
||||
} catch (error) {
|
||||
await Promise.all(stored.map((file) => unlink(file.filePath).catch(() => undefined)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async followUpFileContent(fileId: string) {
|
||||
const [file] = (await this.dataSource.query(`
|
||||
SELECT
|
||||
file.original_name AS "originalName",
|
||||
file.stored_name AS "storedName",
|
||||
file.size_bytes AS "sizeBytes",
|
||||
file.sha256
|
||||
FROM inspection_report_follow_up_files file
|
||||
WHERE file.id = $1
|
||||
`, [fileId])) as Array<{
|
||||
originalName: string;
|
||||
storedName: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
if (!file) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_REPORT_FOLLOW_UP_FILE_NOT_FOUND',
|
||||
message: 'Documento de seguimiento no encontrado',
|
||||
});
|
||||
}
|
||||
const filePath = this.safePath(file.storedName);
|
||||
await this.verifyStoredFile(filePath, Number(file.sizeBytes), file.sha256);
|
||||
return { filePath, originalName: file.originalName };
|
||||
}
|
||||
|
||||
private async requireReport(
|
||||
manager: EntityManager,
|
||||
reportId: string,
|
||||
lock = false,
|
||||
): Promise<ReportContext> {
|
||||
const suffix = lock ? 'FOR UPDATE OF report' : '';
|
||||
const [row] = (await manager.query(`
|
||||
SELECT
|
||||
report.id,
|
||||
report.code,
|
||||
report.act_id AS "actId",
|
||||
report.visit_id AS "visitId",
|
||||
report.generated_by AS "generatedBy",
|
||||
report.gedo_if_identifier AS "gedoIfIdentifier",
|
||||
report.gedo_officialized_on::text AS "gedoOfficializedOn",
|
||||
report.gedo_pdf_stored_name AS "gedoPdfStoredName",
|
||||
report.gedo_pdf_size_bytes AS "gedoPdfSizeBytes",
|
||||
report.gedo_pdf_sha256 AS "gedoPdfSha256",
|
||||
act.code AS "actCode",
|
||||
visit.code AS "visitCode"
|
||||
FROM inspection_reports report
|
||||
INNER JOIN inspection_acts act ON act.id = report.act_id
|
||||
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
|
||||
WHERE report.id = $1
|
||||
${suffix}
|
||||
`, [reportId])) as ReportContext[];
|
||||
if (!row) throw this.notFound();
|
||||
return row;
|
||||
}
|
||||
|
||||
private async assertCanWorkReport(
|
||||
manager: EntityManager,
|
||||
report: ReportContext,
|
||||
principal: AuthPrincipal,
|
||||
): Promise<void> {
|
||||
if (principal.roles.includes('admin') || principal.roles.includes('supervisor')) return;
|
||||
if (report.generatedBy === principal.userId) return;
|
||||
const [member] = (await manager.query(`
|
||||
SELECT 1 AS found
|
||||
FROM inspection_visit_members
|
||||
WHERE visit_id = $1
|
||||
AND user_id = $2
|
||||
AND included = true
|
||||
LIMIT 1
|
||||
`, [report.visitId, principal.userId])) as Array<{ found: number }>;
|
||||
if (member) return;
|
||||
throw new ForbiddenException({
|
||||
code: 'INSPECTION_REPORT_NOT_ASSIGNED',
|
||||
message: 'Sólo el inspector asignado a la inspección o su jefatura puede trabajar este Informe',
|
||||
});
|
||||
}
|
||||
|
||||
private contentSnapshot(report: InspectionReport): Record<string, unknown> {
|
||||
return {
|
||||
referenceText: report.referenceText,
|
||||
generalObjective: report.generalObjective,
|
||||
specificObjective: report.specificObjective,
|
||||
background: report.background,
|
||||
legalFramework: report.legalFramework,
|
||||
executiveSummary: report.executiveSummary,
|
||||
description: report.description,
|
||||
conclusion: report.conclusion,
|
||||
};
|
||||
}
|
||||
|
||||
private assertDate(value: string, message: string): void {
|
||||
const parsed = new Date(`${value}T00:00:00Z`);
|
||||
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
|
||||
throw new BadRequestException({ code: 'INSPECTION_REPORT_DATE_INVALID', message });
|
||||
}
|
||||
}
|
||||
|
||||
private safePath(storedName: string): string {
|
||||
const filePath = resolve(this.root, storedName);
|
||||
if (!filePath.startsWith(`${this.root}/`)) {
|
||||
throw new InternalServerErrorException({
|
||||
code: 'INSPECTION_REPORT_STORAGE_INVALID_PATH',
|
||||
message: 'Ruta de almacenamiento inválida',
|
||||
});
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private async verifyStoredFile(filePath: string, expectedSize: number, expectedSha256: string): Promise<void> {
|
||||
const metadata = await stat(filePath).catch(() => null);
|
||||
if (!metadata?.isFile() || metadata.size !== expectedSize) throw this.storageError();
|
||||
const buffer = await readFile(filePath);
|
||||
const actualSha256 = createHash('sha256').update(buffer).digest('hex');
|
||||
if (actualSha256 !== expectedSha256) throw this.storageError();
|
||||
}
|
||||
|
||||
private storageError(): InternalServerErrorException {
|
||||
return new InternalServerErrorException({
|
||||
code: 'INSPECTION_REPORT_STORAGE_INTEGRITY_ERROR',
|
||||
message: 'El archivo almacenado no supera la verificación de integridad',
|
||||
});
|
||||
}
|
||||
|
||||
private notFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'INSPECTION_REPORT_NOT_FOUND',
|
||||
message: 'Informe de inspección no encontrado',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
frozenSha256: string;
|
||||
frozenSnapshot: Record<string, unknown>;
|
||||
executiveSummary?: string | null;
|
||||
reportDescription?: string | null;
|
||||
}
|
||||
|
||||
const crcTable = (() => {
|
||||
@@ -56,8 +58,8 @@ function text(value: unknown, fallback = '—'): string {
|
||||
}
|
||||
|
||||
function isoDate(value: unknown): string {
|
||||
const date = new Date(String(value ?? ''));
|
||||
return Number.isFinite(date.getTime()) ? date.toLocaleDateString('es-AR') : '—';
|
||||
const parsed = new Date(String(value ?? ''));
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '—';
|
||||
}
|
||||
|
||||
function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string {
|
||||
@@ -76,52 +78,96 @@ function table(headers: string[], rows: string[][]): string {
|
||||
return `<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 {
|
||||
const report = asRecord(input.frozenSnapshot.report);
|
||||
const actClosure = asRecord(input.frozenSnapshot.actClosure);
|
||||
const prepared = asRecord(actClosure.preparedSnapshot);
|
||||
const act = asRecord(prepared.act);
|
||||
const visit = asRecord(act.visit);
|
||||
const responsible = asRecord(prepared.responsible);
|
||||
const team = asArray(prepared.team);
|
||||
const assets = asArray(prepared.assets);
|
||||
const findings = asArray(prepared.findings);
|
||||
const signatures = asArray(actClosure.signatures);
|
||||
const companies = [...new Set(assets.map((item) => text(asRecord(item.operatorCompany).name, '')).filter(Boolean))];
|
||||
const areas = [...new Set(assets.map((item) => text(asRecord(item.operationalArea).name, '')).filter(Boolean))];
|
||||
const inspectorNames = team.map((member) => `${text(member.firstName, '')} ${text(member.lastName, '')}`.trim()).filter(Boolean);
|
||||
const assetRows = assets.map((item) => [text(item.code), text(item.name), text(item.commonName, ''), text(item.typeName)]);
|
||||
const findingRows = findings.map((item) => [text(item.code), text(item.title), text(item.description), item.severity == null ? '—' : `${text(item.severity)}/10`, isoDate(item.correctionDueOn)]);
|
||||
const signatureRows = signatures.map((item) => [text(item.signerType), text(item.signerName), text(item.status), isoDate(item.signedAt ?? item.createdAt)]);
|
||||
const snapshot = f4Snapshot(input);
|
||||
const findingRows = snapshot.findings.map((item) => [
|
||||
text(item.code),
|
||||
text(item.title),
|
||||
text(item.description),
|
||||
item.isRecurrence ? `Sí${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No',
|
||||
item.severity == null ? '—' : `${text(item.severity)}/10`,
|
||||
]);
|
||||
const inventoryRows = snapshot.inventories.map((item) => [
|
||||
text(item.code),
|
||||
text(item.name),
|
||||
text(item.typeName ?? item.typeCode),
|
||||
]);
|
||||
const signatureRows = snapshot.signatures.map((item) => [
|
||||
text(item.signerType),
|
||||
text(item.signerName),
|
||||
text(item.status),
|
||||
text(item.companyManifestation, ''),
|
||||
isoDate(item.signedAt ?? item.createdAt),
|
||||
]);
|
||||
|
||||
const executive = input.executiveSummary?.trim()
|
||||
|| '[EDITAR] Incorporar aquí el resumen ejecutivo del informe.';
|
||||
const description = input.reportDescription?.trim()
|
||||
|| '[EDITAR] Incorporar aquí la descripción técnica, análisis y consideraciones del inspector.';
|
||||
|
||||
const body = [
|
||||
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
|
||||
paragraph('Borrador automático para revisión del Director de Hidrocarburos', 'Heading2'),
|
||||
paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'),
|
||||
labelValue('Informe', input.code),
|
||||
labelValue('Título', input.title),
|
||||
labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)),
|
||||
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
|
||||
labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)),
|
||||
labelValue('Urgencia', urgency(snapshot.act.urgency)),
|
||||
labelValue('Plazo', deadline(snapshot.act)),
|
||||
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
|
||||
labelValue('Acta', text(report.actCode ?? act.code)),
|
||||
labelValue('Inspección', text(report.visitCode ?? visit.code)),
|
||||
labelValue('Empresa', companies.join(' · ') || 'Según alcance del acta'),
|
||||
labelValue('Área', areas.join(' · ') || 'Según alcance del acta'),
|
||||
labelValue('Inspector/es', inspectorNames.join(' · ') || '—'),
|
||||
labelValue('Responsable de empresa', text(responsible.fullName)),
|
||||
labelValue('Cargo', text(responsible.position)),
|
||||
paragraph('Datos de la visita', 'Heading1'),
|
||||
labelValue('Objetivo', text(visit.objective)),
|
||||
labelValue('Fecha de inspección', isoDate(act.occurredAt)),
|
||||
labelValue('Resumen', text(act.summary)),
|
||||
labelValue('Observaciones', text(act.observations)),
|
||||
paragraph('Elementos inspeccionados', 'Heading1'),
|
||||
assetRows.length ? table(['Código', 'Nombre técnico', 'Nombre habitual', 'Tipo'], assetRows) : paragraph('No se registraron elementos en la instantánea.'),
|
||||
paragraph('Resumen ejecutivo', 'Heading1'),
|
||||
paragraph(executive),
|
||||
paragraph('Descripción / análisis técnico', 'Heading1'),
|
||||
paragraph(description),
|
||||
paragraph('Acta fuente · contenido inmutable', 'Heading1'),
|
||||
paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
|
||||
labelValue('Resumen del Acta', text(snapshot.act.summary)),
|
||||
labelValue('Observaciones del Acta', text(snapshot.act.observations)),
|
||||
labelValue('Responsable de empresa', text(snapshot.responsible.fullName)),
|
||||
labelValue('Cargo', text(snapshot.responsible.position)),
|
||||
paragraph('Inventario inspeccionado', 'Heading1'),
|
||||
inventoryRows.length
|
||||
? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
|
||||
: paragraph('No se registraron elementos de Inventario en el Acta.'),
|
||||
paragraph('Hallazgos', 'Heading1'),
|
||||
findingRows.length ? table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento'], findingRows) : paragraph('No se registraron hallazgos en la instantánea.'),
|
||||
paragraph('Firmas y constancias', 'Heading1'),
|
||||
signatureRows.length ? table(['Tipo', 'Firmante', 'Estado', 'Fecha'], signatureRows) : paragraph('No se registraron firmas en la instantánea.'),
|
||||
paragraph('Integridad documental', 'Heading1'),
|
||||
labelValue('Hash del informe', input.frozenSha256),
|
||||
labelValue('Hash de cierre del acta', text(report.actClosureSha256)),
|
||||
paragraph('El contenido de este archivo fue generado desde la instantánea congelada del acta. El diseño institucional y la firma final del Director se incorporarán en la etapa de revisión correspondiente.'),
|
||||
findingRows.length
|
||||
? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows)
|
||||
: paragraph('El Acta no contiene Hallazgos.'),
|
||||
paragraph('Firmas y manifestaciones', 'Heading1'),
|
||||
signatureRows.length
|
||||
? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows)
|
||||
: paragraph('No se registraron firmas en la instantánea sellada.'),
|
||||
paragraph('Integridad de la fuente', 'Heading1'),
|
||||
labelValue('Hash de la fuente del INF', input.frozenSha256),
|
||||
labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
|
||||
labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)),
|
||||
paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'),
|
||||
].join('');
|
||||
|
||||
return `<?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(0, 28);
|
||||
locals.push(local, name, entry.data);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
@@ -168,6 +215,7 @@ function buildZip(entries: ZipEntry[]): Buffer {
|
||||
centrals.push(central, name);
|
||||
offset += local.length + name.length + entry.data.length;
|
||||
}
|
||||
|
||||
const centralData = Buffer.concat(centrals);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
|
||||
@@ -12,6 +12,8 @@ interface WordRow {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
executiveSummary: string | null;
|
||||
reportDescription: string | null;
|
||||
generatedAt: Date;
|
||||
frozenSha256: string;
|
||||
frozenSnapshot: Record<string, unknown>;
|
||||
@@ -29,7 +31,8 @@ export class InspectionReportWordService {
|
||||
private readonly root: string;
|
||||
|
||||
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');
|
||||
this.root = resolve(configured);
|
||||
if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root');
|
||||
@@ -38,7 +41,11 @@ export class InspectionReportWordService {
|
||||
async ensure(reportId: string): Promise<void> {
|
||||
const row = await this.load(reportId);
|
||||
if (row.wordStatus === 'READY' && row.wordStoredName) {
|
||||
try { await this.content(row.id); await this.ensureInitialRevision(row); return; } catch {}
|
||||
try {
|
||||
await this.content(row.id);
|
||||
await this.ensureInitialRevision(row);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const built = buildInspectionReportWord({
|
||||
@@ -47,40 +54,51 @@ export class InspectionReportWordService {
|
||||
generatedAt: new Date(row.generatedAt),
|
||||
frozenSha256: row.frozenSha256,
|
||||
frozenSnapshot: row.frozenSnapshot,
|
||||
executiveSummary: row.executiveSummary,
|
||||
reportDescription: row.reportDescription,
|
||||
});
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
const storedName = `${row.id}.docx`;
|
||||
const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;
|
||||
const originalName = `${row.code}.docx`;
|
||||
const filePath = resolve(this.root, storedName);
|
||||
await writeFile(filePath, built.buffer, { mode: 0o600 });
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_reports
|
||||
SET word_status = 'READY',
|
||||
word_original_name = $2,
|
||||
word_stored_name = $3,
|
||||
word_mime_type = $4,
|
||||
word_size_bytes = $5,
|
||||
word_sha256 = $6,
|
||||
word_generated_at = CURRENT_TIMESTAMP,
|
||||
word_error = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
SET word_status='READY',
|
||||
word_original_name=$2,
|
||||
word_stored_name=$3,
|
||||
word_mime_type=$4,
|
||||
word_size_bytes=$5,
|
||||
word_sha256=$6,
|
||||
word_generated_at=CURRENT_TIMESTAMP,
|
||||
word_error=NULL,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
`, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]);
|
||||
await this.ensureInitialRevision(await this.load(row.id));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido';
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_reports
|
||||
SET word_status = 'FAILED', word_error = $2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
SET word_status='FAILED',word_error=$2,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
`, [row.id, message]).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||
const row = await this.load(reportId);
|
||||
if (row.wordStatus !== 'READY' || !row.wordStoredName || !row.wordOriginalName || !row.wordSha256 || !row.wordSizeBytes) {
|
||||
throw new NotFoundException({ code: 'INSPECTION_REPORT_WORD_NOT_READY', message: 'El archivo Word del informe todavía no está disponible' });
|
||||
if (
|
||||
row.wordStatus !== 'READY'
|
||||
|| !row.wordStoredName
|
||||
|| !row.wordOriginalName
|
||||
|| !row.wordSha256
|
||||
|| !row.wordSizeBytes
|
||||
) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_REPORT_WORD_NOT_READY',
|
||||
message: 'El archivo Word del informe todavía no está disponible',
|
||||
});
|
||||
}
|
||||
const filePath = resolve(this.root, row.wordStoredName);
|
||||
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
|
||||
@@ -89,40 +107,78 @@ export class InspectionReportWordService {
|
||||
const buffer = await readFile(filePath);
|
||||
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
||||
if (sha256 !== row.wordSha256) throw this.storageError();
|
||||
return { filePath, originalName: row.wordOriginalName, mimeType: row.wordMimeType ?? WORD_MIME };
|
||||
return {
|
||||
filePath,
|
||||
originalName: row.wordOriginalName,
|
||||
mimeType: row.wordMimeType ?? WORD_MIME,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private async ensureInitialRevision(row: WordRow): Promise<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(`
|
||||
INSERT INTO inspection_report_revisions (
|
||||
report_id, revision_number, source, original_name, stored_name, mime_type,
|
||||
size_bytes, sha256, change_summary, created_by, created_at
|
||||
) VALUES ($1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial',$7,COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP))
|
||||
ON CONFLICT (report_id, revision_number) DO NOTHING
|
||||
`, [row.id, row.wordOriginalName, row.wordStoredName, row.wordMimeType, row.wordSizeBytes, row.wordSha256, row.generatedBy]);
|
||||
report_id,revision_number,source,original_name,stored_name,mime_type,
|
||||
size_bytes,sha256,change_summary,created_by,created_at
|
||||
) VALUES (
|
||||
$1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial editable',$7,
|
||||
COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP)
|
||||
)
|
||||
ON CONFLICT (report_id,revision_number) DO NOTHING
|
||||
`, [
|
||||
row.id,
|
||||
row.wordOriginalName,
|
||||
row.wordStoredName,
|
||||
row.wordMimeType,
|
||||
row.wordSizeBytes,
|
||||
row.wordSha256,
|
||||
row.generatedBy,
|
||||
]);
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_reports
|
||||
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
SET current_revision_number=GREATEST(current_revision_number,1),updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
`, [row.id]);
|
||||
}
|
||||
|
||||
private async load(reportId: string): Promise<WordRow> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT id, code, title, generated_at AS "generatedAt", frozen_sha256 AS "frozenSha256",
|
||||
frozen_snapshot AS "frozenSnapshot", word_status AS "wordStatus",
|
||||
word_original_name AS "wordOriginalName", word_stored_name AS "wordStoredName",
|
||||
word_mime_type AS "wordMimeType", word_size_bytes::integer AS "wordSizeBytes",
|
||||
word_sha256 AS "wordSha256", generated_by AS "generatedBy"
|
||||
FROM inspection_reports WHERE id = $1
|
||||
SELECT id,code,title,
|
||||
executive_summary AS "executiveSummary",
|
||||
report_description AS "reportDescription",
|
||||
generated_at AS "generatedAt",
|
||||
frozen_sha256 AS "frozenSha256",
|
||||
frozen_snapshot AS "frozenSnapshot",
|
||||
word_status AS "wordStatus",
|
||||
word_original_name AS "wordOriginalName",
|
||||
word_stored_name AS "wordStoredName",
|
||||
word_mime_type AS "wordMimeType",
|
||||
word_size_bytes::integer AS "wordSizeBytes",
|
||||
word_sha256 AS "wordSha256",
|
||||
generated_by AS "generatedBy"
|
||||
FROM inspection_reports
|
||||
WHERE id=$1
|
||||
`, [reportId]) as WordRow[];
|
||||
if (!row) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
|
||||
if (!row) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_REPORT_NOT_FOUND',
|
||||
message: 'Informe de inspección no encontrado',
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private storageError(): InternalServerErrorException {
|
||||
return new InternalServerErrorException({ code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR', message: 'El archivo Word del informe no está disponible o no supera la validación de integridad' });
|
||||
return new InternalServerErrorException({
|
||||
code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR',
|
||||
message: 'El archivo Word del informe no está disponible o no supera la validación de integridad',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import type { Response } from 'express';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
||||
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
|
||||
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import {
|
||||
InspectionReportWorkflowService,
|
||||
MAX_INSPECTION_REPORT_FILE_BYTES,
|
||||
type UploadedInspectionReportFile,
|
||||
} from './inspection-report-workflow.service';
|
||||
import { InspectionReportsService } from './inspection-reports.service';
|
||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||
|
||||
@Controller('inspection-reports')
|
||||
export class InspectionReportsController {
|
||||
constructor(private readonly reports: InspectionReportsService, private readonly word: InspectionReportWordService) {}
|
||||
constructor(
|
||||
private readonly reports: InspectionReportsService,
|
||||
private readonly word: InspectionReportWordService,
|
||||
private readonly workflow: InspectionReportWorkflowService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
@@ -42,6 +68,65 @@ export class InspectionReportsController {
|
||||
return response.sendFile(content.filePath);
|
||||
}
|
||||
|
||||
@Get(':id/gedo-pdf')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
async gedoPdfContent(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
const content = await this.workflow.officialPdfContent(id);
|
||||
response.setHeader('Content-Type', content.mimeType);
|
||||
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||
return response.sendFile(content.filePath);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('inspection_reports.generate')
|
||||
updateNarrative(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: UpdateInspectionReportDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.workflow.updateNarrative(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/officialize')
|
||||
@RequirePermissions('inspection_reports.generate')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 },
|
||||
}))
|
||||
officialize(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: OfficializeInspectionReportDto,
|
||||
@UploadedFile() file: UploadedInspectionReportFile | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.workflow.officialize(id, dto, file, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id/follow-ups')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.workflow.listFollowUps(id);
|
||||
}
|
||||
|
||||
@Post(':id/follow-ups')
|
||||
@RequirePermissions('inspection_reports.generate')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 },
|
||||
}))
|
||||
addFollowUp(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: CreateInspectionReportFollowUpDto,
|
||||
@UploadedFile() file: UploadedInspectionReportFile | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.workflow.addFollowUp(id, dto, file, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
|
||||
@@ -1,40 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
|
||||
import { DocumentDeliveryController } from './document-delivery.controller';
|
||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
|
||||
import { InspectionDeadlineAdminService } from './inspection-deadline-admin.service';
|
||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||
import {
|
||||
InspectionReportDossierController,
|
||||
InspectionReportFollowUpFileController,
|
||||
} from './inspection-report-dossier.controller';
|
||||
import { InspectionReportDossierService } from './inspection-report-dossier.service';
|
||||
import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
|
||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||
import { InspectionReportsService } from './inspection-reports.service';
|
||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
import { SmtpSettingsController } from './smtp-settings.controller';
|
||||
import { SmtpSettingsService } from './smtp-settings.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, InspectionDeadlinesModule],
|
||||
imports: [AuditModule],
|
||||
controllers: [
|
||||
InspectionReportsController,
|
||||
InspectionActReportController,
|
||||
InspectionReportDossierController,
|
||||
InspectionReportFollowUpFileController,
|
||||
InspectionDeadlineAdminController,
|
||||
DocumentDeliveryController,
|
||||
SmtpSettingsController,
|
||||
],
|
||||
providers: [
|
||||
InspectionReportsService,
|
||||
InspectionReportWorkflowService,
|
||||
InspectionReportWordService,
|
||||
InspectionReportDossierService,
|
||||
InspectionActPdfService,
|
||||
InspectionDeadlineAdminService,
|
||||
InspectionDocumentDeliveryService,
|
||||
SmtpDeliveryService,
|
||||
SmtpSettingsService,
|
||||
],
|
||||
exports: [InspectionReportsService],
|
||||
exports: [InspectionReportsService, SmtpDeliveryService],
|
||||
})
|
||||
export class InspectionReportsModule {}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import {
|
||||
AuditAction,
|
||||
DocumentSequenceType,
|
||||
InspectionActStatus,
|
||||
InspectionReportPdfStatus,
|
||||
InspectionReportStatus,
|
||||
@@ -16,7 +15,7 @@ import type { ListInspectionReportsQueryDto } from './dto/list-inspection-report
|
||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||
|
||||
const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-REPORT-V1';
|
||||
const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-INF-V4';
|
||||
|
||||
interface ContextAsset {
|
||||
id: string;
|
||||
@@ -32,21 +31,32 @@ export interface InspectionReportListItem {
|
||||
reportNumber: number;
|
||||
code: string;
|
||||
status: InspectionReportStatus;
|
||||
executiveSummary: string | null;
|
||||
reportDescription: string | null;
|
||||
gedoIfIdentifier: string | null;
|
||||
gedoOfficializedAt: Date | null;
|
||||
gedoPdfOriginalName: string | null;
|
||||
gedoPdfSha256: string | null;
|
||||
pdfStatus: InspectionReportPdfStatus;
|
||||
wordStatus: 'PENDING' | 'READY' | 'FAILED';
|
||||
wordGeneratedAt: Date | null;
|
||||
reviewStatus: 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED';
|
||||
currentRevisionNumber: number;
|
||||
approvedAt: Date | null;
|
||||
signedAt: Date | null;
|
||||
title: string;
|
||||
actVersion: number;
|
||||
actClosureSha256: string;
|
||||
frozenSha256: string;
|
||||
generatedAt: Date;
|
||||
generatedBy: { id: string; username: string; firstName: string; lastName: string };
|
||||
act: { id: string; code: string; title: string; status: string; occurredAt: Date; closedAt: Date | null };
|
||||
visit: { id: string; code: string; title: string; status: string };
|
||||
act: {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: string;
|
||||
occurredAt: Date;
|
||||
sealedAt: Date | null;
|
||||
urgency: string;
|
||||
deadlineAt: Date | null;
|
||||
};
|
||||
visit: { id: string; code: string; status: string };
|
||||
companies: ContextAsset[];
|
||||
areas: ContextAsset[];
|
||||
findingCount: number;
|
||||
@@ -63,10 +73,9 @@ export interface PendingInspectionReportItem {
|
||||
actTitle: string;
|
||||
actYear: number;
|
||||
occurredAt: Date;
|
||||
closedAt: Date;
|
||||
sealedAt: Date;
|
||||
closureSha256: string;
|
||||
visitCode: string;
|
||||
visitTitle: string;
|
||||
companies: ContextAsset[];
|
||||
areas: ContextAsset[];
|
||||
findingCount: number;
|
||||
@@ -127,7 +136,7 @@ export class InspectionReportsService {
|
||||
|
||||
async listPending(query: ListInspectionReportsQueryDto) {
|
||||
const conditions = [
|
||||
`act.status = 'CLOSED'`,
|
||||
`act.status = 'SEALED'`,
|
||||
'report.id IS NULL',
|
||||
'act.closure_sha256 IS NOT NULL',
|
||||
];
|
||||
@@ -142,22 +151,6 @@ export class InspectionReportsService {
|
||||
act.code ILIKE ${search}
|
||||
OR act.title ILIKE ${search}
|
||||
OR visit.code ILIKE ${search}
|
||||
OR visit.title ILIKE ${search}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_act_assets search_link
|
||||
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
|
||||
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
|
||||
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
|
||||
WHERE search_link.act_id = act.id
|
||||
AND search_link.included = true
|
||||
AND (
|
||||
search_asset.name ILIKE ${search}
|
||||
OR search_asset.code ILIKE ${search}
|
||||
OR search_company.name ILIKE ${search}
|
||||
OR search_area.name ILIKE ${search}
|
||||
)
|
||||
)
|
||||
)`);
|
||||
}
|
||||
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
|
||||
@@ -196,10 +189,9 @@ export class InspectionReportsService {
|
||||
act.title AS "actTitle",
|
||||
act.act_year AS "actYear",
|
||||
act.occurred_at AS "occurredAt",
|
||||
act.closed_at AS "closedAt",
|
||||
act.sealed_at AS "sealedAt",
|
||||
act.closure_sha256 AS "closureSha256",
|
||||
visit.code AS "visitCode",
|
||||
visit.title AS "visitTitle",
|
||||
COALESCE(context.companies, '[]'::jsonb) AS companies,
|
||||
COALESCE(context.areas, '[]'::jsonb) AS areas,
|
||||
COALESCE(finding_count.total, 0)::integer AS "findingCount"
|
||||
@@ -226,16 +218,7 @@ export class InspectionReportsService {
|
||||
}
|
||||
|
||||
async get(id: string): Promise<InspectionReportView> {
|
||||
const [report] = (await this.dataSource.query(
|
||||
`${this.reportSelect('WHERE report.id = $1')}`,
|
||||
[id],
|
||||
)) as InspectionReportListItem[];
|
||||
if (!report) throw reportNotFound();
|
||||
const [snapshot] = (await this.dataSource.query(
|
||||
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
|
||||
[id],
|
||||
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
|
||||
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
|
||||
return this.getWithManager(this.dataSource.manager, id);
|
||||
}
|
||||
|
||||
async generate(
|
||||
@@ -244,144 +227,144 @@ export class InspectionReportsService {
|
||||
request: RequestWithContext,
|
||||
): Promise<InspectionReportView> {
|
||||
assertMobileInspector(principal);
|
||||
const report = await this.dataSource.transaction(async (manager) => this.ensureFrozenReport(manager, actId, principal, request));
|
||||
const report = await this.dataSource.transaction((manager) =>
|
||||
this.ensureFrozenReport(manager, actId, principal, request),
|
||||
);
|
||||
await this.word.ensure(report.id);
|
||||
return this.get(report.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conserva una copia interna inmutable del Acta sellada como fuente documental.
|
||||
* El INF que se genera desde esa fuente permanece editable y versionable.
|
||||
*/
|
||||
async ensureFrozenReport(
|
||||
manager: EntityManager,
|
||||
actId: string,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<InspectionReportView> {
|
||||
const [act] = (await manager.query(`
|
||||
SELECT
|
||||
act.id,
|
||||
act.visit_id AS "visitId",
|
||||
act.act_year AS "actYear",
|
||||
act.code,
|
||||
act.title,
|
||||
act.status,
|
||||
act.current_version AS "currentVersion",
|
||||
act.closure_sha256 AS "closureSha256",
|
||||
visit.code AS "visitCode",
|
||||
visit.title AS "visitTitle"
|
||||
FROM inspection_acts act
|
||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
WHERE act.id = $1
|
||||
FOR UPDATE OF act
|
||||
`, [actId])) as Array<{
|
||||
id: string;
|
||||
visitId: string;
|
||||
actYear: number;
|
||||
code: string;
|
||||
title: string;
|
||||
status: InspectionActStatus;
|
||||
currentVersion: number;
|
||||
closureSha256: string | null;
|
||||
visitCode: string;
|
||||
visitTitle: string;
|
||||
}>;
|
||||
if (!act) throw actNotFound();
|
||||
if (act.status !== InspectionActStatus.CLOSED || !act.closureSha256) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_REPORT_ACT_NOT_CLOSED',
|
||||
message: 'El informe sólo puede emitirse después del cierre definitivo del acta',
|
||||
});
|
||||
}
|
||||
await this.assertActorAssigned(manager, act.visitId, principal.userId);
|
||||
const [existing] = (await manager.query(
|
||||
'SELECT id FROM inspection_reports WHERE act_id = $1',
|
||||
[actId],
|
||||
)) as Array<{ id: string }>;
|
||||
if (existing) return this.getWithManager(manager, existing.id);
|
||||
const [closure] = (await manager.query(`
|
||||
SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256"
|
||||
FROM inspection_act_closures
|
||||
WHERE act_id = $1
|
||||
`, [actId])) as Array<{
|
||||
finalSnapshot: Record<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,
|
||||
const [act] = (await manager.query(`
|
||||
SELECT
|
||||
act.id,
|
||||
act.actYear,
|
||||
reportNumber,
|
||||
act.visit_id AS "visitId",
|
||||
act.act_year AS "actYear",
|
||||
act.act_number AS "actNumber",
|
||||
act.code,
|
||||
act.title,
|
||||
act.status,
|
||||
act.current_version AS "currentVersion",
|
||||
act.closure_sha256 AS "closureSha256",
|
||||
act.occurred_at AS "occurredAt",
|
||||
visit.code AS "visitCode"
|
||||
FROM inspection_acts act
|
||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
WHERE act.id = $1
|
||||
FOR UPDATE OF act
|
||||
`, [actId])) as Array<{
|
||||
id: string;
|
||||
visitId: string;
|
||||
actYear: number;
|
||||
actNumber: number;
|
||||
code: string;
|
||||
title: string;
|
||||
status: InspectionActStatus;
|
||||
currentVersion: number;
|
||||
closureSha256: string | null;
|
||||
occurredAt: Date;
|
||||
visitCode: string;
|
||||
}>;
|
||||
if (!act) throw actNotFound();
|
||||
if (act.status !== InspectionActStatus.SEALED || !act.closureSha256) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_REPORT_ACT_NOT_SEALED',
|
||||
message: 'El INF sólo puede generarse después de sellar el acta',
|
||||
});
|
||||
}
|
||||
await this.assertActorAssigned(manager, act.visitId, principal.userId);
|
||||
const [existing] = (await manager.query(
|
||||
'SELECT id FROM inspection_reports WHERE act_id = $1',
|
||||
[actId],
|
||||
)) as Array<{ id: string }>;
|
||||
if (existing) return this.getWithManager(manager, existing.id);
|
||||
|
||||
const [closure] = (await manager.query(`
|
||||
SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256"
|
||||
FROM inspection_act_closures
|
||||
WHERE act_id = $1
|
||||
`, [actId])) as Array<{
|
||||
finalSnapshot: Record<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,
|
||||
title,
|
||||
act.currentVersion,
|
||||
act.closureSha256,
|
||||
actId: act.id,
|
||||
inspectionId: act.visitId,
|
||||
actVersion: act.currentVersion,
|
||||
frozenSha256,
|
||||
frozenSnapshot,
|
||||
generatedAt,
|
||||
principal.userId,
|
||||
])) as Array<{ id: string }>;
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.INSPECTION_REPORT_GENERATED,
|
||||
entityType: 'inspection_report',
|
||||
entityId: created.id,
|
||||
afterData: {
|
||||
code,
|
||||
actId: act.id,
|
||||
visitId: act.visitId,
|
||||
actVersion: act.currentVersion,
|
||||
frozenSha256,
|
||||
pdfStatus: InspectionReportPdfStatus.PENDING,
|
||||
},
|
||||
}, manager);
|
||||
return this.getWithManager(manager, created.id);
|
||||
status: InspectionReportStatus.WORKING,
|
||||
},
|
||||
}, manager);
|
||||
return this.getWithManager(manager, created.id);
|
||||
}
|
||||
|
||||
async ensureWordForAct(actId: string): Promise<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);
|
||||
await this.delivery.dispatchForAct(actId).catch(() => undefined);
|
||||
}
|
||||
@@ -398,25 +381,10 @@ export class InspectionReportsService {
|
||||
conditions.push(`(
|
||||
report.code ILIKE ${search}
|
||||
OR report.title ILIKE ${search}
|
||||
OR report.gedo_if_identifier ILIKE ${search}
|
||||
OR act.code ILIKE ${search}
|
||||
OR act.title ILIKE ${search}
|
||||
OR visit.code ILIKE ${search}
|
||||
OR visit.title ILIKE ${search}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_act_assets search_link
|
||||
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
|
||||
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
|
||||
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
|
||||
WHERE search_link.act_id = act.id
|
||||
AND search_link.included = true
|
||||
AND (
|
||||
search_asset.name ILIKE ${search}
|
||||
OR search_asset.code ILIKE ${search}
|
||||
OR search_company.name ILIKE ${search}
|
||||
OR search_area.name ILIKE ${search}
|
||||
)
|
||||
)
|
||||
)`);
|
||||
}
|
||||
if (query.year) conditions.push(`report.report_year = ${add(query.year)}`);
|
||||
@@ -454,20 +422,16 @@ export class InspectionReportsService {
|
||||
return `
|
||||
SELECT
|
||||
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
||||
'id', company.id,
|
||||
'code', company.code,
|
||||
'name', company.name
|
||||
)) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies,
|
||||
'id',company.id,'code',company.code,'name',company.name
|
||||
)) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies,
|
||||
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
||||
'id', area.id,
|
||||
'code', area.code,
|
||||
'name', area.name
|
||||
)) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas
|
||||
'id',area.id,'code',area.code,'name',area.name
|
||||
)) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas
|
||||
FROM inspection_act_assets context_link
|
||||
INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id
|
||||
LEFT JOIN assets company ON company.id = context_asset.operator_company_id
|
||||
LEFT JOIN assets area ON area.id = context_asset.operational_area_id
|
||||
WHERE context_link.act_id = act.id AND context_link.included = true
|
||||
INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id
|
||||
LEFT JOIN assets company ON company.id=context_asset.operator_company_id
|
||||
LEFT JOIN assets area ON area.id=context_asset.operational_area_id
|
||||
WHERE context_link.act_id=act.id AND context_link.included=true
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -481,50 +445,43 @@ export class InspectionReportsService {
|
||||
report.report_number AS "reportNumber",
|
||||
report.code,
|
||||
report.status,
|
||||
report.executive_summary AS "executiveSummary",
|
||||
report.report_description AS "reportDescription",
|
||||
report.gedo_if_identifier AS "gedoIfIdentifier",
|
||||
report.gedo_officialized_at AS "gedoOfficializedAt",
|
||||
report.gedo_pdf_original_name AS "gedoPdfOriginalName",
|
||||
report.gedo_pdf_sha256 AS "gedoPdfSha256",
|
||||
report.pdf_status AS "pdfStatus",
|
||||
report.word_status AS "wordStatus",
|
||||
report.word_generated_at AS "wordGeneratedAt",
|
||||
report.review_status AS "reviewStatus",
|
||||
report.current_revision_number AS "currentRevisionNumber",
|
||||
report.approved_at AS "approvedAt",
|
||||
report.signed_at AS "signedAt",
|
||||
report.title,
|
||||
report.act_version AS "actVersion",
|
||||
report.act_closure_sha256 AS "actClosureSha256",
|
||||
report.frozen_sha256 AS "frozenSha256",
|
||||
report.generated_at AS "generatedAt",
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', generator.id,
|
||||
'username', generator.username,
|
||||
'firstName', generator.first_name,
|
||||
'lastName', generator.last_name
|
||||
'id',generator.id,'username',generator.username,
|
||||
'firstName',generator.first_name,'lastName',generator.last_name
|
||||
) AS "generatedBy",
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', act.id,
|
||||
'code', act.code,
|
||||
'title', act.title,
|
||||
'status', act.status,
|
||||
'occurredAt', act.occurred_at,
|
||||
'closedAt', act.closed_at
|
||||
'id',act.id,'code',act.code,'title',act.title,'status',act.status,
|
||||
'occurredAt',act.occurred_at,'sealedAt',act.sealed_at,
|
||||
'urgency',act.urgency,'deadlineAt',act.deadline_at
|
||||
) AS act,
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', visit.id,
|
||||
'code', visit.code,
|
||||
'title', visit.title,
|
||||
'status', visit.status
|
||||
'id',visit.id,'code',visit.code,'status',visit.status
|
||||
) AS visit,
|
||||
COALESCE(context.companies, '[]'::jsonb) AS companies,
|
||||
COALESCE(context.areas, '[]'::jsonb) AS areas,
|
||||
COALESCE(finding_count.total, 0)::integer AS "findingCount"
|
||||
COALESCE(context.companies,'[]'::jsonb) AS companies,
|
||||
COALESCE(context.areas,'[]'::jsonb) AS areas,
|
||||
COALESCE(finding_count.total,0)::integer AS "findingCount"
|
||||
FROM inspection_reports report
|
||||
INNER JOIN inspection_acts act ON act.id = report.act_id
|
||||
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
|
||||
INNER JOIN users generator ON generator.id = report.generated_by
|
||||
INNER JOIN inspection_acts act ON act.id=report.act_id
|
||||
INNER JOIN inspection_visits visit ON visit.id=report.visit_id
|
||||
INNER JOIN users generator ON generator.id=report.generated_by
|
||||
LEFT JOIN LATERAL (${this.contextSelect()}) context ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS total
|
||||
FROM inspection_findings finding
|
||||
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
|
||||
SELECT COUNT(*) AS total FROM inspection_findings finding
|
||||
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
|
||||
) finding_count ON true
|
||||
${where}
|
||||
`;
|
||||
@@ -543,29 +500,15 @@ export class InspectionReportsService {
|
||||
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
|
||||
}
|
||||
|
||||
private async allocateNumber(manager: EntityManager, year: number): Promise<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> {
|
||||
const rows = (await manager.query(`
|
||||
SELECT 1
|
||||
FROM inspection_visit_members
|
||||
WHERE visit_id = $1 AND user_id = $2 AND included = true
|
||||
LIMIT 1
|
||||
SELECT 1 FROM inspection_visit_members
|
||||
WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1
|
||||
`, [visitId, userId])) as unknown[];
|
||||
if (rows.length === 0) {
|
||||
if (!rows.length) {
|
||||
throw new ForbiddenException({
|
||||
code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED',
|
||||
message: 'Sólo un inspector asignado a la visita puede solicitar el informe',
|
||||
message: 'Sólo un inspector asignado a la inspección puede solicitar el informe',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,328 +1,197 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { connect as connectNet, Socket } from 'node:net';
|
||||
import { connect as connectTls, TLSSocket } from 'node:tls';
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { decryptSmtpSecret } from './smtp-settings-crypto';
|
||||
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 Reply { code:number; text:string; }
|
||||
|
||||
interface MailInput {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
attachment?: MailAttachment;
|
||||
}
|
||||
|
||||
interface Reply {
|
||||
code: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface RuntimeSmtpConfig {
|
||||
source: 'DATABASE' | 'ENV';
|
||||
export interface EffectiveSmtpSettings {
|
||||
source: 'DATABASE' | 'ENVIRONMENT';
|
||||
host: string;
|
||||
port: number;
|
||||
securityMode: 'TLS' | 'STARTTLS';
|
||||
user: string;
|
||||
pass: string;
|
||||
fromHeader: string;
|
||||
securityMode: SmtpSecurityMode;
|
||||
username: string | null;
|
||||
password: string;
|
||||
fromName: string | null;
|
||||
fromEmail: string;
|
||||
replyTo: string | null;
|
||||
}
|
||||
|
||||
class SmtpSession {
|
||||
private buffer = '';
|
||||
private waiters: Array<(reply: Reply) => void> = [];
|
||||
private replyLines: string[] = [];
|
||||
private readonly dataHandler = (chunk: Buffer | string) =>
|
||||
this.onData(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
|
||||
|
||||
constructor(private socket: Socket | TLSSocket) {
|
||||
this.attach(socket);
|
||||
}
|
||||
|
||||
private attach(socket: Socket | TLSSocket) {
|
||||
socket.setEncoding('utf8');
|
||||
socket.setTimeout(15000, () => socket.destroy(new Error('SMTP timeout')));
|
||||
socket.on('data', this.dataHandler);
|
||||
}
|
||||
|
||||
detachForUpgrade() {
|
||||
this.socket.off('data', this.dataHandler);
|
||||
this.socket.setTimeout(0);
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
replaceSocket(socket: Socket | TLSSocket) {
|
||||
this.socket = socket;
|
||||
this.buffer = '';
|
||||
this.attach(socket);
|
||||
}
|
||||
|
||||
private onData(chunk: string) {
|
||||
this.buffer += chunk;
|
||||
const lines = this.buffer.split(/\r?\n/);
|
||||
this.buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
this.replyLines.push(line);
|
||||
if (/^\d{3} /.test(line)) {
|
||||
const code = Number(line.slice(0, 3));
|
||||
const reply = { code, text: this.replyLines.join('\n') };
|
||||
this.replyLines = [];
|
||||
this.waiters.shift()?.(reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reply(): Promise<Reply> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (error: Error) => {
|
||||
this.socket.off('error', onError);
|
||||
reject(error);
|
||||
};
|
||||
this.socket.once('error', onError);
|
||||
this.waiters.push((reply) => {
|
||||
this.socket.off('error', onError);
|
||||
resolve(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);
|
||||
}
|
||||
|
||||
end() {
|
||||
this.socket.end();
|
||||
}
|
||||
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'));
|
||||
constructor(private socket:Socket|TLSSocket){ this.attach(socket); }
|
||||
private attach(socket:Socket|TLSSocket){ socket.setEncoding('utf8'); socket.setTimeout(15000,()=>socket.destroy(new Error('SMTP timeout'))); socket.on('data',this.dataHandler); }
|
||||
detachForUpgrade(){ this.socket.off('data',this.dataHandler); this.socket.setTimeout(0); return this.socket; }
|
||||
replaceSocket(socket:Socket|TLSSocket){ this.socket=socket; this.buffer=''; this.attach(socket); }
|
||||
private onData(chunk:string){ this.buffer+=chunk; const lines=this.buffer.split(/\r?\n/); this.buffer=lines.pop()??''; for(const line of lines){ if(!line)continue; this.replyLines.push(line); if(/^\d{3} /.test(line)){ const code=Number(line.slice(0,3)); const reply={code,text:this.replyLines.join('\n')}; this.replyLines=[]; const waiter=this.waiters.shift(); if(waiter)waiter(reply); } } }
|
||||
reply():Promise<Reply>{return new Promise((resolve,reject)=>{ const onError=(e:Error)=>{this.socket.off('error',onError);reject(e)}; this.socket.once('error',onError); this.waiters.push((reply)=>{this.socket.off('error',onError);resolve(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);}
|
||||
end(){this.socket.end();}
|
||||
}
|
||||
|
||||
function encodedSubject(value: string) {
|
||||
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
||||
}
|
||||
|
||||
function escapeHeader(value: string) {
|
||||
return value.replace(/[\r\n]/g, ' ').replaceAll('"', "'");
|
||||
}
|
||||
|
||||
function mime(input: MailInput, config: RuntimeSmtpConfig) {
|
||||
const headers = [
|
||||
`From: ${config.fromHeader}`,
|
||||
`To: ${input.to}`,
|
||||
`Subject: ${encodedSubject(input.subject)}`,
|
||||
...(config.replyTo ? [`Reply-To: ${config.replyTo}`] : []),
|
||||
'MIME-Version: 1.0',
|
||||
];
|
||||
if (!input.attachment) {
|
||||
return [
|
||||
...headers,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
Buffer.from(input.text, 'utf8').toString('base64'),
|
||||
'',
|
||||
].join('\r\n').replace(/^\./gm, '..');
|
||||
}
|
||||
const boundary = `dh-${randomUUID()}`;
|
||||
const filename = escapeHeader(input.attachment.filename);
|
||||
return [
|
||||
...headers,
|
||||
`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="${filename}"`,
|
||||
'Content-Transfer-Encoding: base64',
|
||||
`Content-Disposition: attachment; filename="${filename}"`,
|
||||
'',
|
||||
input.attachment.content.toString('base64').replace(/(.{76})/g, '$1\r\n'),
|
||||
`--${boundary}--`,
|
||||
'',
|
||||
].join('\r\n').replace(/^\./gm, '..');
|
||||
}
|
||||
function subject(value:string){return `=?UTF-8?B?${Buffer.from(value,'utf8').toString('base64')}?=`;}
|
||||
function envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();}
|
||||
function mime(input:MailInput,from:string,replyTo:string|null){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,replyTo?`Reply-To: ${replyTo}`:null,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].filter((line):line is string=>line!==null).join('\r\n'); return body.replace(/^\./gm,'..'); }
|
||||
|
||||
@Injectable()
|
||||
export class SmtpDeliveryService implements OnModuleInit {
|
||||
private runtime: RuntimeSmtpConfig | null;
|
||||
private runtimeError: string | null = null;
|
||||
|
||||
export class SmtpDeliveryService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.runtime = this.environmentConfig();
|
||||
private readonly dataSource:DataSource,
|
||||
private readonly config:ConfigService,
|
||||
){}
|
||||
|
||||
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 onModuleInit(): Promise<void> {
|
||||
await this.reload().catch((error) => {
|
||||
this.runtime = null;
|
||||
this.runtimeError = error instanceof Error ? error.message : 'SMTP configuration error';
|
||||
});
|
||||
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};
|
||||
}
|
||||
|
||||
configured() {
|
||||
return Boolean(this.runtime);
|
||||
}
|
||||
|
||||
activeSource() {
|
||||
return this.runtime?.source ?? null;
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
const [row] = (await this.dataSource.query(`
|
||||
SELECT
|
||||
enabled,
|
||||
host,
|
||||
port,
|
||||
security_mode AS "securityMode",
|
||||
username,
|
||||
password_encrypted AS "passwordEncrypted",
|
||||
from_name AS "fromName",
|
||||
from_email AS "fromEmail",
|
||||
reply_to AS "replyTo"
|
||||
FROM smtp_settings
|
||||
WHERE id = 1
|
||||
`).catch(() => [])) as Array<{
|
||||
enabled: boolean;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
securityMode: 'TLS' | 'STARTTLS' | null;
|
||||
username: string | null;
|
||||
passwordEncrypted: string | null;
|
||||
fromName: string | null;
|
||||
fromEmail: string | null;
|
||||
replyTo: string | null;
|
||||
}>;
|
||||
|
||||
if (!row?.enabled) {
|
||||
this.runtime = this.environmentConfig();
|
||||
this.runtimeError = null;
|
||||
return;
|
||||
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]);
|
||||
}
|
||||
if (!row.host || !row.port || !row.securityMode || !row.fromName || !row.fromEmail) {
|
||||
this.runtime = null;
|
||||
this.runtimeError = 'La configuración SMTP del Superadmin está incompleta';
|
||||
return;
|
||||
}
|
||||
let pass = '';
|
||||
if (row.passwordEncrypted) {
|
||||
const key = this.config.get<string>('SMTP_SETTINGS_ENCRYPTION_KEY');
|
||||
if (!key) {
|
||||
this.runtime = null;
|
||||
this.runtimeError = 'Falta SMTP_SETTINGS_ENCRYPTION_KEY para descifrar la contraseña SMTP';
|
||||
return;
|
||||
}
|
||||
pass = decryptSmtpSecret(row.passwordEncrypted, key);
|
||||
}
|
||||
this.runtime = {
|
||||
source: 'DATABASE',
|
||||
host: row.host,
|
||||
port: Number(row.port),
|
||||
securityMode: row.securityMode,
|
||||
user: row.username ?? '',
|
||||
pass,
|
||||
fromHeader: `"${escapeHeader(row.fromName)}" <${row.fromEmail}>`,
|
||||
fromEmail: row.fromEmail,
|
||||
replyTo: row.replyTo,
|
||||
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,
|
||||
};
|
||||
this.runtimeError = null;
|
||||
}
|
||||
|
||||
async send(input: MailInput): Promise<{ messageId: string }> {
|
||||
if (!this.runtime) await this.reload();
|
||||
const config = this.runtime;
|
||||
if (!config) throw new Error(this.runtimeError ?? 'SMTP no configurado');
|
||||
|
||||
const base = await new Promise<Socket | TLSSocket>((resolve, reject) => {
|
||||
if (config.securityMode === 'TLS') {
|
||||
const tls = connectTls({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: true,
|
||||
}, () => resolve(tls));
|
||||
tls.once('error', reject);
|
||||
} else {
|
||||
const raw = connectNet({ host: config.host, port: config.port }, () => resolve(raw));
|
||||
raw.once('error', reject);
|
||||
}
|
||||
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 (config.securityMode === 'STARTTLS') {
|
||||
if (!/STARTTLS/i.test(ehlo.text)) throw new Error('El servidor SMTP no ofrece STARTTLS');
|
||||
await session.command('STARTTLS', 220);
|
||||
const raw = session.detachForUpgrade() as Socket;
|
||||
const upgraded = await new Promise<TLSSocket>((resolve, reject) => {
|
||||
const tls = connectTls({ socket: raw, servername: config.host, rejectUnauthorized: true }, () => resolve(tls));
|
||||
tls.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);
|
||||
ehlo=await session.command('EHLO dh-inspeccion',250);
|
||||
}
|
||||
if (config.user) {
|
||||
if (/AUTH[^\n]*PLAIN/i.test(ehlo.text)) {
|
||||
const token = Buffer.from(`\u0000${config.user}\u0000${config.pass}`, 'utf8').toString('base64');
|
||||
await session.command(`AUTH PLAIN ${token}`, 235);
|
||||
} else {
|
||||
await session.command('AUTH LOGIN', 334);
|
||||
await session.command(Buffer.from(config.user).toString('base64'), 334);
|
||||
await session.command(Buffer.from(config.pass).toString('base64'), 235);
|
||||
}
|
||||
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); }
|
||||
}
|
||||
await session.command(`MAIL FROM:<${config.fromEmail}>`, 250);
|
||||
await session.command(`RCPT TO:<${input.to}>`, [250, 251]);
|
||||
await session.command('DATA', 354);
|
||||
session.write(`${mime(input, config)}\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);
|
||||
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() };
|
||||
const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i);
|
||||
return {messageId:match?.[1]??randomUUID()};
|
||||
}
|
||||
|
||||
private environmentConfig(): RuntimeSmtpConfig | null {
|
||||
const host = this.config.get<string>('SMTP_HOST');
|
||||
const from = this.config.get<string>('MAIL_FROM');
|
||||
if (!host || !from) return null;
|
||||
const match = from.match(/^(?:\s*"?([^"<]+)"?\s*)?<([^>]+)>\s*$/);
|
||||
const fromEmail = (match?.[2] ?? from).trim();
|
||||
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: 'ENV',
|
||||
host,
|
||||
port: Number(this.config.get<string>('SMTP_PORT') ?? 587),
|
||||
securityMode: String(this.config.get<string>('SMTP_SECURE') ?? 'false').toLowerCase() === 'true'
|
||||
? 'TLS'
|
||||
: 'STARTTLS',
|
||||
user: this.config.get<string>('SMTP_USER') ?? '',
|
||||
pass: this.config.get<string>('SMTP_PASS') ?? '',
|
||||
fromHeader: from,
|
||||
fromEmail,
|
||||
replyTo: null,
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
function keyFromSecret(secret: string): Buffer {
|
||||
if (secret.trim().length < 24) {
|
||||
throw new Error('SMTP_SETTINGS_ENCRYPTION_KEY must contain at least 24 characters');
|
||||
}
|
||||
return createHash('sha256').update(secret, 'utf8').digest();
|
||||
}
|
||||
|
||||
export function encryptSmtpSecret(value: string, secret: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', keyFromSecret(secret), 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'),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
export function decryptSmtpSecret(value: string, secret: string): string {
|
||||
const [version, ivEncoded, tagEncoded, encryptedEncoded] = value.split(':');
|
||||
if (version !== 'v1' || !ivEncoded || !tagEncoded || encryptedEncoded === undefined) {
|
||||
throw new Error('Invalid encrypted SMTP password payload');
|
||||
}
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
keyFromSecret(secret),
|
||||
Buffer.from(ivEncoded, 'base64'),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tagEncoded, 'base64'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedEncoded, 'base64')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user