Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccca7a6ff1 | ||
|
|
63671686a9 | ||
|
|
8baba89ce6 | ||
|
|
ad65b44142 | ||
|
|
d0cc7c6f9d | ||
|
|
b80f83ed5f | ||
|
|
fbc63fafb5 | ||
|
|
edc05a5f50 | ||
|
|
ae583a8e45 | ||
|
|
b160e7344b | ||
|
|
41b52d34bf | ||
|
|
7dab175958 | ||
|
|
f1dbb75834 | ||
|
|
e9b318fdb5 | ||
|
|
890b54f7c8 | ||
|
|
3634768f9a | ||
|
|
fbe8f2e8cf | ||
|
|
367c7df45a | ||
|
|
0f9aa589b5 | ||
|
|
dac6c94370 | ||
|
|
1cd11e83ab | ||
|
|
eb89680c32 | ||
|
|
cbab839935 | ||
|
|
ce18f5d1a3 | ||
|
|
03a3a66c51 | ||
|
|
2d4e8ae2a7 | ||
|
|
3e5dfb78a2 | ||
|
|
ecf6f71c58 | ||
|
|
bd0f22969c | ||
|
|
5f3987747d | ||
|
|
a208788488 | ||
|
|
2e5183cc3c | ||
|
|
80ce9ced0f | ||
|
|
d49fb81bcf | ||
|
|
9e50967089 | ||
|
|
a1450da51e | ||
|
|
97a774c264 | ||
|
|
c9f1434462 | ||
|
|
d14cf737c0 | ||
|
|
9f60e671d2 | ||
|
|
28f6fb3684 | ||
|
|
d7184ed537 | ||
|
|
203443d9c6 | ||
|
|
45ca6a783e | ||
|
|
d3bfdeac67 | ||
|
|
9479ae6d1a | ||
|
|
4ca65c6a89 | ||
|
|
3495b33157 | ||
|
|
115a6a2ed8 | ||
|
|
4796581532 | ||
|
|
1d3979fda3 | ||
|
|
72e75fab2f | ||
|
|
26ec07864a | ||
|
|
65cb28299d | ||
|
|
f2f9ccaa72 | ||
|
|
c7fd1f8583 | ||
|
|
c50360e732 | ||
|
|
387357b96c | ||
|
|
eb47a3d66f |
@@ -1,5 +1,5 @@
|
||||
name: Android APK
|
||||
# F3.1: este workflow genera la APK debug verificable de la rama antes de promoverla.
|
||||
# F3.2: genera la APK debug verificable antes de promover la integración multi-Acta.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
- 'feature/f2-3*'
|
||||
- 'feature/f2-4*'
|
||||
- 'feature/f3-1*'
|
||||
- 'feature/f3-2*'
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- '.github/workflows/android.yml'
|
||||
@@ -57,7 +58,7 @@ jobs:
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: DH-Inspeccion-F3.1-0.11.0-debug
|
||||
name: DH-Inspeccion-F3.2-0.12.0-debug
|
||||
path: android-app/app/build/outputs/apk/debug/app-debug.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
@@ -47,6 +47,8 @@ jobs:
|
||||
cache-dependency-path: web-v2/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- name: F3.1 WEB contract
|
||||
run: bash ../scripts/check-f3-1-web-contract.sh
|
||||
- run: npm run build
|
||||
|
||||
contract:
|
||||
@@ -62,5 +64,15 @@ jobs:
|
||||
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 preflight
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
|
||||
docker build --target builder -t "$image" api-v3
|
||||
docker run --rm \
|
||||
-v "$PWD/api-v3/test:/app/test:ro" \
|
||||
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
||||
"$image" npm test
|
||||
docker image rm "$image" >/dev/null 2>&1 || true
|
||||
- name: Build production images
|
||||
run: docker compose --env-file .env.example build api migrate web
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
name: F3.2 Multi-Acta CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'feature/f3-2*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api:
|
||||
name: API · F3.2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api-v3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: api-v3/package-lock.json
|
||||
- run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Tests
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
web:
|
||||
name: WEB · regression
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web-v2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: web-v2/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- name: F3.1 structural WEB contract
|
||||
run: bash ../scripts/check-f3-1-web-contract.sh
|
||||
- run: npm run build
|
||||
|
||||
deploy-preflight:
|
||||
name: VPS-equivalent preflight / Docker
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [api, web]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate shell scripts
|
||||
run: |
|
||||
while IFS= read -r -d '' script; do
|
||||
bash -n "$script"
|
||||
done < <(find scripts -type f -name '*.sh' -print0)
|
||||
- name: Validate Compose
|
||||
run: docker compose --env-file .env.example config >/dev/null
|
||||
- name: VPS-equivalent isolated API tests
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
image="dhv2-api:f3-2-preflight-${GITHUB_SHA::12}"
|
||||
docker build --target builder -t "$image" api-v3
|
||||
docker run --rm \
|
||||
-v "$PWD/api-v3/test:/app/test:ro" \
|
||||
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
||||
"$image" npm test
|
||||
docker image rm "$image" >/dev/null 2>&1 || true
|
||||
- name: Build production images
|
||||
run: docker compose --env-file .env.example build api migrate web
|
||||
@@ -0,0 +1,89 @@
|
||||
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
|
||||
@@ -1,35 +1,10 @@
|
||||
# DH Inspección V2 · D5.6.2
|
||||
|
||||
Hotfix de mantenimiento de Fase D para accesos Android y contrato de regresión de cierre.
|
||||
|
||||
# DH Inspección V2
|
||||
|
||||
Versión: **0.19.5-1**
|
||||
Fase: **D5.5.1 · Corrección de staging de planificación**
|
||||
Repositorio del sistema DH Inspección V2.
|
||||
|
||||
D5.5.1 conserva íntegramente D5.5 y corrige únicamente el contrato de pruebas de staging. D5.5 consolida la preparación de visitas desde oficina antes del trabajo de campo. La visita queda anclada explícitamente a Área y Organización operadora, y el sistema genera un checklist auditable con antecedentes, respuestas vencidas, controles vencidos y próximos controles.
|
||||
## Estructura
|
||||
|
||||
## Alcance D5.5
|
||||
|
||||
- selección web Área → Organización operadora vigente;
|
||||
- fecha y equipo inspector dentro del plan;
|
||||
- checklist automático versionado y append-only;
|
||||
- respuestas de empresa vencidas y controles de verificación vencidos;
|
||||
- próximos controles dentro de 30 días de la fecha planificada;
|
||||
- antecedentes históricos del mismo contexto;
|
||||
- incorporación automática de registros accionables;
|
||||
- alta manual de registros preventivos del mismo Área/Operadora;
|
||||
- exclusiones sólo mediante motivo auditable;
|
||||
- reincorporación sin borrar la exclusión histórica;
|
||||
- visitas de verificación integradas al mismo contrato de planificación;
|
||||
- cambio de contexto o fecha invalida el checklist hasta regeneración explícita;
|
||||
- inicio de visita exclusivamente desde APK mediante la política móvil ya existente.
|
||||
|
||||
## Migración
|
||||
|
||||
`1789063200000-phase-d5-5-web-planning-checklist.ts`
|
||||
`PhaseD55WebPlanningChecklist1789063200000`
|
||||
|
||||
## Despliegue
|
||||
|
||||
El despliegue debe partir exactamente de API/WEB **0.19.4-1** y ejecutar `scripts/deploy-phase-d5-5.sh`. Antes de modificar producción, el script compila API y web en staging y exige **252 tests** aprobados.
|
||||
- `api-v3/`: API v3.
|
||||
- `web-v2/`: aplicación web.
|
||||
- `scripts/`: utilidades operativas y de despliegue.
|
||||
- `docs/`: documentación técnica.
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 18
|
||||
versionName = "0.11.0"
|
||||
versionCode = 19
|
||||
versionName = "0.12.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
@@ -16,6 +16,11 @@ import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse
|
||||
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
|
||||
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
||||
import com.korexlabs.dhinspeccion.data.FieldType
|
||||
import com.korexlabs.dhinspeccion.data.MobileActClosure
|
||||
import com.korexlabs.dhinspeccion.data.MobileActDetail
|
||||
import com.korexlabs.dhinspeccion.data.MobileActSummary
|
||||
import com.korexlabs.dhinspeccion.data.MobileActsRepository
|
||||
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
|
||||
import com.korexlabs.dhinspeccion.data.StoredSession
|
||||
import com.korexlabs.dhinspeccion.data.VisitDetail
|
||||
import com.korexlabs.dhinspeccion.data.VisitSummary
|
||||
@@ -26,6 +31,7 @@ import java.time.Instant
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = DhRepository(application)
|
||||
private val findingsRepository = FieldFindingsRepository(application)
|
||||
private val actsRepository = MobileActsRepository(application)
|
||||
|
||||
var session: StoredSession? by mutableStateOf(repository.currentSession())
|
||||
private set
|
||||
@@ -48,6 +54,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var acts: List<MobileActSummary> by mutableStateOf(emptyList())
|
||||
private set
|
||||
var selectedAct: MobileActDetail? by mutableStateOf(null)
|
||||
private set
|
||||
var actClosure: MobileActClosure? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var fieldFindingOptions: FieldFindingOptionsResponse? by mutableStateOf(null)
|
||||
private set
|
||||
var lastCreatedFinding: FieldFindingItem? by mutableStateOf(null)
|
||||
@@ -85,6 +98,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
inventory = emptyList()
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
clearActState()
|
||||
clearFindingState()
|
||||
}
|
||||
}
|
||||
@@ -101,6 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
clearFindingState()
|
||||
loadActsInternal(id, selectDraft = true)
|
||||
}
|
||||
|
||||
fun closeVisitView() {
|
||||
@@ -108,6 +123,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
inventory = emptyList()
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
clearActState()
|
||||
clearFindingState()
|
||||
loadVisits()
|
||||
}
|
||||
@@ -117,10 +133,51 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
launchBusy {
|
||||
visit = repository.startVisit(id)
|
||||
notice = "Inspección iniciada."
|
||||
loadActsInternal(id, selectDraft = true)
|
||||
loadVisitsInternal()
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadActs() {
|
||||
val visitId = visit?.id ?: return
|
||||
launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) }
|
||||
}
|
||||
|
||||
fun selectAct(actId: String) {
|
||||
launchBusy {
|
||||
selectedAct = actsRepository.get(actId)
|
||||
actClosure = actsRepository.closure(actId)
|
||||
clearFindingState()
|
||||
}
|
||||
}
|
||||
|
||||
fun createActForSelectedInventory() {
|
||||
val currentVisit = visit ?: return
|
||||
val asset = selectedFieldAsset?.asset
|
||||
if (currentVisit.status != "IN_PROGRESS") {
|
||||
error = "La inspección debe estar en curso para crear un Acta."
|
||||
return
|
||||
}
|
||||
if (asset == null) {
|
||||
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
|
||||
return
|
||||
}
|
||||
if (acts.any { it.status == "DRAFT" }) {
|
||||
error = "Ya existe un Acta en borrador. Cerrala o cancelala antes de crear la siguiente."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
val created = actsRepository.create(currentVisit.id, asset.id, currentVisit.code)
|
||||
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."
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchInventory(search: String, parentId: String? = null) {
|
||||
val id = visit?.id ?: return
|
||||
launchBusy {
|
||||
@@ -141,8 +198,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
|
||||
notice = "Inventario agregado a la inspección."
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||
loadFindingOptionsInternal(visitId, item.id)
|
||||
val draft = selectedDraftAct()
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
|
||||
loadActsInternal(visitId, selectDraft = false)
|
||||
loadFindingOptionsInternal(visitId, item.id, draft.id)
|
||||
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,8 +269,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
|
||||
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||
loadFindingOptionsInternal(visitId, result.canonical.id)
|
||||
val draft = selectedDraftAct()
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
|
||||
loadActsInternal(visitId, selectDraft = false)
|
||||
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,8 +302,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
"Fotografía registrada."
|
||||
}
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
if (response.capture.readyForFinding) {
|
||||
loadFindingOptionsInternal(visitId, asset.id)
|
||||
val draft = selectedDraftAct()
|
||||
if (response.capture.readyForFinding && draft != null) {
|
||||
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
|
||||
loadActsInternal(visitId, selectDraft = false)
|
||||
loadFindingOptionsInternal(visitId, asset.id, draft.id)
|
||||
} else if (response.capture.readyForFinding) {
|
||||
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,7 +316,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun openFindingForSelected() {
|
||||
val visitId = visit?.id ?: return
|
||||
val assetId = selectedFieldAsset?.asset?.id ?: return
|
||||
launchBusy { loadFindingOptionsInternal(visitId, assetId) }
|
||||
val draft = selectedDraftAct()
|
||||
if (draft == null) {
|
||||
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
|
||||
loadActsInternal(visitId, selectDraft = false)
|
||||
loadFindingOptionsInternal(visitId, assetId, draft.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun createFieldFinding(
|
||||
@@ -259,6 +338,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
) {
|
||||
val visitId = visit?.id ?: return
|
||||
val assetId = selectedFieldAsset?.asset?.id ?: return
|
||||
val actId = selectedDraftAct()?.id
|
||||
if (actId == null) {
|
||||
error = "No hay un Acta en borrador seleccionada."
|
||||
return
|
||||
}
|
||||
if (description.isBlank()) {
|
||||
error = "Describí el Hallazgo antes de guardarlo."
|
||||
return
|
||||
@@ -272,10 +356,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
selectedAct = actsRepository.ensureAsset(actId, assetId)
|
||||
val response = findingsRepository.create(
|
||||
visitId,
|
||||
assetId,
|
||||
CreateFieldFindingRequest(
|
||||
actId = actId,
|
||||
catalogItemId = catalogItemId,
|
||||
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
|
||||
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
|
||||
@@ -285,8 +371,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
),
|
||||
)
|
||||
lastCreatedFinding = response.finding
|
||||
notice = "Hallazgo ${response.finding.code} registrado. Podés agregar evidencia fotográfica."
|
||||
loadFindingOptionsInternal(visitId, assetId, keepLastCreated = true)
|
||||
notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica."
|
||||
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
|
||||
loadActsInternal(visitId, selectDraft = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +405,139 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
launchBusy { loadEvidenceInternal(findingId) }
|
||||
}
|
||||
|
||||
fun setCompanyResponsiblePresent(
|
||||
fullName: String,
|
||||
documentType: String,
|
||||
documentNumber: String,
|
||||
position: String,
|
||||
email: String?,
|
||||
phone: String?,
|
||||
) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank()) {
|
||||
error = "Completá nombre, documento y cargo del responsable de la empresa."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
actClosure = actsRepository.setResponsible(
|
||||
actId,
|
||||
MobileResponsibleRequest(
|
||||
attendanceStatus = "PRESENT",
|
||||
fullName = fullName.trim(),
|
||||
documentType = documentType,
|
||||
documentNumber = documentNumber.trim(),
|
||||
position = position.trim(),
|
||||
email = email?.trim()?.takeIf { it.isNotBlank() },
|
||||
phone = phone?.trim()?.takeIf { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
notice = "Responsable de empresa registrado para el Acta."
|
||||
}
|
||||
}
|
||||
|
||||
fun setCompanyResponsibleAbsent(reason: String) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
if (reason.trim().length < 10) {
|
||||
error = "Indicá un motivo de ausencia de al menos 10 caracteres."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
actClosure = actsRepository.setResponsible(
|
||||
actId,
|
||||
MobileResponsibleRequest(
|
||||
attendanceStatus = "ABSENT",
|
||||
absenceReason = reason.trim(),
|
||||
),
|
||||
)
|
||||
notice = "Ausencia del responsable registrada."
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareSelectedAct() {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.prepare(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
notice = "Acta preparada. Su contenido quedó congelado para las firmas."
|
||||
}
|
||||
}
|
||||
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
||||
fun signSelectedActAsInspector(
|
||||
png: File,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
accuracyM: Double?,
|
||||
) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
|
||||
notice = "Firma del inspector incorporada al Acta."
|
||||
}
|
||||
}
|
||||
|
||||
fun signSelectedActAsCompany(
|
||||
png: File,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
accuracyM: Double?,
|
||||
manifestation: String,
|
||||
statement: String?,
|
||||
) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.signCompany(
|
||||
actId, png, latitude, longitude, accuracyM, manifestation, statement,
|
||||
)
|
||||
notice = if (manifestation == "DISSENT") {
|
||||
"Firma de empresa registrada con disidencia."
|
||||
} else {
|
||||
"Firma de empresa registrada."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun recordCompanyOutcome(status: String, reason: String) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
if (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."
|
||||
}
|
||||
}
|
||||
|
||||
fun closeSelectedAct() {
|
||||
val currentVisit = visit ?: return
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.closeAct(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."
|
||||
}
|
||||
}
|
||||
|
||||
fun closeInspection() {
|
||||
val visitId = visit?.id ?: return
|
||||
launchBusy {
|
||||
visit = actsRepository.closeVisit(visitId)
|
||||
loadVisitsInternal()
|
||||
notice = "Inspección cerrada. Las Actas y documentos quedan disponibles para oficina."
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFindingFlow() {
|
||||
fieldFindingOptions = null
|
||||
lastCreatedFinding = null
|
||||
@@ -330,12 +550,43 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
clearFindingState()
|
||||
}
|
||||
|
||||
private fun selectedDraftAct(): MobileActDetail? =
|
||||
selectedAct?.takeIf { it.status == "DRAFT" }
|
||||
?: acts.firstOrNull { it.status == "DRAFT" }?.let { summary ->
|
||||
selectedAct?.takeIf { it.id == summary.id && it.status == "DRAFT" }
|
||||
}
|
||||
|
||||
private suspend fun loadActsInternal(visitId: String, selectDraft: Boolean) {
|
||||
acts = actsRepository.list(visitId).data
|
||||
val currentId = selectedAct?.id
|
||||
val current = currentId?.let { id -> acts.firstOrNull { it.id == id } }
|
||||
val target = when {
|
||||
current != null -> current.id
|
||||
selectDraft -> acts.firstOrNull { it.status == "DRAFT" }?.id
|
||||
else -> null
|
||||
}
|
||||
if (target != null) {
|
||||
selectedAct = actsRepository.get(target)
|
||||
actClosure = actsRepository.closure(target)
|
||||
} else if (current == null) {
|
||||
selectedAct = null
|
||||
actClosure = null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshSelectedActInternal(actId: String) {
|
||||
selectedAct = actsRepository.get(actId)
|
||||
actClosure = actsRepository.closure(actId)
|
||||
visit?.id?.let { loadActsInternal(it, selectDraft = false) }
|
||||
}
|
||||
|
||||
private suspend fun loadFindingOptionsInternal(
|
||||
visitId: String,
|
||||
assetId: String,
|
||||
actId: String,
|
||||
keepLastCreated: Boolean = false,
|
||||
) {
|
||||
val options = findingsRepository.options(visitId, assetId)
|
||||
val options = findingsRepository.options(visitId, assetId, actId)
|
||||
fieldFindingOptions = options
|
||||
if (!keepLastCreated) lastCreatedFinding = null
|
||||
val loaded = linkedMapOf<String, List<FieldFindingEvidence>>()
|
||||
@@ -351,6 +602,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun clearActState() {
|
||||
acts = emptyList()
|
||||
selectedAct = null
|
||||
actClosure = null
|
||||
}
|
||||
|
||||
private fun clearFindingState() {
|
||||
fieldFindingOptions = null
|
||||
lastCreatedFinding = null
|
||||
|
||||
+9
-3
@@ -22,6 +22,7 @@ import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
|
||||
@@ -100,12 +101,15 @@ data class FieldFindingEvidenceListResponse(
|
||||
data class FieldFindingOptionsResponse(
|
||||
val act: FieldFindingAct,
|
||||
val capture: CaptureStatus = CaptureStatus(),
|
||||
val assetIncludedInAct: Boolean = true,
|
||||
val catalog: FieldFindingCatalog,
|
||||
val findings: List<FieldFindingItem> = emptyList(),
|
||||
val canAddAnother: Boolean = true,
|
||||
val actSelectionMode: String = "EXPLICIT",
|
||||
)
|
||||
|
||||
data class CreateFieldFindingRequest(
|
||||
val actId: String,
|
||||
val catalogItemId: String? = null,
|
||||
val customTitle: String? = null,
|
||||
val customLegalBasis: String? = null,
|
||||
@@ -119,6 +123,7 @@ data class FieldFindingCreateResponse(
|
||||
val capture: CaptureStatus = CaptureStatus(),
|
||||
val finding: FieldFindingItem,
|
||||
val canAddAnother: Boolean = true,
|
||||
val actSelectionMode: String = "EXPLICIT",
|
||||
)
|
||||
|
||||
private interface FieldFindingsApi {
|
||||
@@ -127,6 +132,7 @@ private interface FieldFindingsApi {
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Path("assetId") assetId: String,
|
||||
@Query("actId") actId: String,
|
||||
): FieldFindingOptionsResponse
|
||||
|
||||
@POST("inspection-visits/{visitId}/field-findings/{assetId}")
|
||||
@@ -166,7 +172,7 @@ private interface FieldFindingsApi {
|
||||
|
||||
/**
|
||||
* Cliente de campo para Hallazgos y sus evidencias append-only.
|
||||
* Comparte el almacén cifrado de sesión y nunca persiste la contraseña.
|
||||
* F3.2 exige que la APK identifique explícitamente el Acta activa.
|
||||
*/
|
||||
class FieldFindingsRepository(context: Context) {
|
||||
private val store = SecureSessionStore(context.applicationContext)
|
||||
@@ -179,9 +185,9 @@ class FieldFindingsRepository(context: Context) {
|
||||
.build()
|
||||
.create(FieldFindingsApi::class.java)
|
||||
|
||||
suspend fun options(visitId: String, assetId: String): FieldFindingOptionsResponse =
|
||||
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse =
|
||||
authorized { session ->
|
||||
api.options("Bearer ${session.accessToken}", visitId, assetId)
|
||||
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
|
||||
}
|
||||
|
||||
suspend fun create(
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
package com.korexlabs.dhinspeccion.data
|
||||
|
||||
import android.content.Context
|
||||
import com.korexlabs.dhinspeccion.BuildConfig
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
|
||||
data class MobileActSummary(
|
||||
val id: String,
|
||||
val visitId: String,
|
||||
val code: String,
|
||||
val status: String,
|
||||
val occurredAt: String,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
val assetCount: Int = 0,
|
||||
val findingCount: Int = 0,
|
||||
)
|
||||
|
||||
data class MobileActDetail(
|
||||
val id: String,
|
||||
val visitId: String,
|
||||
val code: String,
|
||||
val status: String,
|
||||
val occurredAt: String,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
val assetCount: Int = 0,
|
||||
val findingCount: Int = 0,
|
||||
val assets: List<AssetSummary> = emptyList(),
|
||||
)
|
||||
|
||||
data class MobileActListMeta(
|
||||
val page: Int = 1,
|
||||
val pageSize: Int = 100,
|
||||
val total: Int = 0,
|
||||
val totalPages: Int = 0,
|
||||
)
|
||||
|
||||
data class MobileActListResponse(
|
||||
val data: List<MobileActSummary> = emptyList(),
|
||||
val meta: MobileActListMeta = MobileActListMeta(),
|
||||
)
|
||||
|
||||
data class CreateMobileActRequest(
|
||||
val occurredAt: String,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
val assetIds: List<String>,
|
||||
)
|
||||
|
||||
data class UpdateMobileActRequest(
|
||||
val assetIds: List<String>,
|
||||
)
|
||||
|
||||
data class MobileResponsibleRequest(
|
||||
val attendanceStatus: String,
|
||||
val fullName: String? = null,
|
||||
val documentType: String? = null,
|
||||
val documentNumber: String? = null,
|
||||
val position: String? = null,
|
||||
val email: String? = null,
|
||||
val phone: String? = null,
|
||||
val absenceReason: String? = null,
|
||||
)
|
||||
|
||||
data class MobileResponsible(
|
||||
val actId: String,
|
||||
val attendanceStatus: String,
|
||||
val fullName: String? = null,
|
||||
val documentType: String? = null,
|
||||
val documentNumber: String? = null,
|
||||
val position: String? = null,
|
||||
val email: String? = null,
|
||||
val phone: String? = null,
|
||||
val absenceReason: String? = null,
|
||||
)
|
||||
|
||||
data class MobileActClosureHeader(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val status: String,
|
||||
val visitId: String,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
)
|
||||
|
||||
data class MobileVisitClosureHeader(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val status: String,
|
||||
val actualClosedAt: String? = null,
|
||||
)
|
||||
|
||||
data class MobileSignature(
|
||||
val id: String,
|
||||
val signerType: String,
|
||||
val signerUserId: String? = null,
|
||||
val signerName: String,
|
||||
val status: String,
|
||||
val reason: String? = null,
|
||||
val companyManifestation: String? = null,
|
||||
val companyStatement: String? = null,
|
||||
val signedAt: String? = null,
|
||||
val imageSha256: String? = null,
|
||||
)
|
||||
|
||||
data class MobileClosureRecord(
|
||||
val schemaVersion: String,
|
||||
val preparedSha256: String,
|
||||
val preparedAt: String,
|
||||
val finalSha256: String? = null,
|
||||
val deviceClosedAt: String? = null,
|
||||
val serverClosedAt: String? = null,
|
||||
val uploadMode: String? = null,
|
||||
val isCurrent: Boolean = true,
|
||||
)
|
||||
|
||||
data class MobileClosureConsents(
|
||||
val version: String = "",
|
||||
val inspector: String = "",
|
||||
val company: String = "",
|
||||
)
|
||||
|
||||
data class MobileActClosure(
|
||||
val act: MobileActClosureHeader,
|
||||
val visit: MobileVisitClosureHeader,
|
||||
val responsible: MobileResponsible? = null,
|
||||
val closure: MobileClosureRecord? = null,
|
||||
val signatures: List<MobileSignature> = emptyList(),
|
||||
val consents: MobileClosureConsents = MobileClosureConsents(),
|
||||
)
|
||||
|
||||
data class MobileCompanyOutcomeRequest(
|
||||
val status: String,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
data class MobileCloseActRequest(
|
||||
val clientClosedAt: String = Instant.now().toString(),
|
||||
val uploadMode: String = "ONLINE",
|
||||
)
|
||||
|
||||
data class MobileCloseVisitRequest(
|
||||
val clientClosedAt: String = Instant.now().toString(),
|
||||
)
|
||||
|
||||
private interface MobileActsApi {
|
||||
@GET("inspection-visits/{visitId}/acts")
|
||||
suspend fun listActs(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Query("pageSize") pageSize: Int = 100,
|
||||
): MobileActListResponse
|
||||
|
||||
@GET("inspection-acts/{actId}")
|
||||
suspend fun act(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActDetail
|
||||
|
||||
@POST("inspection-visits/{visitId}/acts")
|
||||
suspend fun createAct(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Body request: CreateMobileActRequest,
|
||||
): MobileActDetail
|
||||
|
||||
@PATCH("inspection-acts/{actId}")
|
||||
suspend fun updateAct(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Body request: UpdateMobileActRequest,
|
||||
): MobileActDetail
|
||||
|
||||
@GET("inspection-acts/{actId}/closure")
|
||||
suspend fun closure(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActClosure
|
||||
|
||||
@PUT("inspection-acts/{actId}/responsible")
|
||||
suspend fun responsible(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Body request: MobileResponsibleRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/ready")
|
||||
suspend fun ready(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/reopen")
|
||||
suspend fun reopen(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActClosure
|
||||
|
||||
@Multipart
|
||||
@POST("inspection-acts/{actId}/signatures/inspector")
|
||||
suspend fun signInspector(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("consentAccepted") consentAccepted: RequestBody,
|
||||
@Part("clientSignedAt") clientSignedAt: RequestBody,
|
||||
@Part("latitude") latitude: RequestBody?,
|
||||
@Part("longitude") longitude: RequestBody?,
|
||||
@Part("accuracyM") accuracyM: RequestBody?,
|
||||
@Part("deviceLabel") deviceLabel: RequestBody,
|
||||
): MobileActClosure
|
||||
|
||||
@Multipart
|
||||
@POST("inspection-acts/{actId}/signatures/company")
|
||||
suspend fun signCompany(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("consentAccepted") consentAccepted: RequestBody,
|
||||
@Part("clientSignedAt") clientSignedAt: RequestBody,
|
||||
@Part("latitude") latitude: RequestBody?,
|
||||
@Part("longitude") longitude: RequestBody?,
|
||||
@Part("accuracyM") accuracyM: RequestBody?,
|
||||
@Part("deviceLabel") deviceLabel: RequestBody,
|
||||
@Part("manifestation") manifestation: RequestBody?,
|
||||
@Part("statement") statement: RequestBody?,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/company-outcome")
|
||||
suspend fun companyOutcome(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Body request: MobileCompanyOutcomeRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/close")
|
||||
suspend fun closeAct(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Body request: MobileCloseActRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-visits/{visitId}/close")
|
||||
suspend fun closeVisit(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Body request: MobileCloseVisitRequest,
|
||||
): VisitDetail
|
||||
|
||||
@POST("auth/mobile/refresh")
|
||||
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
|
||||
}
|
||||
|
||||
class MobileActsRepository(context: Context) {
|
||||
private val store = SecureSessionStore(context.applicationContext)
|
||||
private val refreshMutex = Mutex()
|
||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||
private val api: MobileActsApi = Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.client(OkHttpClient.Builder().build())
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(MobileActsApi::class.java)
|
||||
|
||||
suspend fun list(visitId: String): MobileActListResponse = authorized { session ->
|
||||
api.listActs("Bearer ${session.accessToken}", visitId)
|
||||
}
|
||||
|
||||
suspend fun get(actId: String): MobileActDetail = authorized { session ->
|
||||
api.act("Bearer ${session.accessToken}", actId)
|
||||
}
|
||||
|
||||
suspend fun create(visitId: String, assetId: String, visitCode: String): MobileActDetail = authorized { session ->
|
||||
api.createAct(
|
||||
"Bearer ${session.accessToken}",
|
||||
visitId,
|
||||
CreateMobileActRequest(
|
||||
occurredAt = Instant.now().toString(),
|
||||
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.",
|
||||
assetIds = listOf(assetId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
|
||||
val detail = get(actId)
|
||||
if (detail.assets.any { it.id == assetId }) return detail
|
||||
val ids = (detail.assets.map { it.id } + assetId).distinct()
|
||||
return authorized { session ->
|
||||
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(ids))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun closure(actId: String): MobileActClosure = authorized { session ->
|
||||
api.closure("Bearer ${session.accessToken}", actId)
|
||||
}
|
||||
|
||||
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session ->
|
||||
api.responsible("Bearer ${session.accessToken}", actId, request)
|
||||
}
|
||||
|
||||
suspend fun 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 signInspector(
|
||||
actId: String,
|
||||
png: File,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
accuracyM: Double?,
|
||||
): MobileActClosure = signature(actId, png, latitude, longitude, accuracyM, company = false)
|
||||
|
||||
suspend fun signCompany(
|
||||
actId: String,
|
||||
png: File,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
accuracyM: Double?,
|
||||
manifestation: String = "CONFORMITY",
|
||||
statement: String? = null,
|
||||
): MobileActClosure = signature(
|
||||
actId = actId,
|
||||
png = png,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
accuracyM = accuracyM,
|
||||
company = true,
|
||||
manifestation = manifestation,
|
||||
statement = statement,
|
||||
)
|
||||
|
||||
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session ->
|
||||
api.companyOutcome(
|
||||
"Bearer ${session.accessToken}",
|
||||
actId,
|
||||
MobileCompanyOutcomeRequest(status, reason.trim()),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun closeAct(actId: String): MobileActClosure = authorized { session ->
|
||||
api.closeAct("Bearer ${session.accessToken}", actId, MobileCloseActRequest())
|
||||
}
|
||||
|
||||
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
|
||||
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
|
||||
}
|
||||
|
||||
private suspend fun signature(
|
||||
actId: String,
|
||||
png: File,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
accuracyM: Double?,
|
||||
company: Boolean,
|
||||
manifestation: String? = null,
|
||||
statement: String? = null,
|
||||
): MobileActClosure = authorized { session ->
|
||||
val text = "text/plain".toMediaType()
|
||||
val file = MultipartBody.Part.createFormData(
|
||||
"file",
|
||||
png.name,
|
||||
png.asRequestBody("image/png".toMediaType()),
|
||||
)
|
||||
val consent = "true".toRequestBody(text)
|
||||
val signedAt = Instant.now().toString().toRequestBody(text)
|
||||
val device = "DH Android".toRequestBody(text)
|
||||
val lat = latitude?.toString()?.toRequestBody(text)
|
||||
val lon = longitude?.toString()?.toRequestBody(text)
|
||||
val accuracy = accuracyM?.toString()?.toRequestBody(text)
|
||||
if (company) {
|
||||
api.signCompany(
|
||||
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
|
||||
lat, lon, accuracy, device,
|
||||
manifestation?.toRequestBody(text),
|
||||
statement?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
|
||||
)
|
||||
} else {
|
||||
api.signInspector(
|
||||
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
|
||||
lat, lon, accuracy, device,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
|
||||
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||
try {
|
||||
return block(session)
|
||||
} catch (error: HttpException) {
|
||||
if (error.code() != 401) throw error
|
||||
}
|
||||
session = refresh(session.refreshToken)
|
||||
return block(session)
|
||||
}
|
||||
|
||||
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
|
||||
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||
if (latest.refreshToken != previousRefreshToken) return@withLock latest
|
||||
try {
|
||||
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
|
||||
} catch (error: Throwable) {
|
||||
store.clear()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,15 +76,31 @@ private data class F3GeoSnapshot(
|
||||
@Composable
|
||||
fun F3VisitRoot(model: MainViewModel) {
|
||||
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
|
||||
if (inventoryMode) {
|
||||
F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
|
||||
} else {
|
||||
F3VisitOverview(model, onInventory = { inventoryMode = true })
|
||||
var actsMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
|
||||
when {
|
||||
actsMode -> MobileActsScreen(
|
||||
model = model,
|
||||
onBack = { actsMode = false },
|
||||
onGoInventory = {
|
||||
actsMode = false
|
||||
inventoryMode = true
|
||||
},
|
||||
)
|
||||
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
|
||||
else -> F3VisitOverview(
|
||||
model = model,
|
||||
onInventory = { inventoryMode = true },
|
||||
onActs = { actsMode = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3VisitOverview(model: MainViewModel, onInventory: () -> Unit) {
|
||||
private fun F3VisitOverview(
|
||||
model: MainViewModel,
|
||||
onInventory: () -> Unit,
|
||||
onActs: () -> Unit,
|
||||
) {
|
||||
val visit = model.visit ?: return
|
||||
Column(
|
||||
Modifier
|
||||
@@ -137,9 +153,17 @@ private fun F3VisitOverview(model: MainViewModel, onInventory: () -> Unit) {
|
||||
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Abrir Inventario de campo")
|
||||
}
|
||||
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
|
||||
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
|
||||
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
|
||||
}
|
||||
} else if (visit.status == "CLOSED") {
|
||||
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Ver Actas · ${model.acts.size}")
|
||||
}
|
||||
} else if (visit.status == "PLANNED") {
|
||||
Text(
|
||||
"Primero iniciá la inspección para habilitar altas, fotografías y Hallazgos.",
|
||||
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.tasks.CancellationTokenSource
|
||||
import com.korexlabs.dhinspeccion.MainViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
private data class ActSignatureGeo(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracyM: Double?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MobileActsScreen(
|
||||
model: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onGoInventory: () -> Unit,
|
||||
) {
|
||||
val visit = model.visit ?: return
|
||||
val selected = model.selectedAct
|
||||
val closure = model.actClosure
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var attendance by rememberSaveable(selected?.id) {
|
||||
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
|
||||
}
|
||||
var fullName by rememberSaveable(selected?.id) {
|
||||
mutableStateOf(closure?.responsible?.fullName.orEmpty())
|
||||
}
|
||||
var documentType by rememberSaveable(selected?.id) {
|
||||
mutableStateOf(closure?.responsible?.documentType ?: "DNI")
|
||||
}
|
||||
var documentNumber by rememberSaveable(selected?.id) {
|
||||
mutableStateOf(closure?.responsible?.documentNumber.orEmpty())
|
||||
}
|
||||
var position by rememberSaveable(selected?.id) {
|
||||
mutableStateOf(closure?.responsible?.position.orEmpty())
|
||||
}
|
||||
var email by rememberSaveable(selected?.id) {
|
||||
mutableStateOf(closure?.responsible?.email.orEmpty())
|
||||
}
|
||||
var phone by rememberSaveable(selected?.id) {
|
||||
mutableStateOf(closure?.responsible?.phone.orEmpty())
|
||||
}
|
||||
var absenceReason by rememberSaveable(selected?.id) {
|
||||
mutableStateOf(closure?.responsible?.absenceReason.orEmpty())
|
||||
}
|
||||
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
|
||||
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(visit.id) { model.reloadActs() }
|
||||
|
||||
fun signWithGeo(file: File, company: Boolean) {
|
||||
scope.launch {
|
||||
val geo = runCatching { currentActSignatureGeo(context) }.getOrNull()
|
||||
if (company) {
|
||||
model.signSelectedActAsCompany(
|
||||
png = file,
|
||||
latitude = geo?.latitude,
|
||||
longitude = geo?.longitude,
|
||||
accuracyM = geo?.accuracyM,
|
||||
manifestation = manifestation,
|
||||
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
||||
)
|
||||
} else {
|
||||
model.signSelectedActAsInspector(
|
||||
png = file,
|
||||
latitude = geo?.latitude,
|
||||
longitude = geo?.longitude,
|
||||
accuracyM = geo?.accuracyM,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 36.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
OutlinedButton(onClick = onBack, enabled = !model.busy) { Text("Volver") }
|
||||
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
|
||||
F32ActMessage(model)
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Actas de esta inspección", fontWeight = FontWeight.Bold)
|
||||
if (model.acts.isEmpty()) {
|
||||
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
|
||||
}
|
||||
model.acts.forEach { act ->
|
||||
val active = selected?.id == act.id
|
||||
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"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val hasDraftAct = model.acts.any { it.status == "DRAFT" }
|
||||
if (visit.status == "IN_PROGRESS" && !hasDraftAct) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Nueva Acta", fontWeight = FontWeight.Bold)
|
||||
if (model.acts.any { it.status == "READY" }) {
|
||||
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.",
|
||||
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.")
|
||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Ir a Inventario y elegir")
|
||||
}
|
||||
} else {
|
||||
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
|
||||
Button(
|
||||
onClick = { model.createActForSelectedInventory() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Crear nueva Acta") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected != null) {
|
||||
HorizontalDivider()
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(actStatusLabel(selected.status))
|
||||
Text("${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) }
|
||||
}
|
||||
}
|
||||
|
||||
when (selected.status) {
|
||||
"DRAFT" -> {
|
||||
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
|
||||
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
|
||||
}
|
||||
if (attendance == "PRESENT") {
|
||||
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth())
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
|
||||
AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "✓ $kind" else kind) })
|
||||
}
|
||||
}
|
||||
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth())
|
||||
Button(
|
||||
onClick = {
|
||||
model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone)
|
||||
},
|
||||
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Guardar responsable") }
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
absenceReason,
|
||||
{ absenceReason = it },
|
||||
label = { Text("Motivo de ausencia *") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
|
||||
enabled = !model.busy && absenceReason.trim().length >= 10,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Guardar ausencia") }
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("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.")
|
||||
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.")
|
||||
Button(
|
||||
onClick = { model.prepareSelectedAct() },
|
||||
enabled = !model.busy && closure?.responsible != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Preparar Acta para firmas") }
|
||||
}
|
||||
|
||||
"READY" -> {
|
||||
val signatures = closure?.signatures.orEmpty()
|
||||
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
|
||||
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
||||
if (inspectorSigned) {
|
||||
Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||
SignaturePad(
|
||||
label = "Firmá como inspector/a",
|
||||
enabled = !model.busy,
|
||||
onCaptured = { file -> signWithGeo(file, company = false) },
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Recepción de la empresa", fontWeight = FontWeight.Bold)
|
||||
if (companyOutcome != null) {
|
||||
val detail = when (companyOutcome.status) {
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada"
|
||||
"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") }
|
||||
} else {
|
||||
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
|
||||
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "En disidencia") })
|
||||
}
|
||||
if (manifestation == "DISSENT") {
|
||||
OutlinedTextField(
|
||||
dissentStatement,
|
||||
{ dissentStatement = it },
|
||||
label = { Text("Manifestación de disidencia *") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
SignaturePad(
|
||||
label = "Firma del responsable de empresa",
|
||||
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||
onCaptured = { file -> signWithGeo(file, company = true) },
|
||||
)
|
||||
Text("Si la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(
|
||||
refusalReason,
|
||||
{ refusalReason = it },
|
||||
label = { Text("Motivo de negativa") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
||||
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Registrar negativa a firmar") }
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
if (inspectorSigned && companyOutcome != null) {
|
||||
Button(
|
||||
onClick = { model.closeSelectedAct() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar Acta definitivamente") }
|
||||
} else if (inspectorSigned) {
|
||||
Text(
|
||||
"La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"CLOSED" -> {
|
||||
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.")
|
||||
closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"CANCELLED" -> Text("Esta Acta fue cancelada y permanece sólo como antecedente.")
|
||||
}
|
||||
}
|
||||
|
||||
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
val 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.",
|
||||
)
|
||||
Button(
|
||||
onClick = { model.closeInspection() },
|
||||
enabled = !model.busy && drafts == 0,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar inspección y salir de la empresa") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F32ActMessage(model: MainViewModel) {
|
||||
val text = model.error ?: model.notice ?: return
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (model.error != null) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
onClick = { model.clearMessages() },
|
||||
) {
|
||||
Text(text, Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun actStatusLabel(status: String): String = when (status) {
|
||||
"DRAFT" -> "Borrador"
|
||||
"READY" -> "Preparada para firmas"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> status
|
||||
}
|
||||
|
||||
private fun hasActLocation(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo = suspendCancellableCoroutine { continuation ->
|
||||
if (!hasActLocation(context)) {
|
||||
continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
|
||||
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas as AndroidCanvas
|
||||
import android.graphics.Color as AndroidColor
|
||||
import android.graphics.Paint
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@Composable
|
||||
fun SignaturePad(
|
||||
label: String,
|
||||
enabled: Boolean = true,
|
||||
onCaptured: (File) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val strokes = remember { mutableStateListOf<List<Offset>>() }
|
||||
var currentStroke by remember { mutableStateOf<List<Offset>>(emptyList()) }
|
||||
var canvasSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
|
||||
Text(
|
||||
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(190.dp)
|
||||
.background(Color.White)
|
||||
.onSizeChanged { canvasSize = it }
|
||||
.pointerInput(enabled) {
|
||||
if (!enabled) return@pointerInput
|
||||
detectDragGestures(
|
||||
onDragStart = { position -> currentStroke = listOf(position) },
|
||||
onDrag = { change, _ ->
|
||||
change.consume()
|
||||
currentStroke = currentStroke + change.position
|
||||
},
|
||||
onDragEnd = {
|
||||
if (currentStroke.size > 1) strokes.add(currentStroke)
|
||||
currentStroke = emptyList()
|
||||
},
|
||||
onDragCancel = { currentStroke = emptyList() },
|
||||
)
|
||||
},
|
||||
) {
|
||||
val all = strokes + listOf(currentStroke)
|
||||
all.forEach { stroke ->
|
||||
stroke.zipWithNext().forEach { (start, end) ->
|
||||
drawLine(
|
||||
color = Color.Black,
|
||||
start = start,
|
||||
end = end,
|
||||
strokeWidth = 4.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { strokes.clear(); currentStroke = emptyList() },
|
||||
enabled = enabled && (strokes.isNotEmpty() || currentStroke.isNotEmpty()),
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Limpiar") }
|
||||
Button(
|
||||
onClick = {
|
||||
val width = canvasSize.width.coerceAtLeast(1)
|
||||
val height = canvasSize.height.coerceAtLeast(1)
|
||||
val targetWidth = 1000
|
||||
val targetHeight = 400
|
||||
val scaleX = targetWidth.toFloat() / width.toFloat()
|
||||
val scaleY = targetHeight.toFloat() / height.toFloat()
|
||||
val bitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888)
|
||||
val native = AndroidCanvas(bitmap)
|
||||
native.drawColor(AndroidColor.WHITE)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = AndroidColor.BLACK
|
||||
strokeWidth = 7f
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
style = Paint.Style.STROKE
|
||||
}
|
||||
strokes.forEach { stroke ->
|
||||
stroke.zipWithNext().forEach { (start, end) ->
|
||||
native.drawLine(
|
||||
start.x * scaleX,
|
||||
start.y * scaleY,
|
||||
end.x * scaleX,
|
||||
end.y * scaleY,
|
||||
paint,
|
||||
)
|
||||
}
|
||||
}
|
||||
val file = File.createTempFile("DH_FIRMA_", ".png", context.cacheDir)
|
||||
FileOutputStream(file).use { stream ->
|
||||
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream))
|
||||
}
|
||||
bitmap.recycle()
|
||||
onCaptured(file)
|
||||
},
|
||||
enabled = enabled && strokes.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Usar firma") }
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.24.0-1",
|
||||
"version": "0.25.0-1",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
|
||||
+13
-24
@@ -3,28 +3,27 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { ActAdministrationModule } from './act-administration/act-administration.module';
|
||||
import { AdministrationModule } from './administration/administration.module';
|
||||
import { AuthorizationModule } from './authorization/authorization.module';
|
||||
import { PermissionsGuard } from './authorization/guards/permissions.guard';
|
||||
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 { AuthorizationModule } from './authorization/authorization.module';
|
||||
import { PermissionsGuard } from './authorization/guards/permissions.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 { SurveyPlanningModule } from './survey-planning/survey-planning.module';
|
||||
import { SurveyExecutionModule } from './survey-execution/survey-execution.module';
|
||||
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
|
||||
import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
|
||||
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
|
||||
import { InspectionClosingModule } from './inspection-closing/inspection-closing.module';
|
||||
import { AssetImportsModule } from './asset-imports/asset-imports.module';
|
||||
import { InspectionDeadlinesModule } from './inspection-deadlines/inspection-deadlines.module';
|
||||
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
|
||||
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
|
||||
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
|
||||
import { ActAdministrationModule } from './act-administration/act-administration.module';
|
||||
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
|
||||
|
||||
function required(config: ConfigService, key: string): string {
|
||||
const value = config.get<string>(key);
|
||||
@@ -34,10 +33,7 @@ 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) => ({
|
||||
@@ -55,13 +51,7 @@ 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,
|
||||
@@ -69,12 +59,11 @@ function required(config: ConfigService, key: string): string {
|
||||
AdministrationModule,
|
||||
DashboardModule,
|
||||
AssetMasterModule,
|
||||
SurveyPlanningModule,
|
||||
SurveyExecutionModule,
|
||||
InspectionVisitsModule,
|
||||
InspectionActsModule,
|
||||
InspectionFindingsModule,
|
||||
InspectionClosingModule,
|
||||
InspectionDeadlinesModule,
|
||||
InspectionReportsModule,
|
||||
InspectionVerificationsModule,
|
||||
ActAdministrationModule,
|
||||
|
||||
@@ -6,14 +6,8 @@ export { Role } from './role.entity';
|
||||
export { UserRole } from './user-role.entity';
|
||||
export { User, UserStatus } from './user.entity';
|
||||
export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity';
|
||||
export {
|
||||
AssetVersion,
|
||||
AssetVersionChangeType,
|
||||
} from './asset-version.entity';
|
||||
export {
|
||||
AssetAttributeDataType,
|
||||
AssetAttributeDefinition,
|
||||
} from './asset-attribute-definition.entity';
|
||||
export { AssetVersion, AssetVersionChangeType } from './asset-version.entity';
|
||||
export { AssetAttributeDataType, AssetAttributeDefinition } from './asset-attribute-definition.entity';
|
||||
export { AssetAttributeValue } from './asset-attribute-value.entity';
|
||||
export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity';
|
||||
export { OrganizationProfile, OrganizationKind } from './organization-profile.entity';
|
||||
@@ -25,111 +19,35 @@ export { AreaLegalRight, AreaLegalRightType, AreaLegalRightStatus } from './area
|
||||
export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity';
|
||||
export { AssetTypeParentRule } from './asset-type-parent-rule.entity';
|
||||
export { AssetType, AssetTypeOperationalRole } from './asset-type.entity';
|
||||
export {
|
||||
AssetGeometry,
|
||||
AssetGeometrySource,
|
||||
AssetGeometryType,
|
||||
} from './asset-geometry.entity';
|
||||
export {
|
||||
AssetMedia,
|
||||
AssetMediaKind,
|
||||
AssetMediaSource,
|
||||
} from './asset-media.entity';
|
||||
export {
|
||||
SurveyCampaign,
|
||||
SurveyCampaignStatus,
|
||||
} from './survey-campaign.entity';
|
||||
export {
|
||||
SurveyCampaignTarget,
|
||||
SurveyTargetStatus,
|
||||
} from './survey-campaign-target.entity';
|
||||
export {
|
||||
SurveyReportOutcome,
|
||||
SurveyReportStatus,
|
||||
SurveyTargetReport,
|
||||
} from './survey-target-report.entity';
|
||||
export { SurveyTargetReportMedia } from './survey-target-report-media.entity';
|
||||
export {
|
||||
SurveyReportVersionEvent,
|
||||
SurveyTargetReportVersion,
|
||||
} from './survey-target-report-version.entity';
|
||||
export {
|
||||
InspectionVisit,
|
||||
InspectionVisitStatus,
|
||||
} from './inspection-visit.entity';
|
||||
export { AssetGeometry, AssetGeometrySource, AssetGeometryType } from './asset-geometry.entity';
|
||||
export { AssetMedia, AssetMediaKind, AssetMediaSource } from './asset-media.entity';
|
||||
export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entity';
|
||||
export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
|
||||
export { InspectionVisitMember } from './inspection-visit-member.entity';
|
||||
export {
|
||||
DocumentAnnualSequence,
|
||||
DocumentSequenceType,
|
||||
} from './document-annual-sequence.entity';
|
||||
export {
|
||||
InspectionAct,
|
||||
InspectionActStatus,
|
||||
} from './inspection-act.entity';
|
||||
export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity';
|
||||
export { InspectionAct, InspectionActStatus, InspectionActUrgency, InspectionDeadlineBasis, InspectionDeadlineDayType } from './inspection-act.entity';
|
||||
export { InspectionActAsset } from './inspection-act-asset.entity';
|
||||
export {
|
||||
InspectionReport,
|
||||
InspectionReportPdfStatus,
|
||||
InspectionReportReviewStatus,
|
||||
InspectionReportStatus,
|
||||
InspectionReportWordStatus,
|
||||
} from './inspection-report.entity';
|
||||
export {
|
||||
InspectionActVersion,
|
||||
InspectionActVersionEvent,
|
||||
} from './inspection-act-version.entity';
|
||||
export {
|
||||
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 { 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 { InspectionActVersion, InspectionActVersionEvent } from './inspection-act-version.entity';
|
||||
export { InspectionActResponsible, InspectionResponsibleAttendanceStatus, InspectionResponsibleDocumentType } from './inspection-act-responsible.entity';
|
||||
export { InspectionActClosure, InspectionActUploadMode } from './inspection-act-closure.entity';
|
||||
export { InspectionActSignature, InspectionActSignatureSource, InspectionActSignatureStatus, InspectionActSignerType, InspectionCompanySignatureManifestation } from './inspection-act-signature.entity';
|
||||
export { FindingCategory } from './finding-category.entity';
|
||||
export { FindingCatalogItem } from './finding-catalog-item.entity';
|
||||
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
|
||||
export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
|
||||
export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
|
||||
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
|
||||
export {
|
||||
InspectionFinding,
|
||||
InspectionFindingResponseDueBasis,
|
||||
InspectionFindingStatus,
|
||||
} from './inspection-finding.entity';
|
||||
export {
|
||||
InspectionFindingVersion,
|
||||
InspectionFindingVersionEvent,
|
||||
} from './inspection-finding-version.entity';
|
||||
export {
|
||||
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';
|
||||
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 { InspectionFindingVerificationVisit, InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity';
|
||||
export { InspectionFindingVerificationEvent, InspectionFindingVerificationEventType } from './inspection-finding-verification-event.entity';
|
||||
|
||||
import { AuditEvent } from './audit-event.entity';
|
||||
import { AuthSession } from './auth-session.entity';
|
||||
@@ -154,18 +72,17 @@ import { AreaLegalRightOrganization } from './area-legal-right-organization.enti
|
||||
import { AssetGeometry } from './asset-geometry.entity';
|
||||
import { AssetVersion } from './asset-version.entity';
|
||||
import { AssetMedia } from './asset-media.entity';
|
||||
import { SurveyCampaign } from './survey-campaign.entity';
|
||||
import { SurveyCampaignTarget } from './survey-campaign-target.entity';
|
||||
import { SurveyTargetReport } from './survey-target-report.entity';
|
||||
import { SurveyTargetReportMedia } from './survey-target-report-media.entity';
|
||||
import { SurveyTargetReportVersion } from './survey-target-report-version.entity';
|
||||
import { InspectionVisit } from './inspection-visit.entity';
|
||||
import { InspectionVisitAsset } from './inspection-visit-asset.entity';
|
||||
import { InspectionVisitMember } from './inspection-visit-member.entity';
|
||||
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';
|
||||
@@ -184,55 +101,17 @@ 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,
|
||||
SurveyCampaign,
|
||||
SurveyCampaignTarget,
|
||||
SurveyTargetReport,
|
||||
SurveyTargetReportMedia,
|
||||
SurveyTargetReportVersion,
|
||||
InspectionVisit,
|
||||
InspectionVisitAsset,
|
||||
InspectionVisitMember,
|
||||
DocumentAnnualSequence,
|
||||
InspectionAct,
|
||||
InspectionActAsset,
|
||||
InspectionReport,
|
||||
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,
|
||||
InspectionDeadlinePolicy, InspectionNonWorkingDay, InspectionReport,
|
||||
InspectionReportFollowUp, InspectionReportFollowUpFile, InspectionActVersion,
|
||||
InspectionActResponsible, InspectionActClosure, InspectionActSignature, FindingCategory,
|
||||
FindingCatalogItem, FindingCatalogItemAssetType, FindingCatalogAssetTypeProfile,
|
||||
FindingCatalogAssetOverride, FindingCatalogProposal, InspectionFinding,
|
||||
InspectionFindingVersion, InspectionFindingCommunication, InspectionFindingEvidence,
|
||||
InspectionFindingVerificationVisit, InspectionFindingVerificationEvent,
|
||||
];
|
||||
|
||||
@@ -9,6 +9,21 @@ export enum InspectionActStatus {
|
||||
RECTIFIED = 'RECTIFIED',
|
||||
}
|
||||
|
||||
export enum InspectionActUrgency {
|
||||
URGENT = 'URGENT',
|
||||
NON_URGENT = 'NON_URGENT',
|
||||
}
|
||||
|
||||
export enum InspectionDeadlineDayType {
|
||||
BUSINESS = 'BUSINESS',
|
||||
CALENDAR = 'CALENDAR',
|
||||
}
|
||||
|
||||
export enum InspectionDeadlineBasis {
|
||||
ACT_DATE = 'ACT_DATE',
|
||||
GEDO_LOAD_DATE = 'GEDO_LOAD_DATE',
|
||||
}
|
||||
|
||||
@Entity({ name: 'inspection_acts' })
|
||||
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
|
||||
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
|
||||
@@ -47,6 +62,42 @@ 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: 'deadline_days', type: 'integer', nullable: true })
|
||||
deadlineDays!: number | null;
|
||||
|
||||
@Column({ name: 'deadline_day_type', type: 'varchar', length: 20, 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_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: 'locked_at', type: 'timestamptz', nullable: true })
|
||||
lockedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'locked_by', type: 'uuid', nullable: true })
|
||||
lockedBy!: string | null;
|
||||
|
||||
@Column({ name: 'locked_sha256', type: 'char', length: 64, nullable: true })
|
||||
lockedSha256!: string | null;
|
||||
|
||||
@Column({ name: 'sealed_at', type: 'timestamptz', nullable: true })
|
||||
sealedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'sealed_by', type: 'uuid', nullable: true })
|
||||
sealedBy!: string | null;
|
||||
|
||||
@Column({ name: 'current_version', type: 'integer', default: 0 })
|
||||
currentVersion!: number;
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
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;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
days!: number;
|
||||
|
||||
@Column({ name: 'day_type', type: 'varchar', length: 20 })
|
||||
dayType!: InspectionDeadlineDayType;
|
||||
|
||||
@Column({ type: 'varchar', length: 24 })
|
||||
basis!: InspectionDeadlineBasis;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy!: string | null;
|
||||
}
|
||||
@@ -18,6 +18,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'])
|
||||
export class InspectionFinding extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -61,6 +62,12 @@ export class InspectionFinding extends TimestampedEntity {
|
||||
@Column({ type: 'smallint', nullable: true })
|
||||
severity!: number | null;
|
||||
|
||||
@Column({ name: 'is_recurrence', type: 'boolean', default: false })
|
||||
isRecurrence!: boolean;
|
||||
|
||||
@Column({ name: 'antecedent_finding_id', type: 'uuid', nullable: true })
|
||||
antecedentFindingId!: string | null;
|
||||
|
||||
@Column({ name: 'correction_due_on', type: 'date', nullable: true })
|
||||
correctionDueOn!: string | null;
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
export enum InspectionReportFollowUpType {
|
||||
COMPANY_NOTE = 'COMPANY_NOTE',
|
||||
COMPANY_DOCUMENT = 'COMPANY_DOCUMENT',
|
||||
INTERNAL_NOTE = 'INTERNAL_NOTE',
|
||||
VERIFICATION = 'VERIFICATION',
|
||||
OTHER = 'OTHER',
|
||||
}
|
||||
|
||||
@Entity({ name: 'inspection_report_follow_ups' })
|
||||
@Index('idx_inspection_report_follow_ups_report_occurred', ['reportId', 'occurredOn'])
|
||||
@Index('idx_inspection_report_follow_ups_type', ['eventType'])
|
||||
export class InspectionReportFollowUp extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'report_id', type: 'uuid' })
|
||||
reportId!: string;
|
||||
|
||||
@Column({ name: 'event_type', type: 'varchar', length: 32 })
|
||||
eventType!: InspectionReportFollowUpType;
|
||||
|
||||
@Column({ name: 'reference_number', type: 'varchar', length: 255, nullable: true })
|
||||
referenceNumber!: string | null;
|
||||
|
||||
@Column({ name: 'occurred_on', type: 'date' })
|
||||
occurredOn!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy!: string | null;
|
||||
}
|
||||
@@ -31,6 +31,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'])
|
||||
export class InspectionReport extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -80,6 +81,57 @@ 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;
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
export enum SurveyTargetStatus {
|
||||
PENDING = 'PENDING',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
SUBMITTED = 'SUBMITTED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
SKIPPED = 'SKIPPED',
|
||||
}
|
||||
|
||||
@Entity({ name: 'survey_campaign_targets' })
|
||||
@Index('uq_survey_campaign_targets_campaign_asset', ['campaignId', 'assetId'], { unique: true })
|
||||
@Index('idx_survey_campaign_targets_campaign_status', ['campaignId', 'status'])
|
||||
@Index('idx_survey_campaign_targets_asset_id', ['assetId'])
|
||||
@Index('idx_survey_campaign_targets_assigned_user_id', ['assignedUserId'])
|
||||
export class SurveyCampaignTarget extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'campaign_id', type: 'uuid' })
|
||||
campaignId!: string;
|
||||
|
||||
@Column({ name: 'asset_id', type: 'uuid' })
|
||||
assetId!: string;
|
||||
|
||||
@Column({ name: 'assigned_user_id', type: 'uuid', nullable: true })
|
||||
assignedUserId!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 24, default: SurveyTargetStatus.PENDING })
|
||||
status!: SurveyTargetStatus;
|
||||
|
||||
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
|
||||
dueAt!: Date | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
instructions!: string | null;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy!: string | null;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy!: string | null;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
export enum SurveyCampaignStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
PLANNED = 'PLANNED',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
COMPLETED = 'COMPLETED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
|
||||
@Entity({ name: 'survey_campaigns' })
|
||||
@Index('uq_survey_campaigns_code', ['code'], { unique: true })
|
||||
@Index('idx_survey_campaigns_status', ['status'])
|
||||
@Index('idx_survey_campaigns_scope_asset_id', ['scopeAssetId'])
|
||||
@Index('idx_survey_campaigns_coordinator_user_id', ['coordinatorUserId'])
|
||||
@Index('idx_survey_campaigns_planned_dates', ['plannedStartAt', 'plannedEndAt'])
|
||||
export class SurveyCampaign extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
code!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 24, default: SurveyCampaignStatus.DRAFT })
|
||||
status!: SurveyCampaignStatus;
|
||||
|
||||
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
|
||||
plannedStartAt!: Date | null;
|
||||
|
||||
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
|
||||
plannedEndAt!: Date | null;
|
||||
|
||||
@Column({ name: 'scope_asset_id', type: 'uuid', nullable: true })
|
||||
scopeAssetId!: string | null;
|
||||
|
||||
@Column({ name: 'coordinator_user_id', type: 'uuid', nullable: true })
|
||||
coordinatorUserId!: string | null;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy!: string | null;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy!: string | null;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
import { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
@Entity({ name: 'survey_target_report_media' })
|
||||
@Index('idx_survey_target_report_media_media_id', ['mediaId'])
|
||||
@Index('idx_survey_target_report_media_included', ['reportId', 'included'])
|
||||
export class SurveyTargetReportMedia extends TimestampedEntity {
|
||||
@PrimaryColumn({ name: 'report_id', type: 'uuid' })
|
||||
reportId!: string;
|
||||
|
||||
@PrimaryColumn({ name: 'media_id', type: 'uuid' })
|
||||
mediaId!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
included!: boolean;
|
||||
|
||||
@Column({ name: 'added_by', type: 'uuid', nullable: true })
|
||||
addedBy!: string | null;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
export enum SurveyReportVersionEvent {
|
||||
SUBMITTED = 'SUBMITTED',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED',
|
||||
}
|
||||
|
||||
@Entity({ name: 'survey_target_report_versions' })
|
||||
@Index('uq_survey_target_report_versions_number', ['reportId', 'versionNumber'], { unique: true })
|
||||
@Index('idx_survey_target_report_versions_created_at', ['createdAt'])
|
||||
export class SurveyTargetReportVersion {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'report_id', type: 'uuid' })
|
||||
reportId!: string;
|
||||
|
||||
@Column({ name: 'version_number', type: 'integer' })
|
||||
versionNumber!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 24 })
|
||||
event!: SurveyReportVersionEvent;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
snapshot!: Record<string, unknown>;
|
||||
|
||||
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
|
||||
actorUserId!: string | null;
|
||||
|
||||
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
|
||||
actorUsername!: string | null;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
export enum SurveyReportOutcome {
|
||||
CONFIRMED = 'CONFIRMED',
|
||||
CHANGES_RECORDED = 'CHANGES_RECORDED',
|
||||
NOT_LOCATED = 'NOT_LOCATED',
|
||||
}
|
||||
|
||||
export enum SurveyReportStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
SUBMITTED = 'SUBMITTED',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED',
|
||||
}
|
||||
|
||||
@Entity({ name: 'survey_target_reports' })
|
||||
@Index('uq_survey_target_reports_target_id', ['targetId'], { unique: true })
|
||||
@Index('idx_survey_target_reports_status', ['status'])
|
||||
@Index('idx_survey_target_reports_submitted_by', ['submittedBy'])
|
||||
@Index('idx_survey_target_reports_reviewed_by', ['reviewedBy'])
|
||||
export class SurveyTargetReport extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'target_id', type: 'uuid' })
|
||||
targetId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||
outcome!: SurveyReportOutcome | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 24, default: SurveyReportStatus.DRAFT })
|
||||
status!: SurveyReportStatus;
|
||||
|
||||
@Column({ name: 'observed_at', type: 'timestamptz', nullable: true })
|
||||
observedAt!: Date | null;
|
||||
|
||||
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
|
||||
latitude!: string | null;
|
||||
|
||||
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
|
||||
longitude!: string | null;
|
||||
|
||||
@Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
accuracyM!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes!: string | null;
|
||||
|
||||
@Column({ name: 'asset_version_at_submission', type: 'integer', nullable: true })
|
||||
assetVersionAtSubmission!: number | null;
|
||||
|
||||
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
|
||||
submittedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'submitted_by', type: 'uuid', nullable: true })
|
||||
submittedBy!: string | null;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
||||
reviewedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
|
||||
reviewedBy!: string | null;
|
||||
|
||||
@Column({ name: 'review_notes', type: 'text', nullable: true })
|
||||
reviewNotes!: string | null;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy!: string | null;
|
||||
|
||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||
updatedBy!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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'
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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()`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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()`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { InspectionActUrgency } from '../../database/entities';
|
||||
|
||||
export class LockInspectionActDto {
|
||||
@IsEnum(InspectionActUrgency)
|
||||
urgency!: InspectionActUrgency;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
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,26 +48,6 @@ export class InspectionClosingController {
|
||||
return this.closing.upsertResponsible(actId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('ready')
|
||||
@RequirePermissions('inspection_closure.prepare')
|
||||
ready(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.prepare(actId, principal, request);
|
||||
}
|
||||
|
||||
@Post('reopen')
|
||||
@RequirePermissions('inspection_closure.prepare')
|
||||
reopen(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.reopen(actId, principal, request);
|
||||
}
|
||||
|
||||
@Post('signatures/inspector')
|
||||
@RequirePermissions('inspection_closure.sign')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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 {
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
@@ -8,8 +11,12 @@ import {
|
||||
import { InspectionClosingService } from './inspection-closing.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, InspectionReportsModule],
|
||||
controllers: [InspectionClosingController, InspectionSignatureContentController],
|
||||
providers: [InspectionClosingService],
|
||||
imports: [AuditModule, InspectionDeadlinesModule, InspectionReportsModule],
|
||||
controllers: [
|
||||
InspectionActLifecycleController,
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
],
|
||||
providers: [InspectionActLifecycleService, InspectionClosingService],
|
||||
})
|
||||
export class InspectionClosingModule {}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,302 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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,31 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c
|
||||
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 { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto';
|
||||
import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
|
||||
import { InspectionFindingsService } from './inspection-findings.service';
|
||||
|
||||
@@ -84,15 +83,4 @@ export class InspectionFindingsController {
|
||||
) {
|
||||
return this.findings.close(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Patch(':id/follow-up')
|
||||
@RequirePermissions('inspection_findings.follow_up')
|
||||
updateFollowUp(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: UpdateInspectionFindingFollowUpDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.findings.updateFollowUp(id, dto, principal, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 {
|
||||
InspectionActFindingsController,
|
||||
InspectionFindingsController,
|
||||
@@ -21,6 +23,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
controllers: [
|
||||
FindingCatalogController,
|
||||
InspectionActFindingsController,
|
||||
InspectionFindingRecurrenceController,
|
||||
InspectionFindingsController,
|
||||
InspectionFindingEvidenceController,
|
||||
InspectionFindingCommunicationsController,
|
||||
@@ -31,6 +34,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
F3FindingCatalogResolverService,
|
||||
FindingCatalogMergeService,
|
||||
InspectionFindingsService,
|
||||
InspectionFindingRecurrenceService,
|
||||
InspectionEvidenceService,
|
||||
],
|
||||
exports: [
|
||||
@@ -38,6 +42,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
F3FindingCatalogResolverService,
|
||||
FindingCatalogMergeService,
|
||||
InspectionFindingsService,
|
||||
InspectionFindingRecurrenceService,
|
||||
],
|
||||
})
|
||||
export class InspectionFindingsModule {}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEnum, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { InspectionReportFollowUpType } from '../../database/entities';
|
||||
|
||||
export class CreateInspectionReportFollowUpDto {
|
||||
@IsEnum(InspectionReportFollowUpType)
|
||||
eventType!: InspectionReportFollowUpType;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
referenceNumber?: string | null;
|
||||
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
occurredOn!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
description?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,2 +1,12 @@
|
||||
import { Transform } from 'class-transformer'; import { IsEmail,IsOptional,MaxLength } from 'class-validator';
|
||||
export class UpdateDocumentDeliverySettingsDto { @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) officeEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) directorEmail?:string|null; }
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsOptional, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateDocumentDeliverySettingsDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
|
||||
)
|
||||
@IsEmail()
|
||||
@MaxLength(320)
|
||||
officeEmail?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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,79 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export enum SmtpSecurityModeDto {
|
||||
TLS = 'TLS',
|
||||
STARTTLS = 'STARTTLS',
|
||||
}
|
||||
|
||||
export class UpdateSmtpSettingsDto {
|
||||
@Transform(({ value }) => value === true || value === 'true')
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
host!: string;
|
||||
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
port!: number;
|
||||
|
||||
@IsEnum(SmtpSecurityModeDto)
|
||||
securityMode!: SmtpSecurityModeDto;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
username?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === true || value === 'true')
|
||||
@IsBoolean()
|
||||
clearPassword?: boolean;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(160)
|
||||
fromName!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
|
||||
@IsEmail()
|
||||
@MaxLength(255)
|
||||
fromEmail!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null))
|
||||
@IsEmail()
|
||||
@MaxLength(255)
|
||||
replyTo?: string | null;
|
||||
}
|
||||
|
||||
export class TestSmtpSettingsDto {
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
|
||||
@IsEmail()
|
||||
@MaxLength(255)
|
||||
to!: string;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -10,7 +9,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' | 'DIRECTOR' | 'INSPECTOR';
|
||||
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR' | 'DIRECTOR';
|
||||
|
||||
export interface DeliveryRow {
|
||||
id: string;
|
||||
@@ -35,26 +34,18 @@ 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",
|
||||
director_email AS "directorEmail",
|
||||
updated_at AS "updatedAt"
|
||||
SELECT office_email AS "officeEmail", updated_at AS "updatedAt"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id=1
|
||||
`) as Array<{
|
||||
officeEmail: string | null;
|
||||
directorEmail: string | null;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
WHERE id = 1
|
||||
`) as Array<{ officeEmail: string | null; updatedAt: Date }>;
|
||||
return {
|
||||
...row,
|
||||
smtpConfigured: this.smtp.configured(),
|
||||
mailFrom: this.config.get<string>('MAIL_FROM') ?? null,
|
||||
smtpSource: this.smtp.activeSource(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,9 +57,11 @@ export class InspectionDocumentDeliveryService {
|
||||
const before = await this.settings();
|
||||
await this.dataSource.query(`
|
||||
UPDATE institutional_delivery_settings
|
||||
SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=1
|
||||
`, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]);
|
||||
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({
|
||||
...administrationAuditContext(principal, request),
|
||||
@@ -84,34 +77,34 @@ export class InspectionDocumentDeliveryService {
|
||||
async list() {
|
||||
const data = await this.dataSource.query(`
|
||||
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,
|
||||
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",
|
||||
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",
|
||||
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))
|
||||
END AS "recipientUserName"
|
||||
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
|
||||
ORDER BY d.created_at DESC
|
||||
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
|
||||
LIMIT 200
|
||||
`);
|
||||
return { data };
|
||||
@@ -120,13 +113,14 @@ 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);
|
||||
const rows = await this.rowsForAct(actId);
|
||||
for (const row of rows) await this.attempt(row).catch(() => undefined);
|
||||
for (const row of await this.rowsForAct(actId)) {
|
||||
await this.attempt(row).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async retry(
|
||||
@@ -156,42 +150,42 @@ export class InspectionDocumentDeliveryService {
|
||||
const rows = await this.dataSource.query(`
|
||||
SELECT id
|
||||
FROM inspection_document_deliveries
|
||||
WHERE status<>'SENT'
|
||||
WHERE status <> 'SENT'
|
||||
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",director_email AS "directorEmail"
|
||||
SELECT office_email AS "officeEmail"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id=1
|
||||
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
||||
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) {
|
||||
@@ -229,19 +223,18 @@ export class InspectionDocumentDeliveryService {
|
||||
recipientKey: inspector.id,
|
||||
recipientEmail: inspector.email,
|
||||
});
|
||||
}
|
||||
|
||||
if (reportId) {
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'REPORT_WORD',
|
||||
recipientKind: 'DIRECTOR',
|
||||
recipientAssetId: null,
|
||||
recipientUserId: null,
|
||||
recipientKey: '00000000-0000-0000-0000-000000000000',
|
||||
recipientEmail: settings?.directorEmail ?? null,
|
||||
});
|
||||
if (reportId) {
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'REPORT_WORD',
|
||||
recipientKind: 'INSPECTOR',
|
||||
recipientAssetId: null,
|
||||
recipientUserId: inspector.id,
|
||||
recipientKey: inspector.id,
|
||||
recipientEmail: inspector.email,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +242,7 @@ export class InspectionDocumentDeliveryService {
|
||||
actId: string;
|
||||
reportId: string | null;
|
||||
documentKind: 'ACT_PDF' | 'REPORT_WORD';
|
||||
recipientKind: DeliveryRecipientKind;
|
||||
recipientKind: Exclude<DeliveryRecipientKind, 'DIRECTOR'>;
|
||||
recipientAssetId: string | null;
|
||||
recipientUserId: string | null;
|
||||
recipientKey: string;
|
||||
@@ -258,23 +251,23 @@ 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
|
||||
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'
|
||||
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
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, [
|
||||
input.actId,
|
||||
input.reportId,
|
||||
@@ -291,31 +284,45 @@ export class InspectionDocumentDeliveryService {
|
||||
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
|
||||
return this.dataSource.query(`
|
||||
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
|
||||
ORDER BY d.created_at
|
||||
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
|
||||
`, [actId]) as Promise<DeliveryRow[]>;
|
||||
}
|
||||
|
||||
private async load(id: string): Promise<DeliveryRow> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
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
|
||||
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
|
||||
`, [id]) as DeliveryRow[];
|
||||
if (!row) {
|
||||
throw new NotFoundException({
|
||||
@@ -332,34 +339,41 @@ export class InspectionDocumentDeliveryService {
|
||||
const [company] = await this.dataSource.query(`
|
||||
SELECT notification_email AS email
|
||||
FROM organization_profiles
|
||||
WHERE asset_id=$1
|
||||
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
|
||||
WHERE id = $1 AND is_active = true
|
||||
`, [row.recipientUserId]) as Array<{ email: string | null }>;
|
||||
email = inspector?.email ?? null;
|
||||
} else {
|
||||
} else if (row.recipientKind === 'OFFICE') {
|
||||
const [settings] = await this.dataSource.query(`
|
||||
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
|
||||
SELECT office_email AS "officeEmail"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id=1
|
||||
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
||||
email = row.recipientKind === 'OFFICE'
|
||||
? settings?.officeEmail ?? null
|
||||
: settings?.directorEmail ?? null;
|
||||
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'
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -406,9 +420,12 @@ export class InspectionDocumentDeliveryService {
|
||||
|
||||
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
|
||||
SET attempts = attempts + 1,
|
||||
last_attempt_at = CURRENT_TIMESTAMP,
|
||||
status = 'PENDING',
|
||||
last_error = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [row.id]);
|
||||
|
||||
try {
|
||||
@@ -416,10 +433,10 @@ export class InspectionDocumentDeliveryService {
|
||||
? `Acta ${row.actCode}`
|
||||
: `Informe ${row.reportCode ?? ''}`;
|
||||
const text = row.documentKind === 'REPORT_WORD'
|
||||
? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.`
|
||||
? `Se adjunta el Informe Word automático ${row.reportCode ?? ''} para revisión y edición del inspector responsable antes de su carga en GEDO.`
|
||||
: row.recipientKind === 'INSPECTOR'
|
||||
? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.`
|
||||
: `Se adjunta el acta cerrada e inmutable ${row.actCode}.`;
|
||||
? `Se adjunta copia del Acta ${row.actCode} correspondiente a tu inspección.`
|
||||
: `Se adjunta el Acta ${row.actCode}.`;
|
||||
const sent = await this.smtp.send({
|
||||
to: row.recipientEmail,
|
||||
subject: `DH Inspección · ${label}`,
|
||||
@@ -428,9 +445,12 @@ export class InspectionDocumentDeliveryService {
|
||||
});
|
||||
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
|
||||
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,
|
||||
@@ -463,8 +483,10 @@ export class InspectionDocumentDeliveryService {
|
||||
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
|
||||
SET status = $2,
|
||||
last_error = $3,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [id, status, error.slice(0, 500)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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' };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
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,31 +1,39 @@
|
||||
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 { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||
import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller';
|
||||
import { InspectionReportReviewService } from './inspection-report-review.service';
|
||||
import {
|
||||
InspectionReportDossierController,
|
||||
InspectionReportFollowUpFileController,
|
||||
} from './inspection-report-dossier.controller';
|
||||
import { InspectionReportDossierService } from './inspection-report-dossier.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],
|
||||
imports: [AuditModule, InspectionDeadlinesModule],
|
||||
controllers: [
|
||||
InspectionReportsController,
|
||||
InspectionActReportController,
|
||||
InspectionReportDossierController,
|
||||
InspectionReportFollowUpFileController,
|
||||
DocumentDeliveryController,
|
||||
InspectionReportReviewController,
|
||||
InspectionReportRevisionContentController,
|
||||
SmtpSettingsController,
|
||||
],
|
||||
providers: [
|
||||
InspectionReportsService,
|
||||
InspectionReportWordService,
|
||||
InspectionReportDossierService,
|
||||
InspectionActPdfService,
|
||||
InspectionDocumentDeliveryService,
|
||||
SmtpDeliveryService,
|
||||
InspectionReportReviewService,
|
||||
SmtpSettingsService,
|
||||
],
|
||||
exports: [InspectionReportsService],
|
||||
})
|
||||
|
||||
@@ -1,37 +1,328 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { connect as connectNet, Socket } from 'node:net';
|
||||
import { connect as connectTls, TLSSocket } from 'node:tls';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { decryptSmtpSecret } from './smtp-settings-crypto';
|
||||
|
||||
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 MailAttachment {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
content: Buffer;
|
||||
}
|
||||
|
||||
interface MailInput {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
attachment?: MailAttachment;
|
||||
}
|
||||
|
||||
interface Reply {
|
||||
code: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface RuntimeSmtpConfig {
|
||||
source: 'DATABASE' | 'ENV';
|
||||
host: string;
|
||||
port: number;
|
||||
securityMode: 'TLS' | 'STARTTLS';
|
||||
user: string;
|
||||
pass: string;
|
||||
fromHeader: string;
|
||||
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=[]; 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();}
|
||||
current(){return this.socket;}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].join('\r\n'); return body.replace(/^\./gm,'..'); }
|
||||
function 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, '..');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmtpDeliveryService {
|
||||
constructor(private readonly config:ConfigService){}
|
||||
configured(){return Boolean(this.config.get<string>('SMTP_HOST')&&this.config.get<string>('MAIL_FROM'));}
|
||||
async send(input:MailInput):Promise<{messageId:string}>{ const host=this.config.get<string>('SMTP_HOST'); const from=this.config.get<string>('MAIL_FROM'); if(!host||!from)throw new Error('SMTP no configurado'); const port=Number(this.config.get<string>('SMTP_PORT')??587); const secure=String(this.config.get<string>('SMTP_SECURE')??'false').toLowerCase()==='true'; const user=this.config.get<string>('SMTP_USER')??''; const pass=this.config.get<string>('SMTP_PASS')??''; const base=await new Promise<Socket|TLSSocket>((resolve,reject)=>{ if(secure){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); } else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); } }); const session=new SmtpSession(base); const welcome=await session.reply(); if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`); let ehlo=await session.command(`EHLO dh-inspeccion`,250); if(!secure){ if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS'); await session.command('STARTTLS',220); const rawForTls=session.detachForUpgrade() as Socket; const upgraded=await new Promise<TLSSocket>((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }); session.replaceSocket(upgraded); ehlo=await session.command('EHLO dh-inspeccion',250); }
|
||||
if(user){ if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${user}\u0000${pass}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); } else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(user).toString('base64'),334); await session.command(Buffer.from(pass).toString('base64'),235); } }
|
||||
await session.command(`MAIL FROM:<${envelope(from)}>`,250); await session.command(`RCPT TO:<${input.to}>`,[250,251]); await session.command('DATA',354); session.write(`${mime(input,from)}\r\n.\r\n`); const sent=await session.reply(); if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`); await session.command('QUIT',221).catch(()=>undefined); session.end(); const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i); return {messageId:match?.[1]??randomUUID()}; }
|
||||
export class SmtpDeliveryService implements OnModuleInit {
|
||||
private runtime: RuntimeSmtpConfig | null;
|
||||
private runtimeError: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.runtime = this.environmentConfig();
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.reload().catch((error) => {
|
||||
this.runtime = null;
|
||||
this.runtimeError = error instanceof Error ? error.message : 'SMTP configuration error';
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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,
|
||||
};
|
||||
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 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);
|
||||
});
|
||||
session.replaceSocket(upgraded);
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
session.end();
|
||||
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();
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Get, Put, 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 { TestSmtpSettingsDto, UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto';
|
||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
import { SmtpSettingsService } from './smtp-settings.service';
|
||||
|
||||
@Controller('system-mail-settings')
|
||||
export class SmtpSettingsController {
|
||||
constructor(
|
||||
private readonly settings: SmtpSettingsService,
|
||||
private readonly smtp: SmtpDeliveryService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('system_mail.manage')
|
||||
async get() {
|
||||
return {
|
||||
...(await this.settings.getPublic()),
|
||||
transportConfigured: this.smtp.configured(),
|
||||
activeSource: this.smtp.activeSource(),
|
||||
};
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequirePermissions('system_mail.manage')
|
||||
async update(
|
||||
@Body() dto: UpdateSmtpSettingsDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
const result = await this.settings.update(dto, principal, request);
|
||||
await this.smtp.reload();
|
||||
return {
|
||||
...result,
|
||||
transportConfigured: this.smtp.configured(),
|
||||
activeSource: this.smtp.activeSource(),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@RequirePermissions('system_mail.manage')
|
||||
async test(@Body() dto: TestSmtpSettingsDto) {
|
||||
const sent = await this.smtp.send({
|
||||
to: dto.to,
|
||||
subject: 'DH Inspección · Prueba SMTP',
|
||||
text: 'Este correo confirma que la salida SMTP configurada en Superadmin funciona correctamente.',
|
||||
});
|
||||
return { sent: true, messageId: sent.messageId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { ConflictException, Injectable } 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';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import type { UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto';
|
||||
import { encryptSmtpSecret } from './smtp-settings-crypto';
|
||||
|
||||
interface SmtpSettingsRow {
|
||||
enabled: boolean;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
securityMode: string | null;
|
||||
username: string | null;
|
||||
passwordEncrypted: string | null;
|
||||
fromName: string | null;
|
||||
fromEmail: string | null;
|
||||
replyTo: string | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmtpSettingsService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async getPublic() {
|
||||
const row = await this.load();
|
||||
return this.toPublic(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
dto: UpdateSmtpSettingsDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const encryptionKey = this.config.get<string>('SMTP_SETTINGS_ENCRYPTION_KEY');
|
||||
if (dto.password !== undefined && !encryptionKey) {
|
||||
throw new ConflictException({
|
||||
code: 'SMTP_SETTINGS_ENCRYPTION_KEY_MISSING',
|
||||
message: 'Falta configurar la clave maestra del servidor para proteger la contraseña SMTP',
|
||||
});
|
||||
}
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const [current] = (await manager.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",
|
||||
updated_at AS "updatedAt"
|
||||
FROM smtp_settings
|
||||
WHERE id = 1
|
||||
FOR UPDATE
|
||||
`)) as SmtpSettingsRow[];
|
||||
let passwordEncrypted = current?.passwordEncrypted ?? null;
|
||||
if (dto.clearPassword) passwordEncrypted = null;
|
||||
if (dto.password !== undefined) {
|
||||
passwordEncrypted = encryptSmtpSecret(dto.password, encryptionKey!);
|
||||
}
|
||||
await manager.query(`
|
||||
UPDATE smtp_settings
|
||||
SET enabled = $1,
|
||||
host = $2,
|
||||
port = $3,
|
||||
security_mode = $4,
|
||||
username = $5,
|
||||
password_encrypted = $6,
|
||||
from_name = $7,
|
||||
from_email = $8,
|
||||
reply_to = $9,
|
||||
updated_by = $10,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`, [
|
||||
dto.enabled,
|
||||
dto.host,
|
||||
dto.port,
|
||||
dto.securityMode,
|
||||
dto.username ?? null,
|
||||
passwordEncrypted,
|
||||
dto.fromName,
|
||||
dto.fromEmail,
|
||||
dto.replyTo ?? null,
|
||||
principal.userId,
|
||||
]);
|
||||
const [after] = (await manager.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",
|
||||
updated_at AS "updatedAt"
|
||||
FROM smtp_settings
|
||||
WHERE id = 1
|
||||
`)) as SmtpSettingsRow[];
|
||||
const beforePublic = current ? this.toPublic(current) : null;
|
||||
const afterPublic = this.toPublic(after);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: 'SMTP_SETTINGS_UPDATED',
|
||||
entityType: 'smtp_settings',
|
||||
entityId: '1',
|
||||
beforeData: beforePublic,
|
||||
afterData: afterPublic,
|
||||
metadata: { passwordChanged: dto.password !== undefined || Boolean(dto.clearPassword) },
|
||||
}, manager);
|
||||
return afterPublic;
|
||||
});
|
||||
}
|
||||
|
||||
private async load(): Promise<SmtpSettingsRow> {
|
||||
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",
|
||||
updated_at AS "updatedAt"
|
||||
FROM smtp_settings
|
||||
WHERE id = 1
|
||||
`)) as SmtpSettingsRow[];
|
||||
return row ?? {
|
||||
enabled: false,
|
||||
host: null,
|
||||
port: null,
|
||||
securityMode: null,
|
||||
username: null,
|
||||
passwordEncrypted: null,
|
||||
fromName: null,
|
||||
fromEmail: null,
|
||||
replyTo: null,
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
}
|
||||
|
||||
private toPublic(row: SmtpSettingsRow): Record<string, unknown> {
|
||||
return {
|
||||
enabled: row.enabled,
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
securityMode: row.securityMode,
|
||||
username: row.username,
|
||||
passwordConfigured: Boolean(row.passwordEncrypted),
|
||||
fromName: row.fromName,
|
||||
fromEmail: row.fromEmail,
|
||||
replyTo: row.replyTo,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,13 @@ const optionalText = ({ value }: { value: unknown }) =>
|
||||
/**
|
||||
* Hallazgo capturado desde la APK sobre un Inventario ya seleccionado.
|
||||
* El assetId se toma de la URL para evitar inconsistencias entre pantalla y payload.
|
||||
* `actId` queda opcional sólo para compatibilidad con Android 0.11.0; F3.2 siempre lo envía.
|
||||
*/
|
||||
export class CreateFieldFindingDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
actId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(optionalText)
|
||||
@IsUUID('4')
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Selección explícita del Acta activa desde la APK.
|
||||
* `actId` queda opcional sólo durante la transición desde Android 0.11.0;
|
||||
* F3.2 móvil siempre lo informa.
|
||||
*/
|
||||
export class FieldFindingActQueryDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
actId?: string;
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
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 { CreateFieldFindingDto } from './dto/create-field-finding.dto';
|
||||
import { FieldFindingActQueryDto } from './dto/field-finding-act-query.dto';
|
||||
import { FieldFindingsService } from './field-findings.service';
|
||||
|
||||
@Controller('inspection-visits/:visitId/field-findings')
|
||||
@@ -22,9 +24,10 @@ export class FieldFindingsController {
|
||||
options(
|
||||
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||
@Query() query: FieldFindingActQueryDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
): Promise<unknown> {
|
||||
return this.fieldFindings.options(visitId, assetId, principal);
|
||||
return this.fieldFindings.options(visitId, assetId, query.actId, principal);
|
||||
}
|
||||
|
||||
@Get(':assetId')
|
||||
@@ -32,9 +35,10 @@ export class FieldFindingsController {
|
||||
list(
|
||||
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||
@Query() query: FieldFindingActQueryDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
): Promise<unknown> {
|
||||
return this.fieldFindings.list(visitId, assetId, principal);
|
||||
return this.fieldFindings.list(visitId, assetId, query.actId, principal);
|
||||
}
|
||||
|
||||
@Post(':assetId')
|
||||
|
||||
@@ -36,9 +36,15 @@ export class FieldFindingsService {
|
||||
private readonly findings: InspectionFindingsService,
|
||||
) {}
|
||||
|
||||
async options(visitId: string, assetId: string, principal: AuthPrincipal) {
|
||||
async options(
|
||||
visitId: string,
|
||||
assetId: string,
|
||||
actId: string | undefined,
|
||||
principal: AuthPrincipal,
|
||||
) {
|
||||
const gate = await this.requireGate(visitId, assetId, principal);
|
||||
const act = await this.requireDraftAct(visitId);
|
||||
const act = await this.requireDraftAct(visitId, actId);
|
||||
const assetIncludedInAct = await this.actContainsAsset(act.id, assetId);
|
||||
const [catalog, findings] = await Promise.all([
|
||||
this.catalog.listApplicableForAsset(assetId, {}),
|
||||
this.findings.listForAct(act.id),
|
||||
@@ -48,21 +54,30 @@ export class FieldFindingsService {
|
||||
context: gate.context,
|
||||
act,
|
||||
capture: gate.capture,
|
||||
assetIncludedInAct,
|
||||
catalog,
|
||||
findings: findings.data.filter((finding) => finding.assetId === assetId),
|
||||
canAddAnother: true,
|
||||
canAddAnother: assetIncludedInAct,
|
||||
actSelectionMode: actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT',
|
||||
};
|
||||
}
|
||||
|
||||
async list(visitId: string, assetId: string, principal: AuthPrincipal) {
|
||||
async list(
|
||||
visitId: string,
|
||||
assetId: string,
|
||||
actId: string | undefined,
|
||||
principal: AuthPrincipal,
|
||||
) {
|
||||
const gate = await this.requireGate(visitId, assetId, principal);
|
||||
const act = await this.requireDraftAct(visitId);
|
||||
const act = await this.requireDraftAct(visitId, actId);
|
||||
const findings = await this.findings.listForAct(act.id);
|
||||
return {
|
||||
context: gate.context,
|
||||
act,
|
||||
capture: gate.capture,
|
||||
assetIncludedInAct: await this.actContainsAsset(act.id, assetId),
|
||||
data: findings.data.filter((finding) => finding.assetId === assetId),
|
||||
actSelectionMode: actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,7 +89,15 @@ export class FieldFindingsService {
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const gate = await this.requireGate(visitId, assetId, principal);
|
||||
const act = await this.requireDraftAct(visitId);
|
||||
const act = await this.requireDraftAct(visitId, dto.actId);
|
||||
if (!await this.actContainsAsset(act.id, assetId)) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_FINDING_ASSET_NOT_IN_ACT',
|
||||
message: 'Agregá este Inventario al Acta seleccionada antes de registrar el Hallazgo',
|
||||
actId: act.id,
|
||||
assetId,
|
||||
});
|
||||
}
|
||||
const payload: CreateInspectionFindingDto = {
|
||||
assetId,
|
||||
catalogItemId: dto.catalogItemId ?? null,
|
||||
@@ -91,6 +114,7 @@ export class FieldFindingsService {
|
||||
capture: gate.capture,
|
||||
finding,
|
||||
canAddAnother: true,
|
||||
actSelectionMode: dto.actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -268,7 +292,40 @@ export class FieldFindingsService {
|
||||
};
|
||||
}
|
||||
|
||||
private async requireDraftAct(visitId: string): Promise<DraftActRow> {
|
||||
private async actContainsAsset(actId: string, assetId: string): Promise<boolean> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM inspection_act_assets
|
||||
WHERE act_id=$1::uuid AND asset_id=$2::uuid AND included=true
|
||||
) AS included
|
||||
`, [actId, assetId]) as Array<{ included: boolean }>;
|
||||
return Boolean(row?.included);
|
||||
}
|
||||
|
||||
private async requireDraftAct(visitId: string, requestedActId?: string): Promise<DraftActRow> {
|
||||
if (requestedActId) {
|
||||
const [act] = await this.dataSource.query(`
|
||||
SELECT id,code,status
|
||||
FROM inspection_acts
|
||||
WHERE id=$1::uuid AND visit_id=$2::uuid
|
||||
`, [requestedActId, visitId]) as DraftActRow[];
|
||||
if (!act) {
|
||||
throw new NotFoundException({
|
||||
code: 'FIELD_FINDING_ACT_NOT_FOUND',
|
||||
message: 'El Acta seleccionada no pertenece a esta inspección',
|
||||
});
|
||||
}
|
||||
if (act.status !== 'DRAFT') {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_FINDING_ACT_NOT_DRAFT',
|
||||
message: 'Los Hallazgos nuevos sólo pueden agregarse a un Acta en borrador',
|
||||
actId: act.id,
|
||||
actStatus: act.status,
|
||||
});
|
||||
}
|
||||
return act;
|
||||
}
|
||||
|
||||
const rows = await this.dataSource.query(`
|
||||
SELECT id, code, status
|
||||
FROM inspection_acts
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
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, InspectionVisitStatus } from '../database/entities';
|
||||
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
||||
import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
|
||||
import { InspectionVisitsService } from './inspection-visits.service';
|
||||
|
||||
@Injectable()
|
||||
export class InspectionVisitClosureService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly visits: InspectionVisitsService,
|
||||
) {}
|
||||
|
||||
async close(
|
||||
id: string,
|
||||
dto: CloseInspectionVisitDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const [visit] = await manager.query(`
|
||||
SELECT
|
||||
id,
|
||||
status,
|
||||
actual_started_at AS "actualStartedAt"
|
||||
FROM inspection_visits
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [id]) as Array<{
|
||||
id: string;
|
||||
status: InspectionVisitStatus;
|
||||
actualStartedAt: Date | null;
|
||||
}>;
|
||||
if (!visit) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_VISIT_NOT_FOUND',
|
||||
message: 'Inspección no encontrada',
|
||||
});
|
||||
}
|
||||
if (visit.status !== InspectionVisitStatus.IN_PROGRESS) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_VISIT_NOT_IN_PROGRESS',
|
||||
message: 'La Inspección debe estar en curso para cerrarse',
|
||||
});
|
||||
}
|
||||
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
|
||||
`, [id, 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 [actState] = await manager.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE act.status <> 'CANCELLED')::integer AS total,
|
||||
COUNT(*) FILTER (WHERE act.status = 'DRAFT')::integer AS drafts,
|
||||
COUNT(*) FILTER (WHERE act.status = 'READY')::integer AS "pendingSignature",
|
||||
COUNT(*) FILTER (
|
||||
WHERE act.status NOT IN ('CLOSED', 'CANCELLED')
|
||||
)::integer AS "notSealed",
|
||||
COUNT(*) FILTER (
|
||||
WHERE act.status = 'CLOSED'
|
||||
AND (
|
||||
act.closure_sha256 IS NULL
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_act_signatures signature
|
||||
WHERE signature.act_id = act.id
|
||||
AND signature.signer_type = 'INSPECTOR'
|
||||
AND signature.status = 'SIGNED'
|
||||
)
|
||||
OR 1 <> (
|
||||
SELECT COUNT(*)
|
||||
FROM inspection_act_signatures signature
|
||||
WHERE signature.act_id = act.id
|
||||
AND signature.signer_type = 'COMPANY_RESPONSIBLE'
|
||||
AND signature.status IN ('SIGNED', 'REFUSED', 'ABSENT')
|
||||
)
|
||||
)
|
||||
)::integer AS "sealedInvalid"
|
||||
FROM inspection_acts act
|
||||
WHERE act.visit_id = $1
|
||||
`, [id]) as Array<{
|
||||
total: number;
|
||||
drafts: number;
|
||||
pendingSignature: number;
|
||||
notSealed: number;
|
||||
sealedInvalid: number;
|
||||
}>;
|
||||
if (Number(actState?.total ?? 0) < 1) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_VISIT_ACT_REQUIRED',
|
||||
message: 'La Inspección debe contener al menos un Acta antes de cerrarse',
|
||||
});
|
||||
}
|
||||
if (Number(actState?.drafts ?? 0) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_VISIT_DRAFT_ACTS_PENDING',
|
||||
message: 'Finalizá o cancelá todas las Actas en borrador antes de cerrar la Inspección',
|
||||
});
|
||||
}
|
||||
if (Number(actState?.pendingSignature ?? 0) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_VISIT_COMPANY_OUTCOME_PENDING',
|
||||
message: 'No se puede cerrar la Inspección mientras exista un Acta pendiente de firma, disidencia, negativa o constancia de ausencia de la empresa',
|
||||
});
|
||||
}
|
||||
if (Number(actState?.notSealed ?? 0) > 0 || Number(actState?.sealedInvalid ?? 0) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_VISIT_ACTS_NOT_SEALED',
|
||||
message: 'Todas las Actas deben estar selladas con su evidencia de firma antes de cerrar la Inspección',
|
||||
});
|
||||
}
|
||||
|
||||
const serverClosedAt = new Date();
|
||||
const clientClosedAt = new Date(dto.clientClosedAt);
|
||||
if (!Number.isFinite(clientClosedAt.getTime())) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME',
|
||||
message: 'La fecha de cierre informada por el dispositivo no es válida',
|
||||
});
|
||||
}
|
||||
if (visit.actualStartedAt && clientClosedAt.getTime() < new Date(visit.actualStartedAt).getTime()) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME',
|
||||
message: 'La fecha de cierre del dispositivo no puede ser anterior al inicio de la Inspección',
|
||||
});
|
||||
}
|
||||
if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_VISIT_INVALID_DEVICE_TIME',
|
||||
message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro',
|
||||
});
|
||||
}
|
||||
|
||||
await manager.query(`
|
||||
UPDATE inspection_visits
|
||||
SET status = 'CLOSED',
|
||||
actual_closed_at = $2,
|
||||
updated_by = $3,
|
||||
updated_at = $2
|
||||
WHERE id = $1
|
||||
`, [id, serverClosedAt, principal.userId]);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED,
|
||||
entityType: 'inspection_visit',
|
||||
entityId: id,
|
||||
beforeData: { status: InspectionVisitStatus.IN_PROGRESS },
|
||||
afterData: {
|
||||
status: InspectionVisitStatus.CLOSED,
|
||||
clientClosedAt: clientClosedAt.toISOString(),
|
||||
serverClosedAt: serverClosedAt.toISOString(),
|
||||
allActsSealed: true,
|
||||
},
|
||||
metadata: {
|
||||
closeSource: 'ANDROID',
|
||||
companyOutcomeRequiredBeforeClose: true,
|
||||
},
|
||||
}, manager);
|
||||
});
|
||||
return this.visits.getById(id);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,15 @@ export async function nextInspectionVisitCode(
|
||||
manager: EntityManager,
|
||||
plannedStartAt: Date,
|
||||
): Promise<string> {
|
||||
const [yearRow] = (await manager.query(`
|
||||
SELECT EXTRACT(
|
||||
YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza'
|
||||
)::integer AS year
|
||||
`, [plannedStartAt])) as Array<{ year: number }>;
|
||||
const year = Number(yearRow?.year);
|
||||
if (!Number.isInteger(year) || year < 2000 || year > 9999) {
|
||||
throw new Error('Unable to derive inspection visit year');
|
||||
const [dateRow] = (await manager.query(`
|
||||
SELECT
|
||||
EXTRACT(YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::integer AS year,
|
||||
TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS "datePart"
|
||||
`, [plannedStartAt])) as Array<{ year: number; datePart: string }>;
|
||||
const year = Number(dateRow?.year);
|
||||
const datePart = dateRow?.datePart;
|
||||
if (!Number.isInteger(year) || year < 2000 || year > 9999 || !datePart) {
|
||||
throw new Error('Unable to derive inspection visit date');
|
||||
}
|
||||
|
||||
await manager.query(
|
||||
@@ -20,15 +21,25 @@ export async function nextInspectionVisitCode(
|
||||
);
|
||||
|
||||
const [sequenceRow] = (await manager.query(`
|
||||
SELECT COALESCE(MAX(RIGHT(code, 6)::integer), 0)::integer AS sequence
|
||||
FROM inspection_visits
|
||||
WHERE code LIKE $1
|
||||
AND code ~ ('^INS-' || $2::text || '-[0-9]{6}$')
|
||||
`, [`INS-${year}-%`, year])) as Array<{ sequence: number }>;
|
||||
SELECT COALESCE(MAX(candidate.sequence), 0)::integer AS sequence
|
||||
FROM (
|
||||
SELECT SUBSTRING(code FROM '^INSP-([0-9]{5})-[0-9]{2}-[0-9]{2}-[0-9]{2}$')::integer AS sequence
|
||||
FROM inspection_visits
|
||||
WHERE EXTRACT(
|
||||
YEAR FROM COALESCE(planned_start_at, created_at) AT TIME ZONE 'America/Argentina/Mendoza'
|
||||
)::integer = $1
|
||||
AND code ~ '^INSP-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}$'
|
||||
UNION ALL
|
||||
SELECT RIGHT(code, 6)::integer AS sequence
|
||||
FROM inspection_visits
|
||||
WHERE code LIKE $2
|
||||
AND code ~ ('^INS-' || $1::text || '-[0-9]{6}$')
|
||||
) candidate
|
||||
`, [year, `INS-${year}-%`])) as Array<{ sequence: number }>;
|
||||
|
||||
const sequence = Number(sequenceRow?.sequence ?? 0) + 1;
|
||||
if (sequence > 999999) {
|
||||
if (sequence > 99999) {
|
||||
throw new Error(`Inspection visit sequence exhausted for ${year}`);
|
||||
}
|
||||
return `INS-${year}-${String(sequence).padStart(6, '0')}`;
|
||||
return `INSP-${String(sequence).padStart(5, '0')}-${datePart}`;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
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 { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto';
|
||||
import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
|
||||
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
||||
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
|
||||
@@ -21,11 +20,15 @@ import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query
|
||||
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
|
||||
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
|
||||
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
|
||||
import { InspectionVisitClosureService } from './inspection-visit-closure.service';
|
||||
import { InspectionVisitsService } from './inspection-visits.service';
|
||||
|
||||
@Controller('inspection-visits')
|
||||
export class InspectionVisitsController {
|
||||
constructor(private readonly visits: InspectionVisitsService) {}
|
||||
constructor(
|
||||
private readonly visits: InspectionVisitsService,
|
||||
private readonly closure: InspectionVisitClosureService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspections.read')
|
||||
@@ -47,9 +50,7 @@ export class InspectionVisitsController {
|
||||
|
||||
@Get('planning-context/areas/:areaId/operators')
|
||||
@RequirePermissions('inspections.read')
|
||||
planningOperators(
|
||||
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
|
||||
) {
|
||||
planningOperators(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) {
|
||||
return this.visits.listPlanningOperators(areaId);
|
||||
}
|
||||
|
||||
@@ -135,17 +136,6 @@ export class InspectionVisitsController {
|
||||
return this.visits.replaceTeam(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@RequirePermissions('inspections.manage')
|
||||
changeStatus(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ChangeInspectionVisitStatusDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.visits.changeStatus(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/start')
|
||||
@RequirePermissions('inspections.execute')
|
||||
start(
|
||||
@@ -164,6 +154,6 @@ export class InspectionVisitsController {
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.visits.close(id, dto, principal, request);
|
||||
return this.closure.close(id, dto, principal, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FieldFindingsController } from './field-findings.controller';
|
||||
import { FieldFindingsService } from './field-findings.service';
|
||||
import { FieldInventoryController } from './field-inventory.controller';
|
||||
import { FieldInventoryService } from './field-inventory.service';
|
||||
import { InspectionVisitClosureService } from './inspection-visit-closure.service';
|
||||
import { InspectionVisitsController } from './inspection-visits.controller';
|
||||
import { InspectionVisitsService } from './inspection-visits.service';
|
||||
|
||||
@@ -15,6 +16,7 @@ import { InspectionVisitsService } from './inspection-visits.service';
|
||||
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
|
||||
providers: [
|
||||
InspectionVisitsService,
|
||||
InspectionVisitClosureService,
|
||||
FieldInventoryService,
|
||||
F3FieldInventoryStructureService,
|
||||
FieldFindingsService,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export enum SurveyReviewDecision {
|
||||
APPROVE = 'APPROVE',
|
||||
REJECT = 'REJECT',
|
||||
}
|
||||
|
||||
export class ReviewSurveyReportDto {
|
||||
@IsEnum(SurveyReviewDecision)
|
||||
decision!: SurveyReviewDecision;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { SurveyReportOutcome } from '../../database/entities';
|
||||
|
||||
export class SaveSurveyReportDto {
|
||||
@IsOptional()
|
||||
@IsEnum(SurveyReportOutcome)
|
||||
outcome?: SurveyReportOutcome | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
observedAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 6 })
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 6 })
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 3 })
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
accuracyM?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8000)
|
||||
notes?: string | null;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ArrayUnique()
|
||||
@IsUUID('4', { each: true })
|
||||
mediaIds!: string[];
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
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 { ReviewSurveyReportDto } from './dto/review-survey-report.dto';
|
||||
import { SaveSurveyReportDto } from './dto/save-survey-report.dto';
|
||||
import { SurveyExecutionService } from './survey-execution.service';
|
||||
|
||||
@Controller('survey-campaign-targets/:targetId/report')
|
||||
export class SurveyExecutionController {
|
||||
constructor(private readonly execution: SurveyExecutionService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('surveys.read_reports')
|
||||
get(
|
||||
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
|
||||
) {
|
||||
return this.execution.get(targetId);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequirePermissions('surveys.capture')
|
||||
save(
|
||||
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
|
||||
@Body() dto: SaveSurveyReportDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.execution.save(targetId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('submit')
|
||||
@RequirePermissions('surveys.capture')
|
||||
submit(
|
||||
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.execution.submit(targetId, principal, request);
|
||||
}
|
||||
|
||||
@Post('review')
|
||||
@RequirePermissions('surveys.review')
|
||||
review(
|
||||
@Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string,
|
||||
@Body() dto: ReviewSurveyReportDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.execution.review(targetId, dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AssetMasterModule } from '../asset-master/asset-master.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { SurveyExecutionController } from './survey-execution.controller';
|
||||
import { SurveyExecutionService } from './survey-execution.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, AssetMasterModule],
|
||||
controllers: [SurveyExecutionController],
|
||||
providers: [SurveyExecutionService],
|
||||
})
|
||||
export class SurveyExecutionModule {}
|
||||
@@ -1,924 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||
import { AssetHistoryService } from '../asset-master/asset-history.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import {
|
||||
Asset,
|
||||
AssetDataOrigin,
|
||||
AssetInformationStatus,
|
||||
AssetVersionChangeType,
|
||||
AuditAction,
|
||||
SurveyCampaign,
|
||||
SurveyCampaignStatus,
|
||||
SurveyCampaignTarget,
|
||||
SurveyReportOutcome,
|
||||
SurveyReportStatus,
|
||||
SurveyReportVersionEvent,
|
||||
SurveyTargetReport,
|
||||
SurveyTargetStatus,
|
||||
} from '../database/entities';
|
||||
import {
|
||||
SurveyReviewDecision,
|
||||
type ReviewSurveyReportDto,
|
||||
} from './dto/review-survey-report.dto';
|
||||
import type { SaveSurveyReportDto } from './dto/save-survey-report.dto';
|
||||
|
||||
interface ExecutionPerson {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
interface ExecutionContextView {
|
||||
target: {
|
||||
id: string;
|
||||
status: SurveyTargetStatus;
|
||||
dueAt: Date | null;
|
||||
instructions: string | null;
|
||||
assignedUser: ExecutionPerson | null;
|
||||
};
|
||||
campaign: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: SurveyCampaignStatus;
|
||||
};
|
||||
asset: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
typeName: string;
|
||||
informationStatus: AssetInformationStatus;
|
||||
currentVersion: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SurveyReportView {
|
||||
id: string;
|
||||
targetId: string;
|
||||
outcome: SurveyReportOutcome | null;
|
||||
status: SurveyReportStatus;
|
||||
observedAt: Date | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
accuracyM: number | null;
|
||||
notes: string | null;
|
||||
assetVersionAtSubmission: number | null;
|
||||
submittedAt: Date | null;
|
||||
submittedBy: ExecutionPerson | null;
|
||||
reviewedAt: Date | null;
|
||||
reviewedBy: ExecutionPerson | null;
|
||||
reviewNotes: string | null;
|
||||
selectedMediaIds: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ReportMediaView {
|
||||
id: string;
|
||||
kind: 'PHOTO';
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
capturedAt: Date | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
accuracyM: number | null;
|
||||
source: string;
|
||||
createdAt: Date;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
interface ReportVersionView {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
event: SurveyReportVersionEvent;
|
||||
snapshot: Record<string, unknown>;
|
||||
actorUserId: string | null;
|
||||
actorUsername: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SurveyExecutionView extends ExecutionContextView {
|
||||
report: SurveyReportView | null;
|
||||
availableMedia: ReportMediaView[];
|
||||
versions: ReportVersionView[];
|
||||
}
|
||||
|
||||
function targetNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'SURVEY_TARGET_NOT_FOUND',
|
||||
message: 'Objetivo de relevamiento no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
function reportNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'SURVEY_REPORT_NOT_FOUND',
|
||||
message: 'El objetivo todavía no tiene un informe de campo',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SurveyExecutionService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly history: AssetHistoryService,
|
||||
) {}
|
||||
|
||||
async get(targetId: string): Promise<SurveyExecutionView> {
|
||||
return this.dataSource.transaction((manager) => this.loadView(manager, targetId));
|
||||
}
|
||||
|
||||
async save(
|
||||
targetId: string,
|
||||
dto: SaveSurveyReportDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyExecutionView> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const { target, campaign } = await this.lockTargetContext(manager, targetId);
|
||||
this.assertCaptureAllowed(target, campaign, principal);
|
||||
const repository = manager.getRepository(SurveyTargetReport);
|
||||
let report = await this.lockReport(manager, targetId, false);
|
||||
const before = report ? this.reportAuditView(report) : null;
|
||||
if (!report) {
|
||||
report = repository.create({
|
||||
targetId,
|
||||
outcome: null,
|
||||
status: SurveyReportStatus.DRAFT,
|
||||
observedAt: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyM: null,
|
||||
notes: null,
|
||||
assetVersionAtSubmission: null,
|
||||
submittedAt: null,
|
||||
submittedBy: null,
|
||||
reviewedAt: null,
|
||||
reviewedBy: null,
|
||||
reviewNotes: null,
|
||||
createdBy: principal.userId,
|
||||
updatedBy: principal.userId,
|
||||
});
|
||||
} else if (
|
||||
report.status === SurveyReportStatus.SUBMITTED ||
|
||||
report.status === SurveyReportStatus.APPROVED
|
||||
) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_REPORT_LOCKED',
|
||||
message: 'El informe enviado o aprobado ya no puede editarse',
|
||||
});
|
||||
}
|
||||
|
||||
const nextLatitude = dto.latitude === undefined
|
||||
? this.numberOrNull(report.latitude)
|
||||
: dto.latitude;
|
||||
const nextLongitude = dto.longitude === undefined
|
||||
? this.numberOrNull(report.longitude)
|
||||
: dto.longitude;
|
||||
const nextAccuracy = dto.accuracyM === undefined
|
||||
? this.numberOrNull(report.accuracyM)
|
||||
: dto.accuracyM;
|
||||
this.assertCoordinatePair(nextLatitude, nextLongitude, nextAccuracy);
|
||||
await this.validateMedia(manager, target.assetId, dto.mediaIds);
|
||||
|
||||
if (dto.outcome !== undefined) report.outcome = dto.outcome;
|
||||
if (dto.observedAt !== undefined) {
|
||||
report.observedAt = dto.observedAt ? new Date(dto.observedAt) : null;
|
||||
}
|
||||
if (dto.latitude !== undefined) {
|
||||
report.latitude = dto.latitude == null ? null : String(dto.latitude);
|
||||
}
|
||||
if (dto.longitude !== undefined) {
|
||||
report.longitude = dto.longitude == null ? null : String(dto.longitude);
|
||||
}
|
||||
if (dto.accuracyM !== undefined) {
|
||||
report.accuracyM = dto.accuracyM == null ? null : String(dto.accuracyM);
|
||||
}
|
||||
if (dto.notes !== undefined) report.notes = dto.notes?.trim() || null;
|
||||
if (report.status === SurveyReportStatus.REJECTED) {
|
||||
report.status = SurveyReportStatus.DRAFT;
|
||||
report.assetVersionAtSubmission = null;
|
||||
report.submittedAt = null;
|
||||
report.submittedBy = null;
|
||||
report.reviewedAt = null;
|
||||
report.reviewedBy = null;
|
||||
report.reviewNotes = null;
|
||||
}
|
||||
report.updatedBy = principal.userId;
|
||||
await repository.save(report);
|
||||
await this.replaceMediaSelection(manager, report.id, dto.mediaIds, principal.userId);
|
||||
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.SURVEY_REPORT_SAVED,
|
||||
entityType: 'survey_target_report',
|
||||
entityId: report.id,
|
||||
beforeData: before,
|
||||
afterData: this.reportAuditView(report),
|
||||
metadata: { targetId, mediaCount: dto.mediaIds.length },
|
||||
}, manager);
|
||||
return this.loadView(manager, targetId);
|
||||
});
|
||||
}
|
||||
|
||||
async submit(
|
||||
targetId: string,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyExecutionView> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const { target, campaign } = await this.lockTargetContext(manager, targetId);
|
||||
this.assertCaptureAllowed(target, campaign, principal);
|
||||
const report = await this.lockReport(manager, targetId, true);
|
||||
if (!report) throw reportNotFound();
|
||||
if (report.status !== SurveyReportStatus.DRAFT) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_REPORT_NOT_DRAFT',
|
||||
message: 'Sólo se puede enviar un informe guardado como borrador',
|
||||
});
|
||||
}
|
||||
await this.assertComplete(manager, report);
|
||||
|
||||
const asset = await this.lockAsset(manager, target.assetId);
|
||||
const beforeAsset = this.assetAuditView(asset);
|
||||
asset.informationStatus = report.outcome === SurveyReportOutcome.NOT_LOCATED
|
||||
? AssetInformationStatus.OBSERVED
|
||||
: AssetInformationStatus.SURVEYED;
|
||||
asset.dataOrigin = AssetDataOrigin.FIELD_SURVEY;
|
||||
asset.sourceName = `Relevamiento ${campaign.code}`;
|
||||
asset.sourceReference = `survey-report:${report.id}`;
|
||||
asset.sourceObservedAt = report.observedAt;
|
||||
asset.sourceNotes = report.notes;
|
||||
asset.provenanceVerifiedAt = null;
|
||||
asset.provenanceVerifiedBy = null;
|
||||
asset.provenanceUpdatedAt = new Date();
|
||||
asset.provenanceUpdatedBy = principal.userId;
|
||||
asset.updatedBy = principal.userId;
|
||||
await manager.getRepository(Asset).save(asset);
|
||||
const assetVersion = await this.history.capture(
|
||||
manager,
|
||||
asset.id,
|
||||
AssetVersionChangeType.PROVENANCE_UPDATED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
asset.currentVersion = assetVersion;
|
||||
|
||||
report.status = SurveyReportStatus.SUBMITTED;
|
||||
report.assetVersionAtSubmission = assetVersion;
|
||||
report.submittedAt = new Date();
|
||||
report.submittedBy = principal.userId;
|
||||
report.reviewedAt = null;
|
||||
report.reviewedBy = null;
|
||||
report.reviewNotes = null;
|
||||
report.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyTargetReport).save(report);
|
||||
|
||||
target.status = SurveyTargetStatus.SUBMITTED;
|
||||
target.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaignTarget).save(target);
|
||||
campaign.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaign).save(campaign);
|
||||
|
||||
const reportVersion = await this.captureReportVersion(
|
||||
manager,
|
||||
report,
|
||||
SurveyReportVersionEvent.SUBMITTED,
|
||||
principal,
|
||||
);
|
||||
await this.recordAssetChange(
|
||||
manager,
|
||||
asset,
|
||||
beforeAsset,
|
||||
AuditAction.ASSET_PROVENANCE_UPDATED,
|
||||
principal,
|
||||
request,
|
||||
targetId,
|
||||
assetVersion,
|
||||
);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.SURVEY_REPORT_SUBMITTED,
|
||||
entityType: 'survey_target_report',
|
||||
entityId: report.id,
|
||||
afterData: this.reportAuditView(report),
|
||||
metadata: { targetId, assetId: asset.id, assetVersion, reportVersion },
|
||||
}, manager);
|
||||
return this.loadView(manager, targetId);
|
||||
});
|
||||
}
|
||||
|
||||
async review(
|
||||
targetId: string,
|
||||
dto: ReviewSurveyReportDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyExecutionView> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const { target, campaign } = await this.lockTargetContext(manager, targetId);
|
||||
if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS',
|
||||
message: 'La campaña debe estar en curso para revisar informes',
|
||||
});
|
||||
}
|
||||
const report = await this.lockReport(manager, targetId, true);
|
||||
if (!report) throw reportNotFound();
|
||||
if (report.status !== SurveyReportStatus.SUBMITTED || target.status !== SurveyTargetStatus.SUBMITTED) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_REPORT_NOT_SUBMITTED',
|
||||
message: 'El informe no está pendiente de revisión',
|
||||
});
|
||||
}
|
||||
const notes = dto.notes?.trim() || null;
|
||||
if (dto.decision === SurveyReviewDecision.REJECT && (!notes || notes.length < 10)) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_REJECTION_REASON_REQUIRED',
|
||||
message: 'Indicá un motivo de rechazo de al menos 10 caracteres',
|
||||
});
|
||||
}
|
||||
|
||||
const asset = await this.lockAsset(manager, target.assetId);
|
||||
if (
|
||||
dto.decision === SurveyReviewDecision.APPROVE &&
|
||||
asset.currentVersion !== report.assetVersionAtSubmission
|
||||
) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_ASSET_CHANGED_AFTER_SUBMISSION',
|
||||
message: 'El activo cambió después del envío; rechazá el informe para que sea revisado nuevamente',
|
||||
});
|
||||
}
|
||||
const beforeAsset = this.assetAuditView(asset);
|
||||
let assetVersion = asset.currentVersion;
|
||||
|
||||
if (dto.decision === SurveyReviewDecision.APPROVE) {
|
||||
if (report.outcome !== SurveyReportOutcome.NOT_LOCATED) {
|
||||
asset.informationStatus = AssetInformationStatus.VALIDATED;
|
||||
asset.provenanceVerifiedAt = new Date();
|
||||
asset.provenanceVerifiedBy = principal.userId;
|
||||
asset.provenanceUpdatedAt = new Date();
|
||||
asset.provenanceUpdatedBy = principal.userId;
|
||||
asset.updatedBy = principal.userId;
|
||||
await manager.getRepository(Asset).save(asset);
|
||||
assetVersion = await this.history.capture(
|
||||
manager,
|
||||
asset.id,
|
||||
AssetVersionChangeType.PROVENANCE_VERIFIED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
asset.currentVersion = assetVersion;
|
||||
await this.recordAssetChange(
|
||||
manager,
|
||||
asset,
|
||||
beforeAsset,
|
||||
AuditAction.ASSET_PROVENANCE_VERIFIED,
|
||||
principal,
|
||||
request,
|
||||
targetId,
|
||||
assetVersion,
|
||||
);
|
||||
}
|
||||
report.status = SurveyReportStatus.APPROVED;
|
||||
target.status = SurveyTargetStatus.COMPLETED;
|
||||
} else {
|
||||
const assetStillMatchesSubmission =
|
||||
asset.currentVersion === report.assetVersionAtSubmission;
|
||||
const assetNeedsUpdate = assetStillMatchesSubmission && (
|
||||
asset.informationStatus !== AssetInformationStatus.OBSERVED ||
|
||||
asset.provenanceVerifiedAt !== null ||
|
||||
asset.provenanceVerifiedBy !== null
|
||||
);
|
||||
if (assetNeedsUpdate) {
|
||||
asset.informationStatus = AssetInformationStatus.OBSERVED;
|
||||
asset.provenanceVerifiedAt = null;
|
||||
asset.provenanceVerifiedBy = null;
|
||||
asset.provenanceUpdatedAt = new Date();
|
||||
asset.provenanceUpdatedBy = principal.userId;
|
||||
asset.updatedBy = principal.userId;
|
||||
await manager.getRepository(Asset).save(asset);
|
||||
assetVersion = await this.history.capture(
|
||||
manager,
|
||||
asset.id,
|
||||
AssetVersionChangeType.STATUS_CHANGED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
asset.currentVersion = assetVersion;
|
||||
await this.recordAssetChange(
|
||||
manager,
|
||||
asset,
|
||||
beforeAsset,
|
||||
AuditAction.ASSET_INFORMATION_STATUS_CHANGED,
|
||||
principal,
|
||||
request,
|
||||
targetId,
|
||||
assetVersion,
|
||||
);
|
||||
}
|
||||
report.status = SurveyReportStatus.REJECTED;
|
||||
target.status = SurveyTargetStatus.IN_PROGRESS;
|
||||
}
|
||||
|
||||
report.reviewedAt = new Date();
|
||||
report.reviewedBy = principal.userId;
|
||||
report.reviewNotes = notes;
|
||||
report.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyTargetReport).save(report);
|
||||
target.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaignTarget).save(target);
|
||||
campaign.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaign).save(campaign);
|
||||
|
||||
const event = dto.decision === SurveyReviewDecision.APPROVE
|
||||
? SurveyReportVersionEvent.APPROVED
|
||||
: SurveyReportVersionEvent.REJECTED;
|
||||
const reportVersion = await this.captureReportVersion(manager, report, event, principal);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: dto.decision === SurveyReviewDecision.APPROVE
|
||||
? AuditAction.SURVEY_REPORT_APPROVED
|
||||
: AuditAction.SURVEY_REPORT_REJECTED,
|
||||
entityType: 'survey_target_report',
|
||||
entityId: report.id,
|
||||
afterData: this.reportAuditView(report),
|
||||
metadata: { targetId, assetId: asset.id, assetVersion, reportVersion },
|
||||
}, manager);
|
||||
return this.loadView(manager, targetId);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadView(manager: EntityManager, targetId: string): Promise<SurveyExecutionView> {
|
||||
const [context] = (await manager.query(`
|
||||
SELECT
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', target.id,
|
||||
'status', target.status,
|
||||
'dueAt', target.due_at,
|
||||
'instructions', target.instructions,
|
||||
'assignedUser', CASE WHEN assignee.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', assignee.id,
|
||||
'username', assignee.username,
|
||||
'firstName', assignee.first_name,
|
||||
'lastName', assignee.last_name
|
||||
) END
|
||||
) AS target,
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', campaign.id,
|
||||
'code', campaign.code,
|
||||
'name', campaign.name,
|
||||
'status', campaign.status
|
||||
) AS campaign,
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', asset.id,
|
||||
'code', asset.code,
|
||||
'name', asset.name,
|
||||
'typeName', asset_type.name,
|
||||
'informationStatus', asset.information_status,
|
||||
'currentVersion', asset.current_version
|
||||
) AS asset
|
||||
FROM survey_campaign_targets target
|
||||
INNER JOIN survey_campaigns campaign ON campaign.id = target.campaign_id
|
||||
INNER JOIN assets asset ON asset.id = target.asset_id
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
LEFT JOIN users assignee ON assignee.id = target.assigned_user_id
|
||||
WHERE target.id = $1
|
||||
`, [targetId])) as ExecutionContextView[];
|
||||
if (!context) throw targetNotFound();
|
||||
|
||||
const [report] = (await manager.query(`
|
||||
SELECT
|
||||
report.id,
|
||||
report.target_id AS "targetId",
|
||||
report.outcome,
|
||||
report.status,
|
||||
report.observed_at AS "observedAt",
|
||||
report.latitude::double precision AS latitude,
|
||||
report.longitude::double precision AS longitude,
|
||||
report.accuracy_m::double precision AS "accuracyM",
|
||||
report.notes,
|
||||
report.asset_version_at_submission AS "assetVersionAtSubmission",
|
||||
report.submitted_at AS "submittedAt",
|
||||
CASE WHEN submitter.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', submitter.id,
|
||||
'username', submitter.username,
|
||||
'firstName', submitter.first_name,
|
||||
'lastName', submitter.last_name
|
||||
) END AS "submittedBy",
|
||||
report.reviewed_at AS "reviewedAt",
|
||||
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 "reviewedBy",
|
||||
report.review_notes AS "reviewNotes",
|
||||
COALESCE((
|
||||
SELECT ARRAY_AGG(link.media_id ORDER BY link.created_at)
|
||||
FROM survey_target_report_media link
|
||||
INNER JOIN asset_media media ON media.id = link.media_id
|
||||
WHERE link.report_id = report.id
|
||||
AND link.included = true
|
||||
AND media.deleted_at IS NULL
|
||||
), ARRAY[]::uuid[]) AS "selectedMediaIds",
|
||||
report.created_at AS "createdAt",
|
||||
report.updated_at AS "updatedAt"
|
||||
FROM survey_target_reports report
|
||||
LEFT JOIN users submitter ON submitter.id = report.submitted_by
|
||||
LEFT JOIN users reviewer ON reviewer.id = report.reviewed_by
|
||||
WHERE report.target_id = $1
|
||||
`, [targetId])) as SurveyReportView[];
|
||||
|
||||
const availableMedia = (await manager.query(`
|
||||
SELECT
|
||||
media.id,
|
||||
media.kind,
|
||||
media.original_name AS "originalName",
|
||||
media.mime_type AS "mimeType",
|
||||
media.size_bytes::double precision AS "sizeBytes",
|
||||
media.sha256,
|
||||
media.title,
|
||||
media.description,
|
||||
media.captured_at AS "capturedAt",
|
||||
media.latitude::double precision AS latitude,
|
||||
media.longitude::double precision AS longitude,
|
||||
media.accuracy_m::double precision AS "accuracyM",
|
||||
media.source,
|
||||
media.created_at AS "createdAt",
|
||||
EXISTS (
|
||||
SELECT 1 FROM survey_target_report_media link
|
||||
WHERE link.report_id = $2 AND link.media_id = media.id AND link.included = true
|
||||
) AS selected
|
||||
FROM asset_media media
|
||||
WHERE media.asset_id = $1
|
||||
AND media.kind = 'PHOTO'
|
||||
AND media.deleted_at IS NULL
|
||||
ORDER BY media.created_at DESC
|
||||
`, [context.asset.id, report?.id ?? null])) as ReportMediaView[];
|
||||
|
||||
const versions = report
|
||||
? (await manager.query(`
|
||||
SELECT
|
||||
version.id,
|
||||
version.version_number AS "versionNumber",
|
||||
version.event,
|
||||
version.snapshot,
|
||||
version.actor_user_id AS "actorUserId",
|
||||
version.actor_username AS "actorUsername",
|
||||
version.created_at AS "createdAt"
|
||||
FROM survey_target_report_versions version
|
||||
WHERE version.report_id = $1
|
||||
ORDER BY version.version_number DESC
|
||||
`, [report.id])) as ReportVersionView[]
|
||||
: [];
|
||||
return { ...context, report: report ?? null, availableMedia, versions };
|
||||
}
|
||||
|
||||
private async lockTargetContext(
|
||||
manager: EntityManager,
|
||||
targetId: string,
|
||||
): Promise<{ target: SurveyCampaignTarget; campaign: SurveyCampaign }> {
|
||||
const target = await manager.getRepository(SurveyCampaignTarget)
|
||||
.createQueryBuilder('target')
|
||||
.where('target.id = :targetId', { targetId })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!target) throw targetNotFound();
|
||||
const campaign = await manager.getRepository(SurveyCampaign)
|
||||
.createQueryBuilder('campaign')
|
||||
.where('campaign.id = :campaignId', { campaignId: target.campaignId })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!campaign) throw targetNotFound();
|
||||
return { target, campaign };
|
||||
}
|
||||
|
||||
private async lockReport(
|
||||
manager: EntityManager,
|
||||
targetId: string,
|
||||
required: boolean,
|
||||
): Promise<SurveyTargetReport | null> {
|
||||
const report = await manager.getRepository(SurveyTargetReport)
|
||||
.createQueryBuilder('report')
|
||||
.where('report.targetId = :targetId', { targetId })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!report && required) throw reportNotFound();
|
||||
return report;
|
||||
}
|
||||
|
||||
private async lockAsset(manager: EntityManager, assetId: string): Promise<Asset> {
|
||||
const asset = await manager.getRepository(Asset)
|
||||
.createQueryBuilder('asset')
|
||||
.where('asset.id = :assetId', { assetId })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!asset) throw targetNotFound();
|
||||
return asset;
|
||||
}
|
||||
|
||||
private assertCaptureAllowed(
|
||||
target: SurveyCampaignTarget,
|
||||
campaign: SurveyCampaign,
|
||||
principal: AuthPrincipal,
|
||||
): void {
|
||||
if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS',
|
||||
message: 'La campaña debe estar en curso para capturar el relevamiento',
|
||||
});
|
||||
}
|
||||
if (target.status !== SurveyTargetStatus.IN_PROGRESS) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_TARGET_NOT_IN_PROGRESS',
|
||||
message: 'El objetivo debe estar en ejecución para editar o enviar el informe',
|
||||
});
|
||||
}
|
||||
if (
|
||||
!principal.permissions.includes('surveys.manage') &&
|
||||
target.assignedUserId !== principal.userId
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: 'SURVEY_TARGET_NOT_ASSIGNED',
|
||||
message: 'El objetivo no está asignado al usuario actual',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private assertCoordinatePair(
|
||||
latitude: number | null | undefined,
|
||||
longitude: number | null | undefined,
|
||||
accuracyM: number | null | undefined,
|
||||
): void {
|
||||
if ((latitude == null) !== (longitude == null)) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_COORDINATES_INCOMPLETE',
|
||||
message: 'Latitud y longitud deben informarse juntas',
|
||||
});
|
||||
}
|
||||
if (accuracyM != null && latitude == null) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_ACCURACY_WITHOUT_LOCATION',
|
||||
message: 'La precisión requiere coordenadas GPS',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async validateMedia(
|
||||
manager: EntityManager,
|
||||
assetId: string,
|
||||
mediaIds: string[],
|
||||
): Promise<void> {
|
||||
if (mediaIds.length === 0) return;
|
||||
const [row] = (await manager.query(`
|
||||
SELECT COUNT(*)::integer AS count
|
||||
FROM asset_media
|
||||
WHERE asset_id = $1
|
||||
AND id = ANY($2::uuid[])
|
||||
AND kind = 'PHOTO'
|
||||
AND deleted_at IS NULL
|
||||
`, [assetId, mediaIds])) as Array<{ count: number }>;
|
||||
if (Number(row?.count ?? 0) !== mediaIds.length) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_MEDIA_INVALID',
|
||||
message: 'Una o más evidencias no son fotografías activas del activo relevado',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceMediaSelection(
|
||||
manager: EntityManager,
|
||||
reportId: string,
|
||||
mediaIds: string[],
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await manager.query(`
|
||||
UPDATE survey_target_report_media
|
||||
SET included = false, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE report_id = $1 AND included = true
|
||||
`, [reportId]);
|
||||
for (const mediaId of mediaIds) {
|
||||
await manager.query(`
|
||||
INSERT INTO survey_target_report_media (
|
||||
report_id, media_id, included, added_by
|
||||
) VALUES ($1, $2, true, $3)
|
||||
ON CONFLICT (report_id, media_id) DO UPDATE SET
|
||||
included = true,
|
||||
added_by = EXCLUDED.added_by,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, [reportId, mediaId, userId]);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertComplete(
|
||||
manager: EntityManager,
|
||||
report: SurveyTargetReport,
|
||||
): Promise<void> {
|
||||
if (!report.outcome) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_OUTCOME_REQUIRED',
|
||||
message: 'Seleccioná el resultado de la verificación',
|
||||
});
|
||||
}
|
||||
if (!report.observedAt) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_OBSERVED_AT_REQUIRED',
|
||||
message: 'Indicá la fecha y hora de observación',
|
||||
});
|
||||
}
|
||||
if (report.latitude == null || report.longitude == null || report.accuracyM == null) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_GPS_REQUIRED',
|
||||
message: 'Capturá ubicación y precisión GPS antes de enviar',
|
||||
});
|
||||
}
|
||||
if (
|
||||
report.outcome !== SurveyReportOutcome.CONFIRMED &&
|
||||
(!report.notes || report.notes.trim().length < 10)
|
||||
) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_NOTES_REQUIRED',
|
||||
message: 'Describí los cambios o la imposibilidad de localizar el activo',
|
||||
});
|
||||
}
|
||||
const [row] = (await manager.query(`
|
||||
SELECT COUNT(*)::integer AS count
|
||||
FROM survey_target_report_media link
|
||||
INNER JOIN asset_media media ON media.id = link.media_id
|
||||
WHERE link.report_id = $1
|
||||
AND link.included = true
|
||||
AND media.kind = 'PHOTO'
|
||||
AND media.deleted_at IS NULL
|
||||
`, [report.id])) as Array<{ count: number }>;
|
||||
if (Number(row?.count ?? 0) < 1) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_PHOTO_REQUIRED',
|
||||
message: 'Seleccioná al menos una fotografía como evidencia',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async captureReportVersion(
|
||||
manager: EntityManager,
|
||||
report: SurveyTargetReport,
|
||||
event: SurveyReportVersionEvent,
|
||||
principal: AuthPrincipal,
|
||||
): Promise<number> {
|
||||
const [row] = (await manager.query(`
|
||||
SELECT COALESCE(MAX(version_number), 0)::integer + 1 AS "versionNumber"
|
||||
FROM survey_target_report_versions
|
||||
WHERE report_id = $1
|
||||
`, [report.id])) as Array<{ versionNumber: number }>;
|
||||
const versionNumber = Number(row?.versionNumber ?? 1);
|
||||
const snapshot = await this.buildReportSnapshot(manager, report.id);
|
||||
await manager.query(`
|
||||
INSERT INTO survey_target_report_versions (
|
||||
report_id, version_number, event, snapshot, actor_user_id, actor_username
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, [report.id, versionNumber, event, snapshot, principal.userId, principal.username]);
|
||||
return versionNumber;
|
||||
}
|
||||
|
||||
private async buildReportSnapshot(
|
||||
manager: EntityManager,
|
||||
reportId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const [row] = (await manager.query(`
|
||||
SELECT JSONB_BUILD_OBJECT(
|
||||
'id', report.id,
|
||||
'status', report.status,
|
||||
'outcome', report.outcome,
|
||||
'observedAt', report.observed_at,
|
||||
'location', JSONB_BUILD_OBJECT(
|
||||
'latitude', report.latitude,
|
||||
'longitude', report.longitude,
|
||||
'accuracyM', report.accuracy_m
|
||||
),
|
||||
'notes', report.notes,
|
||||
'assetVersionAtSubmission', report.asset_version_at_submission,
|
||||
'submittedAt', report.submitted_at,
|
||||
'submittedBy', report.submitted_by,
|
||||
'reviewedAt', report.reviewed_at,
|
||||
'reviewedBy', report.reviewed_by,
|
||||
'reviewNotes', report.review_notes,
|
||||
'target', JSONB_BUILD_OBJECT(
|
||||
'id', target.id,
|
||||
'status', target.status,
|
||||
'dueAt', target.due_at,
|
||||
'instructions', target.instructions
|
||||
),
|
||||
'campaign', JSONB_BUILD_OBJECT(
|
||||
'id', campaign.id,
|
||||
'code', campaign.code,
|
||||
'name', campaign.name
|
||||
),
|
||||
'asset', JSONB_BUILD_OBJECT(
|
||||
'id', asset.id,
|
||||
'code', asset.code,
|
||||
'name', asset.name,
|
||||
'informationStatus', asset.information_status,
|
||||
'currentVersion', asset.current_version
|
||||
),
|
||||
'evidence', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'id', media.id,
|
||||
'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,
|
||||
'createdAt', media.created_at
|
||||
) ORDER BY media.created_at, media.id)
|
||||
FROM survey_target_report_media link
|
||||
INNER JOIN asset_media media ON media.id = link.media_id
|
||||
WHERE link.report_id = report.id AND link.included = true
|
||||
), '[]'::jsonb)
|
||||
) AS snapshot
|
||||
FROM survey_target_reports report
|
||||
INNER JOIN survey_campaign_targets target ON target.id = report.target_id
|
||||
INNER JOIN survey_campaigns campaign ON campaign.id = target.campaign_id
|
||||
INNER JOIN assets asset ON asset.id = target.asset_id
|
||||
WHERE report.id = $1
|
||||
`, [reportId])) as Array<{ snapshot: Record<string, unknown> }>;
|
||||
if (!row) throw reportNotFound();
|
||||
return row.snapshot;
|
||||
}
|
||||
|
||||
private async recordAssetChange(
|
||||
manager: EntityManager,
|
||||
asset: Asset,
|
||||
beforeData: Record<string, unknown>,
|
||||
action: AuditAction,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
targetId: string,
|
||||
versionNumber: number,
|
||||
): Promise<void> {
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action,
|
||||
entityType: 'asset',
|
||||
entityId: asset.id,
|
||||
beforeData,
|
||||
afterData: this.assetAuditView(asset),
|
||||
metadata: { targetId, versionNumber, source: 'survey_report' },
|
||||
}, manager);
|
||||
}
|
||||
|
||||
private assetAuditView(asset: Asset): Record<string, unknown> {
|
||||
return {
|
||||
informationStatus: asset.informationStatus,
|
||||
dataOrigin: asset.dataOrigin,
|
||||
sourceName: asset.sourceName,
|
||||
sourceReference: asset.sourceReference,
|
||||
sourceObservedAt: asset.sourceObservedAt,
|
||||
provenanceVerifiedAt: asset.provenanceVerifiedAt,
|
||||
provenanceVerifiedBy: asset.provenanceVerifiedBy,
|
||||
currentVersion: asset.currentVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private reportAuditView(report: SurveyTargetReport): Record<string, unknown> {
|
||||
return {
|
||||
targetId: report.targetId,
|
||||
outcome: report.outcome,
|
||||
status: report.status,
|
||||
observedAt: report.observedAt,
|
||||
latitude: this.numberOrNull(report.latitude),
|
||||
longitude: this.numberOrNull(report.longitude),
|
||||
accuracyM: this.numberOrNull(report.accuracyM),
|
||||
notes: report.notes,
|
||||
assetVersionAtSubmission: report.assetVersionAtSubmission,
|
||||
submittedAt: report.submittedAt,
|
||||
submittedBy: report.submittedBy,
|
||||
reviewedAt: report.reviewedAt,
|
||||
reviewedBy: report.reviewedBy,
|
||||
reviewNotes: report.reviewNotes,
|
||||
};
|
||||
}
|
||||
|
||||
private numberOrNull(value: string | number | null): number | null {
|
||||
return value == null ? null : Number(value);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsISO8601, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class AddSurveyTargetDto {
|
||||
@IsUUID('4')
|
||||
assetId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
assignedUserId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
dueAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : null,
|
||||
)
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
instructions?: string | null;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class AssignSurveyTargetDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
assignedUserId!: string | null;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { SurveyCampaignStatus } from '../../database/entities';
|
||||
|
||||
export class ChangeSurveyCampaignStatusDto {
|
||||
@IsEnum(SurveyCampaignStatus)
|
||||
status!: SurveyCampaignStatus;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { SurveyTargetStatus } from '../../database/entities';
|
||||
|
||||
export class ChangeSurveyTargetStatusDto {
|
||||
@IsEnum(SurveyTargetStatus)
|
||||
status!: SurveyTargetStatus;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateSurveyCampaignDto {
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toUpperCase() : value,
|
||||
)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
||||
code!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : null,
|
||||
)
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
plannedStartAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
plannedEndAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
scopeAssetId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
coordinatorUserId?: string | null;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { SurveyCampaignStatus } from '../../database/entities';
|
||||
|
||||
export class ListSurveyCampaignsQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize = 25;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(SurveyCampaignStatus)
|
||||
status?: SurveyCampaignStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
coordinatorUserId?: string;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateSurveyCampaignDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toUpperCase() : value,
|
||||
)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : null,
|
||||
)
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
plannedStartAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
plannedEndAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
scopeAssetId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
coordinatorUserId?: string | null;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateSurveyTargetDto {
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
dueAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : null,
|
||||
)
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
instructions?: string | null;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
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 { AddSurveyTargetDto } from './dto/add-survey-target.dto';
|
||||
import { AssignSurveyTargetDto } from './dto/assign-survey-target.dto';
|
||||
import { ChangeSurveyCampaignStatusDto } from './dto/change-survey-campaign-status.dto';
|
||||
import { ChangeSurveyTargetStatusDto } from './dto/change-survey-target-status.dto';
|
||||
import { CreateSurveyCampaignDto } from './dto/create-survey-campaign.dto';
|
||||
import { ListSurveyCampaignsQueryDto } from './dto/list-survey-campaigns-query.dto';
|
||||
import { UpdateSurveyCampaignDto } from './dto/update-survey-campaign.dto';
|
||||
import { UpdateSurveyTargetDto } from './dto/update-survey-target.dto';
|
||||
import { SurveyPlanningService } from './survey-planning.service';
|
||||
|
||||
@Controller('survey-campaigns')
|
||||
export class SurveyCampaignsController {
|
||||
constructor(private readonly planning: SurveyPlanningService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('surveys.read')
|
||||
list(@Query() query: ListSurveyCampaignsQueryDto) {
|
||||
return this.planning.list(query);
|
||||
}
|
||||
|
||||
@Get('assignees')
|
||||
@RequirePermissions('surveys.assign')
|
||||
assignees() {
|
||||
return this.planning.listAssignees();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('surveys.manage')
|
||||
create(
|
||||
@Body() dto: CreateSurveyCampaignDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.planning.create(dto, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('surveys.read')
|
||||
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.planning.getById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('surveys.manage')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: UpdateSurveyCampaignDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.planning.update(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@RequirePermissions('surveys.manage')
|
||||
changeStatus(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ChangeSurveyCampaignStatusDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.planning.changeCampaignStatus(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/targets')
|
||||
@RequirePermissions('surveys.manage')
|
||||
addTarget(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: AddSurveyTargetDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.planning.addTarget(id, dto, principal, request);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('survey-campaign-targets')
|
||||
export class SurveyCampaignTargetsController {
|
||||
constructor(private readonly planning: SurveyPlanningService) {}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('surveys.manage')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: UpdateSurveyTargetDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.planning.updateTarget(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Put(':id/assignment')
|
||||
@RequirePermissions('surveys.assign')
|
||||
assign(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: AssignSurveyTargetDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.planning.assignTarget(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@RequirePermissions('surveys.execute')
|
||||
changeStatus(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ChangeSurveyTargetStatusDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.planning.changeTargetStatus(id, dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import {
|
||||
SurveyCampaignsController,
|
||||
SurveyCampaignTargetsController,
|
||||
} from './survey-campaigns.controller';
|
||||
import { SurveyPlanningService } from './survey-planning.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [SurveyCampaignsController, SurveyCampaignTargetsController],
|
||||
providers: [SurveyPlanningService],
|
||||
})
|
||||
export class SurveyPlanningModule {}
|
||||
@@ -1,791 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import {
|
||||
AuditAction,
|
||||
SurveyCampaign,
|
||||
SurveyCampaignStatus,
|
||||
SurveyCampaignTarget,
|
||||
SurveyTargetStatus,
|
||||
} from '../database/entities';
|
||||
import type { AddSurveyTargetDto } from './dto/add-survey-target.dto';
|
||||
import type { AssignSurveyTargetDto } from './dto/assign-survey-target.dto';
|
||||
import type { ChangeSurveyCampaignStatusDto } from './dto/change-survey-campaign-status.dto';
|
||||
import type { ChangeSurveyTargetStatusDto } from './dto/change-survey-target-status.dto';
|
||||
import type { CreateSurveyCampaignDto } from './dto/create-survey-campaign.dto';
|
||||
import type { ListSurveyCampaignsQueryDto } from './dto/list-survey-campaigns-query.dto';
|
||||
import type { UpdateSurveyCampaignDto } from './dto/update-survey-campaign.dto';
|
||||
import type { UpdateSurveyTargetDto } from './dto/update-survey-target.dto';
|
||||
|
||||
export interface PersonSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
interface AssetSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SurveyCampaignListItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: SurveyCampaignStatus;
|
||||
plannedStartAt: Date | null;
|
||||
plannedEndAt: Date | null;
|
||||
scopeAsset: AssetSummary | null;
|
||||
coordinator: PersonSummary | null;
|
||||
targetCount: number;
|
||||
pendingCount: number;
|
||||
inProgressCount: number;
|
||||
submittedCount: number;
|
||||
completedCount: number;
|
||||
skippedCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SurveyTargetView {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
asset: AssetSummary & { typeName: string };
|
||||
assignedUser: PersonSummary | null;
|
||||
status: SurveyTargetStatus;
|
||||
dueAt: Date | null;
|
||||
instructions: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SurveyCampaignView extends SurveyCampaignListItem {
|
||||
targets: SurveyTargetView[];
|
||||
}
|
||||
|
||||
const campaignTransitions: Record<SurveyCampaignStatus, SurveyCampaignStatus[]> = {
|
||||
[SurveyCampaignStatus.DRAFT]: [SurveyCampaignStatus.PLANNED, SurveyCampaignStatus.CANCELLED],
|
||||
[SurveyCampaignStatus.PLANNED]: [
|
||||
SurveyCampaignStatus.DRAFT,
|
||||
SurveyCampaignStatus.IN_PROGRESS,
|
||||
SurveyCampaignStatus.CANCELLED,
|
||||
],
|
||||
[SurveyCampaignStatus.IN_PROGRESS]: [
|
||||
SurveyCampaignStatus.COMPLETED,
|
||||
SurveyCampaignStatus.CANCELLED,
|
||||
],
|
||||
[SurveyCampaignStatus.COMPLETED]: [],
|
||||
[SurveyCampaignStatus.CANCELLED]: [],
|
||||
};
|
||||
|
||||
const targetTransitions: Record<SurveyTargetStatus, SurveyTargetStatus[]> = {
|
||||
[SurveyTargetStatus.PENDING]: [SurveyTargetStatus.IN_PROGRESS, SurveyTargetStatus.SKIPPED],
|
||||
[SurveyTargetStatus.IN_PROGRESS]: [
|
||||
SurveyTargetStatus.PENDING,
|
||||
SurveyTargetStatus.SKIPPED,
|
||||
],
|
||||
[SurveyTargetStatus.SUBMITTED]: [],
|
||||
[SurveyTargetStatus.COMPLETED]: [],
|
||||
[SurveyTargetStatus.SKIPPED]: [],
|
||||
};
|
||||
|
||||
function campaignNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'SURVEY_CAMPAIGN_NOT_FOUND',
|
||||
message: 'Campaña de relevamiento no encontrada',
|
||||
});
|
||||
}
|
||||
|
||||
function targetNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'SURVEY_TARGET_NOT_FOUND',
|
||||
message: 'Objetivo de relevamiento no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
function dateValue(value: string | null | undefined): Date | null {
|
||||
return value ? new Date(value) : null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SurveyPlanningService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async list(query: ListSurveyCampaignsQueryDto) {
|
||||
const conditions: string[] = [];
|
||||
const parameters: unknown[] = [];
|
||||
const addParameter = (value: unknown) => {
|
||||
parameters.push(value);
|
||||
return `$${parameters.length}`;
|
||||
};
|
||||
|
||||
if (query.search?.trim()) {
|
||||
const search = addParameter(`%${query.search.trim()}%`);
|
||||
conditions.push(`(campaign.code ILIKE ${search} OR campaign.name ILIKE ${search})`);
|
||||
}
|
||||
if (query.status) conditions.push(`campaign.status = ${addParameter(query.status)}`);
|
||||
if (query.coordinatorUserId) {
|
||||
conditions.push(`campaign.coordinator_user_id = ${addParameter(query.coordinatorUserId)}`);
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const [countRow] = (await this.dataSource.query(
|
||||
`SELECT COUNT(*)::integer AS total FROM survey_campaigns campaign ${where}`,
|
||||
parameters,
|
||||
)) as Array<{ total: number }>;
|
||||
const total = Number(countRow?.total ?? 0);
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
const limitParameter = addParameter(query.pageSize);
|
||||
const offsetParameter = addParameter(offset);
|
||||
const data = (await this.dataSource.query(
|
||||
`${this.campaignSelect(where)}
|
||||
ORDER BY campaign.updated_at DESC, campaign.code ASC
|
||||
LIMIT ${limitParameter} OFFSET ${offsetParameter}`,
|
||||
parameters,
|
||||
)) as SurveyCampaignListItem[];
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listAssignees(): Promise<{ data: PersonSummary[] }> {
|
||||
const data = (await this.dataSource.query(`
|
||||
SELECT DISTINCT
|
||||
user_account.id,
|
||||
user_account.username,
|
||||
user_account.first_name AS "firstName",
|
||||
user_account.last_name AS "lastName"
|
||||
FROM users user_account
|
||||
INNER JOIN user_roles user_role ON user_role.user_id = user_account.id
|
||||
INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id
|
||||
INNER JOIN permissions permission ON permission.id = role_permission.permission_id
|
||||
WHERE user_account.status = 'ACTIVE'
|
||||
AND permission.code = 'surveys.execute'
|
||||
ORDER BY user_account.last_name, user_account.first_name, user_account.username
|
||||
`)) as PersonSummary[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<SurveyCampaignView> {
|
||||
return this.dataSource.transaction((manager) => this.loadCampaignView(manager, id));
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateSurveyCampaignDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyCampaignView> {
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
await this.validateDates(dto.plannedStartAt ?? null, dto.plannedEndAt ?? null);
|
||||
await this.requireAsset(manager, dto.scopeAssetId ?? null);
|
||||
await this.requireActiveUser(manager, dto.coordinatorUserId ?? null);
|
||||
const campaign = manager.getRepository(SurveyCampaign).create({
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
status: SurveyCampaignStatus.DRAFT,
|
||||
plannedStartAt: dateValue(dto.plannedStartAt),
|
||||
plannedEndAt: dateValue(dto.plannedEndAt),
|
||||
scopeAssetId: dto.scopeAssetId ?? null,
|
||||
coordinatorUserId: dto.coordinatorUserId ?? null,
|
||||
createdBy: principal.userId,
|
||||
updatedBy: principal.userId,
|
||||
});
|
||||
await manager.getRepository(SurveyCampaign).save(campaign);
|
||||
const created = await this.loadCampaignView(manager, campaign.id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.SURVEY_CAMPAIGN_CREATED,
|
||||
entityType: 'survey_campaign',
|
||||
entityId: campaign.id,
|
||||
afterData: this.auditCampaign(created),
|
||||
}, manager);
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw this.campaignConflict();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateSurveyCampaignDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyCampaignView> {
|
||||
if (Object.keys(dto).length === 0) {
|
||||
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||
}
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
const campaign = await this.lockCampaign(manager, id);
|
||||
this.assertCampaignEditable(campaign);
|
||||
const before = await this.loadCampaignView(manager, id);
|
||||
const nextStart = dto.plannedStartAt === undefined
|
||||
? campaign.plannedStartAt
|
||||
: dateValue(dto.plannedStartAt);
|
||||
const nextEnd = dto.plannedEndAt === undefined
|
||||
? campaign.plannedEndAt
|
||||
: dateValue(dto.plannedEndAt);
|
||||
await this.validateDates(nextStart, nextEnd);
|
||||
if (dto.scopeAssetId !== undefined) {
|
||||
await this.requireAsset(manager, dto.scopeAssetId);
|
||||
await this.assertCampaignTargetsInScope(manager, id, dto.scopeAssetId);
|
||||
}
|
||||
if (dto.coordinatorUserId !== undefined) {
|
||||
await this.requireActiveUser(manager, dto.coordinatorUserId);
|
||||
}
|
||||
|
||||
if (dto.code !== undefined) campaign.code = dto.code;
|
||||
if (dto.name !== undefined) campaign.name = dto.name;
|
||||
if (dto.description !== undefined) campaign.description = dto.description;
|
||||
if (dto.plannedStartAt !== undefined) campaign.plannedStartAt = nextStart;
|
||||
if (dto.plannedEndAt !== undefined) campaign.plannedEndAt = nextEnd;
|
||||
if (dto.scopeAssetId !== undefined) campaign.scopeAssetId = dto.scopeAssetId;
|
||||
if (dto.coordinatorUserId !== undefined) {
|
||||
campaign.coordinatorUserId = dto.coordinatorUserId;
|
||||
}
|
||||
campaign.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaign).save(campaign);
|
||||
const updated = await this.loadCampaignView(manager, id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.SURVEY_CAMPAIGN_UPDATED,
|
||||
entityType: 'survey_campaign',
|
||||
entityId: id,
|
||||
beforeData: this.auditCampaign(before),
|
||||
afterData: this.auditCampaign(updated),
|
||||
}, manager);
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw this.campaignConflict();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async changeCampaignStatus(
|
||||
id: string,
|
||||
dto: ChangeSurveyCampaignStatusDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyCampaignView> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const campaign = await this.lockCampaign(manager, id);
|
||||
if (campaign.status === dto.status) return this.loadCampaignView(manager, id);
|
||||
if (!campaignTransitions[campaign.status].includes(dto.status)) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_CAMPAIGN_INVALID_TRANSITION',
|
||||
message: `No se puede pasar la campaña de ${campaign.status} a ${dto.status}`,
|
||||
});
|
||||
}
|
||||
const [counts] = (await manager.query(`
|
||||
SELECT
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE status IN ('PENDING', 'IN_PROGRESS', 'SUBMITTED'))::integer AS outstanding
|
||||
FROM survey_campaign_targets WHERE campaign_id = $1
|
||||
`, [id])) as Array<{ total: number; outstanding: number }>;
|
||||
if (dto.status === SurveyCampaignStatus.IN_PROGRESS && Number(counts?.total ?? 0) === 0) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_CAMPAIGN_EMPTY',
|
||||
message: 'Agregá al menos un activo antes de iniciar la campaña',
|
||||
});
|
||||
}
|
||||
if (
|
||||
dto.status === SurveyCampaignStatus.COMPLETED &&
|
||||
Number(counts?.outstanding ?? 0) > 0
|
||||
) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_CAMPAIGN_HAS_OPEN_TARGETS',
|
||||
message: 'Todos los objetivos deben estar completados u omitidos',
|
||||
});
|
||||
}
|
||||
const beforeStatus = campaign.status;
|
||||
campaign.status = dto.status;
|
||||
campaign.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaign).save(campaign);
|
||||
const updated = await this.loadCampaignView(manager, id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.SURVEY_CAMPAIGN_STATUS_CHANGED,
|
||||
entityType: 'survey_campaign',
|
||||
entityId: id,
|
||||
beforeData: { status: beforeStatus },
|
||||
afterData: { status: dto.status },
|
||||
}, manager);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async addTarget(
|
||||
campaignId: string,
|
||||
dto: AddSurveyTargetDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyCampaignView> {
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
const campaign = await this.lockCampaign(manager, campaignId);
|
||||
this.assertCampaignEditable(campaign);
|
||||
await this.requireAsset(manager, dto.assetId);
|
||||
await this.assertAssetInScope(manager, dto.assetId, campaign.scopeAssetId);
|
||||
await this.requireAssignee(manager, dto.assignedUserId ?? null);
|
||||
const target = manager.getRepository(SurveyCampaignTarget).create({
|
||||
campaignId,
|
||||
assetId: dto.assetId,
|
||||
assignedUserId: dto.assignedUserId ?? null,
|
||||
status: SurveyTargetStatus.PENDING,
|
||||
dueAt: dateValue(dto.dueAt),
|
||||
instructions: dto.instructions ?? null,
|
||||
createdBy: principal.userId,
|
||||
updatedBy: principal.userId,
|
||||
});
|
||||
await manager.getRepository(SurveyCampaignTarget).save(target);
|
||||
campaign.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaign).save(campaign);
|
||||
const targetView = await this.loadTargetView(manager, target.id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.SURVEY_TARGET_ADDED,
|
||||
entityType: 'survey_campaign_target',
|
||||
entityId: target.id,
|
||||
afterData: targetView as unknown as Record<string, unknown>,
|
||||
metadata: { campaignId },
|
||||
}, manager);
|
||||
return this.loadCampaignView(manager, campaignId);
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_TARGET_ALREADY_EXISTS',
|
||||
message: 'El activo ya forma parte de esta campaña',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateTarget(
|
||||
id: string,
|
||||
dto: UpdateSurveyTargetDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyCampaignView> {
|
||||
if (Object.keys(dto).length === 0) {
|
||||
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||
}
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const target = await this.lockTarget(manager, id);
|
||||
const campaign = await this.lockCampaign(manager, target.campaignId);
|
||||
this.assertCampaignEditable(campaign);
|
||||
this.assertTargetPlanningEditable(target);
|
||||
const before = await this.loadTargetView(manager, id);
|
||||
if (dto.dueAt !== undefined) target.dueAt = dateValue(dto.dueAt);
|
||||
if (dto.instructions !== undefined) target.instructions = dto.instructions;
|
||||
target.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaignTarget).save(target);
|
||||
campaign.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaign).save(campaign);
|
||||
const updated = await this.loadTargetView(manager, id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.SURVEY_TARGET_UPDATED,
|
||||
entityType: 'survey_campaign_target',
|
||||
entityId: id,
|
||||
beforeData: before as unknown as Record<string, unknown>,
|
||||
afterData: updated as unknown as Record<string, unknown>,
|
||||
metadata: { campaignId: target.campaignId },
|
||||
}, manager);
|
||||
return this.loadCampaignView(manager, target.campaignId);
|
||||
});
|
||||
}
|
||||
|
||||
async assignTarget(
|
||||
id: string,
|
||||
dto: AssignSurveyTargetDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyCampaignView> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const target = await this.lockTarget(manager, id);
|
||||
const campaign = await this.lockCampaign(manager, target.campaignId);
|
||||
this.assertCampaignEditable(campaign);
|
||||
this.assertTargetPlanningEditable(target);
|
||||
await this.requireAssignee(manager, dto.assignedUserId);
|
||||
const beforeUserId = target.assignedUserId;
|
||||
if (beforeUserId === dto.assignedUserId) {
|
||||
return this.loadCampaignView(manager, target.campaignId);
|
||||
}
|
||||
target.assignedUserId = dto.assignedUserId;
|
||||
target.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaignTarget).save(target);
|
||||
campaign.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaign).save(campaign);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.SURVEY_TARGET_ASSIGNED,
|
||||
entityType: 'survey_campaign_target',
|
||||
entityId: id,
|
||||
beforeData: { assignedUserId: beforeUserId },
|
||||
afterData: { assignedUserId: dto.assignedUserId },
|
||||
metadata: { campaignId: target.campaignId },
|
||||
}, manager);
|
||||
return this.loadCampaignView(manager, target.campaignId);
|
||||
});
|
||||
}
|
||||
|
||||
async changeTargetStatus(
|
||||
id: string,
|
||||
dto: ChangeSurveyTargetStatusDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyCampaignView> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const target = await this.lockTarget(manager, id);
|
||||
const campaign = await this.lockCampaign(manager, target.campaignId);
|
||||
const planningOmission =
|
||||
principal.permissions.includes('surveys.manage') &&
|
||||
[SurveyCampaignStatus.DRAFT, SurveyCampaignStatus.PLANNED].includes(campaign.status) &&
|
||||
(
|
||||
(target.status === SurveyTargetStatus.PENDING && dto.status === SurveyTargetStatus.SKIPPED) ||
|
||||
(target.status === SurveyTargetStatus.SKIPPED && dto.status === SurveyTargetStatus.PENDING)
|
||||
);
|
||||
if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS && !planningOmission) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS',
|
||||
message: 'La campaña debe estar en curso para registrar avance; durante la planificación sólo se puede omitir o restaurar un objetivo',
|
||||
});
|
||||
}
|
||||
if (
|
||||
!principal.permissions.includes('surveys.manage') &&
|
||||
target.assignedUserId !== principal.userId
|
||||
) {
|
||||
throw new ForbiddenException({
|
||||
code: 'SURVEY_TARGET_NOT_ASSIGNED',
|
||||
message: 'El objetivo no está asignado al usuario actual',
|
||||
});
|
||||
}
|
||||
if (target.status === dto.status) return this.loadCampaignView(manager, target.campaignId);
|
||||
if (!planningOmission && !targetTransitions[target.status].includes(dto.status)) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_TARGET_INVALID_TRANSITION',
|
||||
message: `No se puede pasar el objetivo de ${target.status} a ${dto.status}`,
|
||||
});
|
||||
}
|
||||
const beforeStatus = target.status;
|
||||
target.status = dto.status;
|
||||
target.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaignTarget).save(target);
|
||||
campaign.updatedBy = principal.userId;
|
||||
await manager.getRepository(SurveyCampaign).save(campaign);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.SURVEY_TARGET_STATUS_CHANGED,
|
||||
entityType: 'survey_campaign_target',
|
||||
entityId: id,
|
||||
beforeData: { status: beforeStatus },
|
||||
afterData: { status: dto.status },
|
||||
metadata: { campaignId: target.campaignId },
|
||||
}, manager);
|
||||
return this.loadCampaignView(manager, target.campaignId);
|
||||
});
|
||||
}
|
||||
|
||||
private campaignSelect(where: string): string {
|
||||
return `
|
||||
SELECT
|
||||
campaign.id,
|
||||
campaign.code,
|
||||
campaign.name,
|
||||
campaign.description,
|
||||
campaign.status,
|
||||
campaign.planned_start_at AS "plannedStartAt",
|
||||
campaign.planned_end_at AS "plannedEndAt",
|
||||
CASE WHEN scope_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', scope_asset.id, 'code', scope_asset.code, 'name', scope_asset.name
|
||||
) END AS "scopeAsset",
|
||||
CASE WHEN coordinator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', coordinator.id,
|
||||
'username', coordinator.username,
|
||||
'firstName', coordinator.first_name,
|
||||
'lastName', coordinator.last_name
|
||||
) END AS coordinator,
|
||||
COALESCE(target_counts.total, 0)::integer AS "targetCount",
|
||||
COALESCE(target_counts.pending, 0)::integer AS "pendingCount",
|
||||
COALESCE(target_counts.in_progress, 0)::integer AS "inProgressCount",
|
||||
COALESCE(target_counts.submitted, 0)::integer AS "submittedCount",
|
||||
COALESCE(target_counts.completed, 0)::integer AS "completedCount",
|
||||
COALESCE(target_counts.skipped, 0)::integer AS "skippedCount",
|
||||
campaign.created_at AS "createdAt",
|
||||
campaign.updated_at AS "updatedAt"
|
||||
FROM survey_campaigns campaign
|
||||
LEFT JOIN assets scope_asset ON scope_asset.id = campaign.scope_asset_id
|
||||
LEFT JOIN users coordinator ON coordinator.id = campaign.coordinator_user_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE target.status = 'PENDING') AS pending,
|
||||
COUNT(*) FILTER (WHERE target.status = 'IN_PROGRESS') AS in_progress,
|
||||
COUNT(*) FILTER (WHERE target.status = 'SUBMITTED') AS submitted,
|
||||
COUNT(*) FILTER (WHERE target.status = 'COMPLETED') AS completed,
|
||||
COUNT(*) FILTER (WHERE target.status = 'SKIPPED') AS skipped
|
||||
FROM survey_campaign_targets target
|
||||
WHERE target.campaign_id = campaign.id
|
||||
) target_counts ON true
|
||||
${where}
|
||||
`;
|
||||
}
|
||||
|
||||
private async loadCampaignView(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
): Promise<SurveyCampaignView> {
|
||||
const [campaign] = (await manager.query(
|
||||
this.campaignSelect('WHERE campaign.id = $1'),
|
||||
[id],
|
||||
)) as SurveyCampaignListItem[];
|
||||
if (!campaign) throw campaignNotFound();
|
||||
const targets = (await manager.query(`
|
||||
${this.targetSelect()}
|
||||
WHERE target.campaign_id = $1
|
||||
ORDER BY
|
||||
CASE target.status
|
||||
WHEN 'SUBMITTED' THEN 1 WHEN 'IN_PROGRESS' THEN 2 WHEN 'PENDING' THEN 3
|
||||
WHEN 'COMPLETED' THEN 4 ELSE 5
|
||||
END,
|
||||
target.due_at NULLS LAST,
|
||||
asset.code
|
||||
`, [id])) as SurveyTargetView[];
|
||||
return { ...campaign, targets };
|
||||
}
|
||||
|
||||
private async loadTargetView(manager: EntityManager, id: string): Promise<SurveyTargetView> {
|
||||
const [target] = (await manager.query(`
|
||||
${this.targetSelect()} WHERE target.id = $1
|
||||
`, [id])) as SurveyTargetView[];
|
||||
if (!target) throw targetNotFound();
|
||||
return target;
|
||||
}
|
||||
|
||||
private targetSelect(): string {
|
||||
return `
|
||||
SELECT
|
||||
target.id,
|
||||
target.campaign_id AS "campaignId",
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', asset.id,
|
||||
'code', asset.code,
|
||||
'name', asset.name,
|
||||
'typeName', asset_type.name
|
||||
) AS asset,
|
||||
CASE WHEN assignee.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', assignee.id,
|
||||
'username', assignee.username,
|
||||
'firstName', assignee.first_name,
|
||||
'lastName', assignee.last_name
|
||||
) END AS "assignedUser",
|
||||
target.status,
|
||||
target.due_at AS "dueAt",
|
||||
target.instructions,
|
||||
target.created_at AS "createdAt",
|
||||
target.updated_at AS "updatedAt"
|
||||
FROM survey_campaign_targets target
|
||||
INNER JOIN assets asset ON asset.id = target.asset_id
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
LEFT JOIN users assignee ON assignee.id = target.assigned_user_id
|
||||
`;
|
||||
}
|
||||
|
||||
private async lockCampaign(manager: EntityManager, id: string): Promise<SurveyCampaign> {
|
||||
const campaign = await manager.getRepository(SurveyCampaign)
|
||||
.createQueryBuilder('campaign')
|
||||
.where('campaign.id = :id', { id })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!campaign) throw campaignNotFound();
|
||||
return campaign;
|
||||
}
|
||||
|
||||
private async lockTarget(manager: EntityManager, id: string): Promise<SurveyCampaignTarget> {
|
||||
const target = await manager.getRepository(SurveyCampaignTarget)
|
||||
.createQueryBuilder('target')
|
||||
.where('target.id = :id', { id })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!target) throw targetNotFound();
|
||||
return target;
|
||||
}
|
||||
|
||||
private async requireAsset(manager: EntityManager, id: string | null): Promise<void> {
|
||||
if (!id) return;
|
||||
const rows = await manager.query(`SELECT 1 FROM assets WHERE id = $1`, [id]) as unknown[];
|
||||
if (rows.length === 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_ASSET_NOT_FOUND',
|
||||
message: 'El activo seleccionado no existe',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async requireActiveUser(manager: EntityManager, id: string | null): Promise<void> {
|
||||
if (!id) return;
|
||||
const rows = await manager.query(
|
||||
`SELECT 1 FROM users WHERE id = $1 AND status = 'ACTIVE'`,
|
||||
[id],
|
||||
) as unknown[];
|
||||
if (rows.length === 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_USER_NOT_ACTIVE',
|
||||
message: 'El usuario seleccionado no existe o está inactivo',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async requireAssignee(manager: EntityManager, id: string | null): Promise<void> {
|
||||
if (!id) return;
|
||||
const rows = await manager.query(`
|
||||
SELECT 1
|
||||
FROM users user_account
|
||||
WHERE user_account.id = $1
|
||||
AND user_account.status = 'ACTIVE'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_roles user_role
|
||||
INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id
|
||||
INNER JOIN permissions permission ON permission.id = role_permission.permission_id
|
||||
WHERE user_role.user_id = user_account.id
|
||||
AND permission.code = 'surveys.execute'
|
||||
)
|
||||
`, [id]) as unknown[];
|
||||
if (rows.length === 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_ASSIGNEE_INVALID',
|
||||
message: 'El responsable debe ser un usuario activo con permiso de ejecución',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async assertAssetInScope(
|
||||
manager: EntityManager,
|
||||
assetId: string,
|
||||
scopeAssetId: string | null,
|
||||
): Promise<void> {
|
||||
if (!scopeAssetId) return;
|
||||
const rows = await manager.query(`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id, parent_id FROM assets WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT parent.id, parent.parent_id
|
||||
FROM assets parent
|
||||
INNER JOIN ancestors child ON child.parent_id = parent.id
|
||||
)
|
||||
SELECT 1 FROM ancestors WHERE id = $2
|
||||
`, [assetId, scopeAssetId]) as unknown[];
|
||||
if (rows.length === 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_TARGET_OUTSIDE_SCOPE',
|
||||
message: 'El activo no pertenece al alcance jerárquico de la campaña',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCampaignTargetsInScope(
|
||||
manager: EntityManager,
|
||||
campaignId: string,
|
||||
scopeAssetId: string | null,
|
||||
): Promise<void> {
|
||||
if (!scopeAssetId) return;
|
||||
const [row] = (await manager.query(`
|
||||
SELECT COUNT(*)::integer AS outside
|
||||
FROM survey_campaign_targets target
|
||||
WHERE target.campaign_id = $1
|
||||
AND NOT EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id, parent_id FROM assets WHERE id = target.asset_id
|
||||
UNION ALL
|
||||
SELECT parent.id, parent.parent_id
|
||||
FROM assets parent
|
||||
INNER JOIN ancestors child ON child.parent_id = parent.id
|
||||
)
|
||||
SELECT 1 FROM ancestors WHERE id = $2
|
||||
)
|
||||
`, [campaignId, scopeAssetId])) as Array<{ outside: number }>;
|
||||
if (Number(row?.outside ?? 0) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_SCOPE_EXCLUDES_TARGETS',
|
||||
message: 'El nuevo alcance dejaría objetivos existentes fuera de la campaña',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async validateDates(
|
||||
start: string | Date | null,
|
||||
end: string | Date | null,
|
||||
): Promise<void> {
|
||||
if (start && end && new Date(end).getTime() < new Date(start).getTime()) {
|
||||
throw new BadRequestException({
|
||||
code: 'SURVEY_INVALID_DATES',
|
||||
message: 'La fecha de finalización no puede ser anterior al inicio',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private assertCampaignEditable(campaign: SurveyCampaign): void {
|
||||
if (
|
||||
campaign.status === SurveyCampaignStatus.COMPLETED ||
|
||||
campaign.status === SurveyCampaignStatus.CANCELLED
|
||||
) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_CAMPAIGN_CLOSED',
|
||||
message: 'Una campaña completada o cancelada ya no puede modificarse',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private assertTargetPlanningEditable(target: SurveyCampaignTarget): void {
|
||||
if (
|
||||
target.status === SurveyTargetStatus.SUBMITTED ||
|
||||
target.status === SurveyTargetStatus.COMPLETED
|
||||
) {
|
||||
throw new ConflictException({
|
||||
code: 'SURVEY_TARGET_PLANNING_LOCKED',
|
||||
message: 'Un objetivo enviado o completado ya no puede replanificarse',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private campaignConflict(): ConflictException {
|
||||
return new ConflictException({
|
||||
code: 'SURVEY_CAMPAIGN_CODE_EXISTS',
|
||||
message: 'Ya existe una campaña con ese código',
|
||||
});
|
||||
}
|
||||
|
||||
private auditCampaign(campaign: SurveyCampaignView): Record<string, unknown> {
|
||||
const { targets: _targets, ...summary } = campaign;
|
||||
return summary as unknown as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export const API_VERSION = '0.24.0-1';
|
||||
export const API_PHASE = 'F3.1';
|
||||
export const API_VERSION = '0.25.0-1';
|
||||
export const API_PHASE = 'F3.2';
|
||||
|
||||
@@ -52,15 +52,3 @@ test('F3.1 fuerza Área → Yacimiento → Instalación → Subinstalación en s
|
||||
assert.match(service, /SUBINSTALACION: 'instalacion'/);
|
||||
assert.match(service, /INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID/);
|
||||
});
|
||||
|
||||
test('F3.1 retira Relevamientos del dashboard y usa alta guiada de cuatro niveles', () => {
|
||||
const layout = readFileSync('../web-v2/src/layout/AppLayout.tsx', 'utf8');
|
||||
const app = readFileSync('../web-v2/src/app/App.tsx', 'utf8');
|
||||
const page = readFileSync('../web-v2/src/pages/InventoryCreatePage.tsx', 'utf8');
|
||||
assert.doesNotMatch(layout, /label: 'Relevamientos'/);
|
||||
assert.match(app, /path="\/relevamiento\/\*" element={<Navigate to="\/inventarios" replace \/>}/);
|
||||
assert.match(app, /path="\/inventarios\/nuevo" element={<InventoryCreatePage \/>}/);
|
||||
for (const kind of ['AREA', 'YACIMIENTO', 'INSTALACION', 'SUBINSTALACION']) assert.match(page, new RegExp(`kind: '${kind}'`));
|
||||
assert.doesNotMatch(page, /kind: 'EQUIPO'/);
|
||||
assert.match(page, /getInventoryFamilyFindings/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const read = (path: string) => readFileSync(path, 'utf8');
|
||||
|
||||
test('F3.2 mantiene múltiples Actas con un solo borrador simultáneo por inspección', () => {
|
||||
const migration = read('src/database/migrations/1789495200000-phase-f1-1-multi-act-inspections.ts');
|
||||
assert.match(migration, /DROP CONSTRAINT IF EXISTS uq_inspection_acts_visit/);
|
||||
assert.match(migration, /CREATE UNIQUE INDEX uq_inspection_acts_one_draft_per_visit/);
|
||||
assert.match(migration, /WHERE status = 'DRAFT'/);
|
||||
});
|
||||
|
||||
test('F3.2 permite seleccionar el Acta explícitamente al trabajar Hallazgos desde la APK', () => {
|
||||
const controller = read('src/inspection-visits/field-findings.controller.ts');
|
||||
const service = read('src/inspection-visits/field-findings.service.ts');
|
||||
const dto = read('src/inspection-visits/dto/create-field-finding.dto.ts');
|
||||
assert.match(controller, /FieldFindingActQueryDto/);
|
||||
assert.match(controller, /query\.actId/);
|
||||
assert.match(dto, /actId\?: string/);
|
||||
assert.match(service, /actSelectionMode: actId \? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT'/);
|
||||
assert.match(service, /WHERE id=\$1::uuid AND visit_id=\$2::uuid/);
|
||||
assert.match(service, /FIELD_FINDING_ACT_NOT_DRAFT/);
|
||||
assert.match(service, /FIELD_FINDING_ASSET_NOT_IN_ACT/);
|
||||
});
|
||||
|
||||
test('F3.2 conserva el cierre documental inmutable de cada Acta', () => {
|
||||
const closing = read('src/inspection-closing/inspection-closing.service.ts');
|
||||
assert.match(closing, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/);
|
||||
assert.match(closing, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/);
|
||||
assert.match(closing, /finalSha256 = sha256CanonicalJson\(finalSnapshot\)/);
|
||||
assert.match(closing, /status = 'CLOSED'/);
|
||||
assert.match(closing, /ensureFrozenReport/);
|
||||
assert.match(closing, /ensureWordForAct/);
|
||||
});
|
||||
|
||||
test('F3.2 cierra la inspección sólo cuando no quedan borradores ni Actas sin firma de inspector', () => {
|
||||
const visits = read('src/inspection-visits/inspection-visits.service.ts');
|
||||
assert.match(visits, /INSPECTION_VISIT_DRAFT_ACTS_PENDING/);
|
||||
assert.match(visits, /INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING/);
|
||||
assert.match(visits, /actsMayCompleteCompanySignatureLater: true/);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import 'reflect-metadata';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
|
||||
import { InspectionActLifecycleController } from '../../src/inspection-closing/inspection-act-lifecycle.controller';
|
||||
import {
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
@@ -12,11 +13,12 @@ function permissionFor(controller: object, method: string): string[] {
|
||||
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
|
||||
}
|
||||
|
||||
test('D5 closing endpoints separate read, prepare, sign and close permissions', () => {
|
||||
test('F4 closing endpoints separate responsible capture, immutable lock, signing and sealing', () => {
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'get'), ['inspection_closure.read']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'responsible'), ['inspection_closure.prepare']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'ready'), ['inspection_closure.prepare']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'reopen'), ['inspection_closure.prepare']);
|
||||
assert.deepEqual(permissionFor(InspectionActLifecycleController.prototype, 'lock'), ['inspection_closure.prepare']);
|
||||
assert.equal('ready' in InspectionClosingController.prototype, false);
|
||||
assert.equal('reopen' in InspectionClosingController.prototype, false);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'inspectorSignature'), ['inspection_closure.sign']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companySignature'), ['inspection_closure.sign']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companyOutcome'), ['inspection_closure.sign']);
|
||||
|
||||
@@ -10,7 +10,7 @@ function permissionFor(method: string): string[] {
|
||||
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
|
||||
}
|
||||
|
||||
test('inspection visit endpoints separate reading, planning, assignment and execution', () => {
|
||||
test('inspection visit endpoints separate reading, planning, assignment and explicit execution', () => {
|
||||
assert.deepEqual(permissionFor('list'), ['inspections.read']);
|
||||
assert.deepEqual(permissionFor('get'), ['inspections.read']);
|
||||
assert.deepEqual(permissionFor('assignees'), ['inspections.assign']);
|
||||
@@ -18,7 +18,7 @@ test('inspection visit endpoints separate reading, planning, assignment and exec
|
||||
assert.deepEqual(permissionFor('update'), ['inspections.manage']);
|
||||
assert.deepEqual(permissionFor('replaceAssets'), ['inspections.manage']);
|
||||
assert.deepEqual(permissionFor('replaceTeam'), ['inspections.assign']);
|
||||
assert.deepEqual(permissionFor('changeStatus'), ['inspections.manage']);
|
||||
assert.equal('changeStatus' in InspectionVisitsController.prototype, false);
|
||||
assert.deepEqual(permissionFor('start'), ['inspections.execute']);
|
||||
assert.deepEqual(permissionFor('close'), ['inspections.execute']);
|
||||
});
|
||||
|
||||
@@ -6,11 +6,13 @@ import test from 'node:test';
|
||||
const root = process.cwd();
|
||||
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
|
||||
test('D5.6.4 generates inspection codes server-side with an annual transactional sequence', () => {
|
||||
test('F4 keeps server-side annual numbering but emits the institutional INSP-date format', () => {
|
||||
const generator = read('src/inspection-visits/inspection-visit-code.ts');
|
||||
assert.match(generator, /INS-\$\{year\}-\$\{String\(sequence\)\.padStart\(6, '0'\)\}/);
|
||||
assert.match(generator, /INSP-\$\{String\(sequence\)\.padStart\(5, '0'\)\}-\$\{datePart\}/);
|
||||
assert.match(generator, /DD-MM-YY/);
|
||||
assert.match(generator, /pg_advisory_xact_lock/);
|
||||
assert.match(generator, /America\/Argentina\/Mendoza/);
|
||||
assert.match(generator, /INS-\$\{year\}-%/);
|
||||
});
|
||||
|
||||
test('D5.6.4 creates one inspection from Area Operator start and lead inspector without manual title or end date', () => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
|
||||
test('F4 locks an Act without requiring an artificial Finding', () => {
|
||||
const service = read('src/inspection-closing/inspection-act-lifecycle.service.ts');
|
||||
assert.doesNotMatch(service, /INSPECTION_ACT_FINDING_REQUIRED/);
|
||||
assert.doesNotMatch(service, /COUNT\(\*\).*inspection_findings[\s\S]*< 1/);
|
||||
assert.match(service, /requireVerificationResults/);
|
||||
});
|
||||
|
||||
test('F4 freezes urgency and the exact deadline policy at lock time', () => {
|
||||
const service = read('src/inspection-closing/inspection-act-lifecycle.service.ts');
|
||||
assert.match(service, /snapshotForLock/);
|
||||
assert.match(service, /deadline_policy_snapshot/);
|
||||
assert.match(service, /locked_sha256/);
|
||||
assert.match(service, /LOCKED_PENDING_SIGNATURE/);
|
||||
});
|
||||
|
||||
test('F4 removes the reopen route once an Act has been blocked', () => {
|
||||
const controller = read('src/inspection-closing/inspection-closing.controller.ts');
|
||||
assert.doesNotMatch(controller, /@Post\('reopen'\)/);
|
||||
assert.doesNotMatch(controller, /@Post\('ready'\)/);
|
||||
const lifecycle = read('src/inspection-closing/inspection-act-lifecycle.controller.ts');
|
||||
assert.match(lifecycle, /@Post\('lock'\)/);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
|
||||
test('F4 grants deadline administration to system admin and the current JEFE-equivalent supervisor role', () => {
|
||||
const migration = read('src/database/migrations/1790038800000-phase-f4-deadline-administration.ts');
|
||||
assert.match(migration, /inspection_deadlines\.manage/);
|
||||
assert.match(migration, /\('admin', 'inspection_deadlines\.manage'\)/);
|
||||
assert.match(migration, /\('supervisor', 'inspection_deadlines\.manage'\)/);
|
||||
assert.doesNotMatch(migration, /\('inspector', 'inspection_deadlines\.manage'\)/);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'reflect-metadata';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
|
||||
import { InspectionDeadlineDayType } from '../../src/database/entities';
|
||||
import { UpdateInspectionDeadlinePolicyDto } from '../../src/inspection-deadlines/dto/update-inspection-deadline-policy.dto';
|
||||
import { InspectionDeadlinesController } from '../../src/inspection-deadlines/inspection-deadlines.controller';
|
||||
|
||||
function permissions(method: string): string[] {
|
||||
const handler = InspectionDeadlinesController.prototype[
|
||||
method as keyof InspectionDeadlinesController
|
||||
];
|
||||
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
|
||||
}
|
||||
|
||||
test('F4 deadline administration is protected by one dedicated JEFE permission', () => {
|
||||
assert.deepEqual(permissions('policies'), ['inspection_deadlines.manage']);
|
||||
assert.deepEqual(permissions('updatePolicy'), ['inspection_deadlines.manage']);
|
||||
assert.deepEqual(permissions('nonWorkingDays'), ['inspection_deadlines.manage']);
|
||||
assert.deepEqual(permissions('upsertNonWorkingDay'), ['inspection_deadlines.manage']);
|
||||
});
|
||||
|
||||
test('F4 deadline policy accepts business or calendar days with a bounded amount', async () => {
|
||||
const valid = plainToInstance(UpdateInspectionDeadlinePolicyDto, {
|
||||
days: 5,
|
||||
dayType: InspectionDeadlineDayType.BUSINESS,
|
||||
});
|
||||
assert.equal((await validate(valid)).length, 0);
|
||||
|
||||
const calendar = plainToInstance(UpdateInspectionDeadlinePolicyDto, {
|
||||
days: 10,
|
||||
dayType: InspectionDeadlineDayType.CALENDAR,
|
||||
});
|
||||
assert.equal((await validate(calendar)).length, 0);
|
||||
|
||||
const invalid = plainToInstance(UpdateInspectionDeadlinePolicyDto, {
|
||||
days: 0,
|
||||
dayType: 'UNKNOWN',
|
||||
});
|
||||
assert.ok((await validate(invalid)).length >= 2);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
|
||||
test('F4 foundation snapshots Act deadlines and seeds the institutional 5/10 business-day policy', () => {
|
||||
const migration = read('src/database/migrations/1790035200000-phase-f4-document-flow-foundation.ts');
|
||||
assert.match(migration, /deadline_policy_snapshot jsonb/);
|
||||
assert.match(migration, /'URGENT', 5, 'BUSINESS', 'ACT_DATE'/);
|
||||
assert.match(migration, /'NON_URGENT', 10, 'BUSINESS', 'GEDO_LOAD_DATE'/);
|
||||
assert.match(migration, /inspection_non_working_days/);
|
||||
});
|
||||
|
||||
test('F4 foundation models recurrence as a new Finding linked to its antecedent', () => {
|
||||
const finding = read('src/database/entities/inspection-finding.entity.ts');
|
||||
const migration = read('src/database/migrations/1790035200000-phase-f4-document-flow-foundation.ts');
|
||||
assert.match(finding, /isRecurrence!/);
|
||||
assert.match(finding, /antecedentFindingId!/);
|
||||
assert.match(migration, /fk_inspection_findings_antecedent/);
|
||||
assert.match(migration, /chk_inspection_findings_recurrence_link/);
|
||||
});
|
||||
|
||||
test('F4 foundation separates editable INF content, GEDO IF officialization and report-level follow-up', () => {
|
||||
const report = read('src/database/entities/inspection-report.entity.ts');
|
||||
const migration = read('src/database/migrations/1790035200000-phase-f4-document-flow-foundation.ts');
|
||||
assert.match(report, /executiveSummary!/);
|
||||
assert.match(report, /description!/);
|
||||
assert.match(report, /gedoIfIdentifier!/);
|
||||
assert.match(report, /gedoOfficializedOn!/);
|
||||
assert.match(migration, /inspection_report_follow_ups/);
|
||||
assert.match(migration, /COMPANY_NOTE/);
|
||||
assert.doesNotMatch(migration, /finding_id uuid NOT NULL[\s\S]*inspection_report_follow_ups/);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
|
||||
test('F4 institutional numbering keeps Inspection, Act and INF date-readable and coherent', () => {
|
||||
const migration = read('src/database/migrations/1790046000000-phase-f4-document-numbering.ts');
|
||||
assert.match(migration, /INSP-/);
|
||||
assert.match(migration, /ACT-/);
|
||||
assert.match(migration, /INF-/);
|
||||
assert.match(migration, /DD-MM-YY/);
|
||||
assert.match(migration, /LPAD\(act_number::text, 5, '0'\)/);
|
||||
});
|
||||
|
||||
test('F4 gives one INF the same annual correlativo as its one Act', () => {
|
||||
const migration = read('src/database/migrations/1790046000000-phase-f4-document-numbering.ts');
|
||||
assert.match(migration, /report_number = act\.act_number/);
|
||||
assert.match(migration, /NEW\.report_number := source_act\.act_number/);
|
||||
assert.match(migration, /NEW\.report_year := source_act\.act_year/);
|
||||
assert.match(migration, /trg_dhv2_f4_set_report_code/);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
|
||||
test('F4 forbids closing an Inspection while any Act still awaits company outcome', () => {
|
||||
const service = read('src/inspection-visits/inspection-visit-closure.service.ts');
|
||||
assert.match(service, /INSPECTION_VISIT_COMPANY_OUTCOME_PENDING/);
|
||||
assert.match(service, /signature\.status IN \('SIGNED', 'REFUSED', 'ABSENT'\)/);
|
||||
assert.match(service, /recipient|signature|empresa/i);
|
||||
});
|
||||
|
||||
test('F4 requires all non-cancelled Acts to be sealed before closing the Inspection', () => {
|
||||
const service = read('src/inspection-visits/inspection-visit-closure.service.ts');
|
||||
assert.match(service, /INSPECTION_VISIT_ACTS_NOT_SEALED/);
|
||||
assert.match(service, /act\.closure_sha256 IS NULL/);
|
||||
assert.match(service, /allActsSealed: true/);
|
||||
});
|
||||
|
||||
test('F4 stamps sealed_at when an Act reaches the physical CLOSED state', () => {
|
||||
const migration = read('src/database/migrations/1790056800000-phase-f4-sealed-acts.ts');
|
||||
assert.match(migration, /sealed_at/);
|
||||
assert.match(migration, /dhv2_f4_stamp_act_seal/);
|
||||
assert.match(migration, /NEW\.status = 'CLOSED'/);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'reflect-metadata';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
|
||||
import { InspectionFindingRecurrenceController } from '../../src/inspection-findings/inspection-finding-recurrence.controller';
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
|
||||
function permissions(method: string): string[] {
|
||||
const handler = InspectionFindingRecurrenceController.prototype[
|
||||
method as keyof InspectionFindingRecurrenceController
|
||||
];
|
||||
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
|
||||
}
|
||||
|
||||
test('F4 exposes recurrence candidates for the selected Inventory and creates a new Finding', () => {
|
||||
assert.deepEqual(permissions('candidates'), ['inspection_findings.read']);
|
||||
assert.deepEqual(permissions('create'), ['inspection_findings.create']);
|
||||
});
|
||||
|
||||
test('F4 recurrence only accepts an unresolved prior Finding from the same Inventory context', () => {
|
||||
const service = read('src/inspection-findings/inspection-finding-recurrence.service.ts');
|
||||
assert.match(service, /finding\.status = 'OPEN'/);
|
||||
assert.match(service, /INSPECTION_FINDING_ANTECEDENT_RESOLVED/);
|
||||
assert.match(service, /INSPECTION_FINDING_RECURRENCE_ASSET_NOT_IN_ACT/);
|
||||
assert.match(service, /finding\.act_id <> \$2/);
|
||||
});
|
||||
|
||||
test('F4 creates recurrence append-only as a new Finding linked to its antecedent', () => {
|
||||
const service = read('src/inspection-findings/inspection-finding-recurrence.service.ts');
|
||||
assert.match(service, /is_recurrence/);
|
||||
assert.match(service, /antecedent_finding_id/);
|
||||
assert.match(service, /INSERT INTO inspection_finding_versions/);
|
||||
assert.match(service, /recurrence: true/);
|
||||
assert.doesNotMatch(service, /UPDATE inspection_findings[\s\S]*antecedentFindingId/);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user