Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c309036cb | ||
|
|
6bda0f68fc | ||
|
|
6a86f67eba | ||
|
|
37795e6586 | ||
|
|
3cd9cdd104 | ||
|
|
205ff1bab8 | ||
|
|
d954ec135f | ||
|
|
4c5742c548 | ||
|
|
cd24736df8 | ||
|
|
8e2d0dfe19 | ||
|
|
afde214cbb | ||
|
|
82213e72f5 |
@@ -0,0 +1,33 @@
|
|||||||
|
COMPOSE_PROJECT_NAME=dhv2
|
||||||
|
|
||||||
|
DB_NAME=dhv2
|
||||||
|
DB_OWNER_USER=dhv2_owner
|
||||||
|
DB_OWNER_PASSWORD=CHANGE_ME
|
||||||
|
DB_APP_USER=dhv2_app
|
||||||
|
DB_APP_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
API_PORT=3000
|
||||||
|
WEB_HOST_PORT=8182
|
||||||
|
API_HOST_PORT=3101
|
||||||
|
|
||||||
|
WEB_ORIGIN=https://dhv2.korexlabs.com
|
||||||
|
JWT_ACCESS_SECRET=CHANGE_ME_WITH_AT_LEAST_64_RANDOM_CHARACTERS
|
||||||
|
REFRESH_TOKEN_PEPPER=CHANGE_ME_WITH_ANOTHER_64_RANDOM_CHARACTERS
|
||||||
|
ACCESS_TOKEN_TTL_SECONDS=900
|
||||||
|
REFRESH_TOKEN_TTL_SECONDS=604800
|
||||||
|
AUTH_MAX_LOGIN_ATTEMPTS=5
|
||||||
|
AUTH_LOCKOUT_SECONDS=900
|
||||||
|
ACCESS_COOKIE_NAME=dhv2_access
|
||||||
|
REFRESH_COOKIE_NAME=dhv2_refresh
|
||||||
|
CSRF_COOKIE_NAME=dhv2_csrf
|
||||||
|
|
||||||
|
INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word
|
||||||
|
INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions
|
||||||
|
|
||||||
|
INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
MAIL_FROM=
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: DH V2 CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dhv2-ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
api:
|
||||||
|
name: API · typecheck, tests, build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: api-v3
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: api-v3/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
web:
|
||||||
|
name: WEB · typecheck, build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web-v2
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web-v2/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
contract:
|
||||||
|
name: Docker / scripts contract
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
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: Build production images
|
||||||
|
run: docker compose --env-file .env.example build api migrate web
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
# Secrets / environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Dependencies / builds
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
**/.next/
|
||||||
|
**/.cache/
|
||||||
|
**/.vite/
|
||||||
|
**/coverage/
|
||||||
|
|
||||||
|
# Backups / exports
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
*.tgz
|
||||||
|
*.dump
|
||||||
|
*.sql
|
||||||
|
*.bak
|
||||||
|
*.bak-*
|
||||||
|
*.backup
|
||||||
|
|
||||||
|
# Runtime / logs
|
||||||
|
*.log
|
||||||
|
*.pid
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS / editors
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Private keys / certificates
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
id_rsa
|
||||||
|
id_ed25519
|
||||||
|
*_github
|
||||||
|
*_github.pub
|
||||||
|
|
||||||
|
# TypeScript generated metadata
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Generated from web-v2/vite.config.ts
|
||||||
|
web-v2/vite.config.js
|
||||||
|
web-v2/vite.config.d.ts
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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**
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:24-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY nest-cli.json tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:24-alpine AS runner
|
||||||
|
RUN apk add --no-cache unzip
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6267
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "dhv2-api",
|
||||||
|
"version": "0.20.0-3",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "tsc -p tsconfig.test.json --noEmit && node --import tsx --test test/**/*.test.ts",
|
||||||
|
"migration:run": "node dist/database/migration-cli.js run",
|
||||||
|
"migration:show": "node dist/database/migration-cli.js show",
|
||||||
|
"migration:revert": "node dist/database/migration-cli.js revert",
|
||||||
|
"bootstrap:admin": "node dist/cli/bootstrap-admin.js",
|
||||||
|
"dev:seed:mendoza-demo": "node dist/cli/dev-seed-mendoza-demo.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.0",
|
||||||
|
"@nestjs/config": "^4.0.0",
|
||||||
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"argon2": "^0.45.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"pg": "^8.0.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.0",
|
||||||
|
"typeorm": "^0.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@types/cookie-parser": "^1.4.9",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { ActAdministrationService, MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from './act-administration.service';
|
||||||
|
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
|
||||||
|
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
|
||||||
|
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
|
||||||
|
|
||||||
|
@Controller('act-administration')
|
||||||
|
export class ActAdministrationQueueController {
|
||||||
|
constructor(private readonly administration: ActAdministrationService) {}
|
||||||
|
@Get('queue') @RequirePermissions('inspection_acts.read') queue(@Query() query: ListActAdministrationQueryDto) { return this.administration.queue(query); }
|
||||||
|
@Get('calendar') @RequirePermissions('inspection_acts.read') calendar(@Query('from') from?: string, @Query('to') to?: string) { return this.administration.calendar(from, to); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('inspection-acts/:actId/administration')
|
||||||
|
export class ActAdministrationController {
|
||||||
|
constructor(private readonly administration: ActAdministrationService) {}
|
||||||
|
@Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); }
|
||||||
|
@Patch('deadline') @RequirePermissions('inspection_findings.follow_up')
|
||||||
|
setDeadline(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: SetActResponseDeadlineDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.setDeadline(actId, dto, principal, request); }
|
||||||
|
@Post('responses') @RequirePermissions('inspection_findings.follow_up')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 } }))
|
||||||
|
addResponse(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CreateActCompanyResponseDto, @UploadedFile() file: UploadedActResponseFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.addResponse(actId, dto, file, principal, request); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('act-company-responses')
|
||||||
|
export class ActCompanyResponseContentController {
|
||||||
|
constructor(private readonly administration: ActAdministrationService) {}
|
||||||
|
@Get(':responseId/content') @RequirePermissions('inspection_acts.read')
|
||||||
|
async content(@Param('responseId', new ParseUUIDPipe({ version: '4' })) responseId: string, @Query('download') download: string | undefined, @Res() response: Response): Promise<void> {
|
||||||
|
const item = await this.administration.responseContent(responseId);
|
||||||
|
const disposition = download === '1' ? 'attachment' : 'inline';
|
||||||
|
const fallbackName = item.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
||||||
|
response.setHeader('Content-Type', 'application/pdf');
|
||||||
|
response.setHeader('Content-Length', String(item.sizeBytes));
|
||||||
|
response.setHeader('Content-Disposition', `${disposition}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(item.originalName)}`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
response.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
|
||||||
|
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(item.filePath, (error) => error ? rejectSend(error) : resolveSend()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import {
|
||||||
|
ActAdministrationController,
|
||||||
|
ActAdministrationQueueController,
|
||||||
|
ActCompanyResponseContentController,
|
||||||
|
} from './act-administration.controller';
|
||||||
|
import { ActAdministrationService } from './act-administration.service';
|
||||||
|
import { FieldBriefingController } from './field-briefing.controller';
|
||||||
|
import { FieldBriefingService } from './field-briefing.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuditModule],
|
||||||
|
controllers: [
|
||||||
|
ActAdministrationQueueController,
|
||||||
|
ActAdministrationController,
|
||||||
|
ActCompanyResponseContentController,
|
||||||
|
FieldBriefingController,
|
||||||
|
],
|
||||||
|
providers: [ActAdministrationService, FieldBriefingService],
|
||||||
|
})
|
||||||
|
export class ActAdministrationModule {}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
|
||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
import { mkdir, stat, unlink, writeFile } from 'fs/promises';
|
||||||
|
import { join, resolve } from 'path';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
|
||||||
|
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
|
||||||
|
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
|
||||||
|
|
||||||
|
export const MAX_ACT_RESPONSE_BYTES = 15 * 1024 * 1024;
|
||||||
|
export interface UploadedActResponseFile { originalname: string; mimetype: string; size: number; buffer: Buffer }
|
||||||
|
|
||||||
|
const STORAGE = resolve(process.env.ACT_COMPANY_RESPONSE_STORAGE_DIR ?? '/app/storage/act-company-responses');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActAdministrationService {
|
||||||
|
constructor(private readonly dataSource: DataSource, private readonly audit: AuditService) {}
|
||||||
|
|
||||||
|
private baseCte() {
|
||||||
|
return `
|
||||||
|
WITH act_rows AS (
|
||||||
|
SELECT ia.id AS "actId", ia.code AS "actCode", ia.status AS "actStatus", ia.occurred_at AS "occurredAt",
|
||||||
|
ia.closed_at AS "closedAt", v.id AS "visitId", v.code AS "visitCode",
|
||||||
|
v.operational_area_id AS "areaId", v.operator_company_id AS "companyId",
|
||||||
|
area.name AS "areaName", COALESCE(op.legal_name, company.name) AS "companyName",
|
||||||
|
dl.id AS "deadlineEventId", dl.response_due_on AS "responseDueOn", dl.reason AS "deadlineReason",
|
||||||
|
dl.created_at AS "deadlineSetAt", rsp.id AS "latestResponseId", rsp.received_on AS "responseReceivedOn",
|
||||||
|
rsp.committed_correction_on AS "committedCorrectionOn",
|
||||||
|
COALESCE(fc.finding_count, 0)::int AS "findingCount",
|
||||||
|
COALESCE(fc.open_count, 0)::int AS "openFindingCount",
|
||||||
|
COALESCE(fc.scheduled_control_count, 0)::int AS "scheduledControlCount"
|
||||||
|
FROM inspection_acts ia
|
||||||
|
JOIN inspection_visits v ON v.id = ia.visit_id
|
||||||
|
LEFT JOIN assets area ON area.id = v.operational_area_id
|
||||||
|
LEFT JOIN assets company ON company.id = v.operator_company_id
|
||||||
|
LEFT JOIN organization_profiles op ON op.asset_id = v.operator_company_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT d.* FROM inspection_act_deadline_events d WHERE d.act_id = ia.id ORDER BY d.created_at DESC, d.id DESC LIMIT 1
|
||||||
|
) dl ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT r.* FROM inspection_act_company_responses r WHERE r.act_id = ia.id ORDER BY r.received_on DESC, r.created_at DESC, r.id DESC LIMIT 1
|
||||||
|
) rsp ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*) FILTER (WHERE f.status <> 'VOIDED') AS finding_count,
|
||||||
|
COUNT(*) FILTER (WHERE f.status = 'OPEN') AS open_count,
|
||||||
|
COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_count
|
||||||
|
FROM inspection_findings f WHERE f.act_id = ia.id
|
||||||
|
) fc ON true
|
||||||
|
WHERE ia.status IN ('CLOSED', 'RECTIFIED')
|
||||||
|
), classified AS (
|
||||||
|
SELECT *, CASE
|
||||||
|
WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED'
|
||||||
|
WHEN "latestResponseId" IS NOT NULL AND "committedCorrectionOn" IS NOT NULL AND "committedCorrectionOn" < CURRENT_DATE AND "openFindingCount" > 0 THEN 'COMMITMENT_OVERDUE'
|
||||||
|
WHEN "latestResponseId" IS NOT NULL AND "openFindingCount" > 0 AND "scheduledControlCount" = 0 THEN 'VERIFICATION_PENDING'
|
||||||
|
WHEN "latestResponseId" IS NOT NULL THEN 'RESPONSE_RECEIVED'
|
||||||
|
WHEN "responseDueOn" IS NULL THEN 'NEW'
|
||||||
|
WHEN "responseDueOn" < CURRENT_DATE THEN 'OVERDUE'
|
||||||
|
WHEN "responseDueOn" <= CURRENT_DATE + 3 THEN 'DUE_SOON'
|
||||||
|
ELSE 'WAITING_RESPONSE'
|
||||||
|
END AS "adminState"
|
||||||
|
FROM act_rows
|
||||||
|
)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async queue(query: ListActAdministrationQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 25;
|
||||||
|
const filters: string[] = [];
|
||||||
|
const args: unknown[] = [];
|
||||||
|
const add = (sql: string, value: unknown) => { args.push(value); filters.push(sql.replace('?', `$${args.length}`)); };
|
||||||
|
if (query.areaId) add('"areaId" = ?', query.areaId);
|
||||||
|
if (query.companyId) add('"companyId" = ?', query.companyId);
|
||||||
|
if (query.state && query.state !== 'ALL') add('"adminState" = ?', query.state);
|
||||||
|
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`${this.baseCte()} SELECT *, COUNT(*) OVER()::int AS "totalRows" FROM classified ${where} ORDER BY "responseDueOn" ASC NULLS FIRST, "occurredAt" DESC LIMIT $${args.length + 1} OFFSET $${args.length + 2}`,
|
||||||
|
[...args, pageSize, (page - 1) * pageSize],
|
||||||
|
) as Array<Record<string, unknown>>;
|
||||||
|
const total = Number(rows[0]?.totalRows ?? 0);
|
||||||
|
const counterFilters: string[] = [];
|
||||||
|
const counterArgs: unknown[] = [];
|
||||||
|
if (query.areaId) { counterArgs.push(query.areaId); counterFilters.push(`"areaId" = $${counterArgs.length}`); }
|
||||||
|
if (query.companyId) { counterArgs.push(query.companyId); counterFilters.push(`"companyId" = $${counterArgs.length}`); }
|
||||||
|
const countersRaw = await this.dataSource.query(
|
||||||
|
`${this.baseCte()} SELECT "adminState" AS state, COUNT(*)::int AS count FROM classified ${counterFilters.length ? `WHERE ${counterFilters.join(' AND ')}` : ''} GROUP BY "adminState"`,
|
||||||
|
counterArgs,
|
||||||
|
) as Array<{ state: string; count: number }>;
|
||||||
|
const counters = Object.fromEntries(countersRaw.map((r) => [r.state, Number(r.count)]));
|
||||||
|
return {
|
||||||
|
data: rows.map(({ totalRows: _ignored, ...row }) => row),
|
||||||
|
counters,
|
||||||
|
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(actId: string) {
|
||||||
|
const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE "actId" = $1`, [actId]);
|
||||||
|
const act = rows[0];
|
||||||
|
if (!act) throw new NotFoundException({ code: 'ACT_ADMIN_NOT_FOUND', message: 'El Acta no está disponible para seguimiento administrativo.' });
|
||||||
|
const deadlines = await this.dataSource.query(`SELECT id, response_due_on AS "responseDueOn", reason, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_deadline_events WHERE act_id = $1 ORDER BY created_at DESC`, [actId]);
|
||||||
|
const responses = await this.dataSource.query(`SELECT id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", mime_type AS "mimeType", size_bytes AS "sizeBytes", sha256, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_company_responses WHERE act_id = $1 ORDER BY received_on DESC, created_at DESC`, [actId]);
|
||||||
|
const findings = await this.dataSource.query(`SELECT id, code, title, status, next_control_on AS "nextControlOn" FROM inspection_findings WHERE act_id = $1 AND status <> 'VOIDED' ORDER BY finding_number`, [actId]);
|
||||||
|
return { act, deadlines, responses, findings };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureClosedAct(actId: string) {
|
||||||
|
const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]);
|
||||||
|
const act = rows[0];
|
||||||
|
if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' });
|
||||||
|
if (!['CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_CLOSED', message: 'El seguimiento administrativo comienza cuando el Acta está cerrada.' });
|
||||||
|
return act;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) {
|
||||||
|
const act = await this.ensureClosedAct(actId);
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id, response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
|
||||||
|
[randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId],
|
||||||
|
);
|
||||||
|
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, responseDueOn: dto.responseDueOn, reason: dto.reason } });
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) {
|
||||||
|
const act = await this.ensureClosedAct(actId);
|
||||||
|
let storedName: string | null = null;
|
||||||
|
let sha256: string | null = null;
|
||||||
|
if (file) {
|
||||||
|
if (file.size <= 0 || file.size > MAX_ACT_RESPONSE_BYTES) throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_SIZE', message: 'El PDF supera el límite permitido.' });
|
||||||
|
if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_TYPE', message: 'La respuesta adjunta debe ser un PDF válido.' });
|
||||||
|
await mkdir(STORAGE, { recursive: true });
|
||||||
|
sha256 = createHash('sha256').update(file.buffer).digest('hex');
|
||||||
|
storedName = `${randomUUID()}.pdf`;
|
||||||
|
await writeFile(join(STORAGE, storedName), file.buffer, { flag: 'wx' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`INSERT INTO inspection_act_company_responses (id, act_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||||
|
RETURNING id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
|
||||||
|
[randomUUID(), actId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
|
||||||
|
);
|
||||||
|
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 } });
|
||||||
|
return rows[0];
|
||||||
|
} catch (error) {
|
||||||
|
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async responseContent(responseId: string) {
|
||||||
|
const rows = await this.dataSource.query(`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes" FROM inspection_act_company_responses WHERE id = $1 AND stored_name IS NOT NULL`, [responseId]);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
|
||||||
|
const filePath = join(STORAGE, row.storedName);
|
||||||
|
const info = await stat(filePath).catch(() => null);
|
||||||
|
if (!info?.isFile()) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'El archivo de respuesta no está disponible.' });
|
||||||
|
return { originalName: row.originalName as string, sizeBytes: Number(row.sizeBytes), filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async calendar(from?: string, to?: string) {
|
||||||
|
const dateRe = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if ((from && !dateRe.test(from)) || (to && !dateRe.test(to))) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'Las fechas deben usar YYYY-MM-DD.' });
|
||||||
|
const start = from ?? new Date().toISOString().slice(0, 10);
|
||||||
|
const endDate = new Date(`${to ?? start}T00:00:00Z`);
|
||||||
|
if (!to) endDate.setUTCDate(endDate.getUTCDate() + 60);
|
||||||
|
const end = endDate.toISOString().slice(0, 10);
|
||||||
|
if (start > end) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'El rango de fechas es inválido.' });
|
||||||
|
const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE ("responseDueOn" BETWEEN $1 AND $2) OR ("committedCorrectionOn" BETWEEN $1 AND $2) ORDER BY COALESCE("responseDueOn", "committedCorrectionOn")`, [start, end]);
|
||||||
|
const events: Array<Record<string, unknown>> = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.responseDueOn && row.responseDueOn >= start && row.responseDueOn <= end) events.push({ type: 'RESPONSE_DUE', date: row.responseDueOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState });
|
||||||
|
if (row.committedCorrectionOn && row.committedCorrectionOn >= start && row.committedCorrectionOn <= end) events.push({ type: 'COMMITMENT_DUE', date: row.committedCorrectionOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState });
|
||||||
|
}
|
||||||
|
return { from: start, to: end, data: events.sort((a, b) => String(a.date).localeCompare(String(b.date))) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateActCompanyResponseDto {
|
||||||
|
@IsDateString()
|
||||||
|
receivedOn!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(8000)
|
||||||
|
details?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
committedCorrectionOn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
contactName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(320)
|
||||||
|
contactEmail?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export const ACT_ADMIN_STATES = [
|
||||||
|
'ALL', 'NEW', 'WAITING_RESPONSE', 'DUE_SOON', 'OVERDUE',
|
||||||
|
'RESPONSE_RECEIVED', 'VERIFICATION_PENDING', 'COMMITMENT_OVERDUE', 'REGULARIZED',
|
||||||
|
] as const;
|
||||||
|
export type ActAdministrationState = typeof ACT_ADMIN_STATES[number];
|
||||||
|
|
||||||
|
export class ListActAdministrationQueryDto {
|
||||||
|
@IsOptional() @IsIn(ACT_ADMIN_STATES) state?: ActAdministrationState;
|
||||||
|
@IsOptional() @IsUUID('4') areaId?: string;
|
||||||
|
@IsOptional() @IsUUID('4') companyId?: string;
|
||||||
|
@IsOptional() @Transform(({ value }) => Number(value)) @IsInt() @Min(1) page?: number;
|
||||||
|
@IsOptional() @Transform(({ value }) => Number(value)) @IsInt() @Min(1) @Max(100) pageSize?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsDateString, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SetActResponseDeadlineDto {
|
||||||
|
@IsDateString()
|
||||||
|
responseDueOn!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(1000)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { FieldBriefingService } from './field-briefing.service';
|
||||||
|
|
||||||
|
@Controller('inspection-visits/:visitId/field-briefing')
|
||||||
|
export class FieldBriefingController {
|
||||||
|
constructor(private readonly briefing: FieldBriefingService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('inspections.read')
|
||||||
|
get(@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string) {
|
||||||
|
return this.briefing.forVisit(visitId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
export type FieldBriefingActState =
|
||||||
|
| 'ACT_RESPONSE_OVERDUE'
|
||||||
|
| 'ACT_RESPONSE_DUE_SOON'
|
||||||
|
| 'WAITING_RESPONSE'
|
||||||
|
| 'COMPANY_COMMITMENT_OVERDUE'
|
||||||
|
| 'VERIFICATION_PENDING'
|
||||||
|
| 'RESPONSE_RECEIVED';
|
||||||
|
|
||||||
|
export interface FieldBriefingFinding {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
severity: number | null;
|
||||||
|
nextControlOn: string | null;
|
||||||
|
asset: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
typeName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldBriefingAct {
|
||||||
|
actId: string;
|
||||||
|
actCode: string;
|
||||||
|
occurredAt: Date;
|
||||||
|
adminState: FieldBriefingActState;
|
||||||
|
responseDueOn: string | null;
|
||||||
|
responseReceivedOn: string | null;
|
||||||
|
committedCorrectionOn: string | null;
|
||||||
|
latestResponseId: string | null;
|
||||||
|
findings: FieldBriefingFinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FieldBriefingService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async forVisit(visitId: string) {
|
||||||
|
const [visit] = (await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
visit.id,
|
||||||
|
visit.code,
|
||||||
|
visit.status,
|
||||||
|
visit.planned_start_at AS "plannedStartAt",
|
||||||
|
visit.operational_area_id AS "areaId",
|
||||||
|
visit.operator_company_id AS "companyId",
|
||||||
|
area.code AS "areaCode",
|
||||||
|
area.name AS "areaName",
|
||||||
|
company.code AS "companyCode",
|
||||||
|
COALESCE(profile.legal_name, company.name) AS "companyName"
|
||||||
|
FROM inspection_visits visit
|
||||||
|
LEFT JOIN assets area ON area.id = visit.operational_area_id
|
||||||
|
LEFT JOIN assets company ON company.id = visit.operator_company_id
|
||||||
|
LEFT JOIN organization_profiles profile ON profile.asset_id = company.id
|
||||||
|
WHERE visit.id = $1
|
||||||
|
`, [visitId])) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
if (!visit) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'INSPECTION_VISIT_NOT_FOUND',
|
||||||
|
message: 'Inspección no encontrada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
|
||||||
|
|
||||||
|
const rows = (await this.dataSource.query(`
|
||||||
|
WITH prior_acts AS (
|
||||||
|
SELECT
|
||||||
|
act.id AS act_id,
|
||||||
|
act.code AS act_code,
|
||||||
|
act.occurred_at,
|
||||||
|
deadline.response_due_on,
|
||||||
|
response.id AS latest_response_id,
|
||||||
|
response.received_on AS response_received_on,
|
||||||
|
response.committed_correction_on,
|
||||||
|
finding.id AS finding_id,
|
||||||
|
finding.code AS finding_code,
|
||||||
|
finding.title AS finding_title,
|
||||||
|
finding.status AS finding_status,
|
||||||
|
finding.severity,
|
||||||
|
finding.next_control_on,
|
||||||
|
asset.id AS asset_id,
|
||||||
|
asset.code AS asset_code,
|
||||||
|
asset.name AS asset_name,
|
||||||
|
asset_type.name AS asset_type_name,
|
||||||
|
CASE
|
||||||
|
WHEN response.id IS NULL
|
||||||
|
AND deadline.response_due_on IS NOT NULL
|
||||||
|
AND deadline.response_due_on < $4::date
|
||||||
|
THEN 'ACT_RESPONSE_OVERDUE'
|
||||||
|
WHEN response.id IS NULL
|
||||||
|
AND deadline.response_due_on IS NOT NULL
|
||||||
|
AND deadline.response_due_on BETWEEN $4::date AND ($4::date + 3)
|
||||||
|
THEN 'ACT_RESPONSE_DUE_SOON'
|
||||||
|
WHEN response.id IS NOT NULL
|
||||||
|
AND response.committed_correction_on IS NOT NULL
|
||||||
|
AND response.committed_correction_on < $4::date
|
||||||
|
THEN 'COMPANY_COMMITMENT_OVERDUE'
|
||||||
|
WHEN response.id IS NOT NULL
|
||||||
|
AND finding.next_control_on IS NULL
|
||||||
|
THEN 'VERIFICATION_PENDING'
|
||||||
|
WHEN response.id IS NOT NULL
|
||||||
|
THEN 'RESPONSE_RECEIVED'
|
||||||
|
ELSE 'WAITING_RESPONSE'
|
||||||
|
END AS admin_state
|
||||||
|
FROM inspection_acts act
|
||||||
|
INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id
|
||||||
|
INNER JOIN inspection_findings finding ON finding.act_id = act.id
|
||||||
|
AND finding.status = 'OPEN'
|
||||||
|
INNER JOIN assets asset ON asset.id = finding.asset_id
|
||||||
|
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT event.response_due_on
|
||||||
|
FROM inspection_act_deadline_events event
|
||||||
|
WHERE event.act_id = act.id
|
||||||
|
ORDER BY event.created_at DESC, event.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) deadline ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT company_response.id, company_response.received_on, company_response.committed_correction_on
|
||||||
|
FROM inspection_act_company_responses company_response
|
||||||
|
WHERE company_response.act_id = act.id
|
||||||
|
ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) response ON true
|
||||||
|
WHERE act.status IN ('CLOSED', 'RECTIFIED')
|
||||||
|
AND source_visit.id <> $1
|
||||||
|
AND source_visit.operational_area_id = $2::uuid
|
||||||
|
AND source_visit.operator_company_id = $3::uuid
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM prior_acts
|
||||||
|
WHERE admin_state IN (
|
||||||
|
'ACT_RESPONSE_OVERDUE',
|
||||||
|
'ACT_RESPONSE_DUE_SOON',
|
||||||
|
'COMPANY_COMMITMENT_OVERDUE',
|
||||||
|
'VERIFICATION_PENDING',
|
||||||
|
'RESPONSE_RECEIVED'
|
||||||
|
)
|
||||||
|
OR next_control_on IS NOT NULL
|
||||||
|
ORDER BY
|
||||||
|
CASE admin_state
|
||||||
|
WHEN 'ACT_RESPONSE_OVERDUE' THEN 1
|
||||||
|
WHEN 'COMPANY_COMMITMENT_OVERDUE' THEN 2
|
||||||
|
WHEN 'VERIFICATION_PENDING' THEN 3
|
||||||
|
WHEN 'ACT_RESPONSE_DUE_SOON' THEN 4
|
||||||
|
ELSE 5
|
||||||
|
END,
|
||||||
|
response_due_on NULLS LAST,
|
||||||
|
occurred_at,
|
||||||
|
act_code,
|
||||||
|
finding_code
|
||||||
|
`, [visitId, visit.areaId, visit.companyId, plannedOn])) as Array<{
|
||||||
|
act_id: string;
|
||||||
|
act_code: string;
|
||||||
|
occurred_at: Date;
|
||||||
|
response_due_on: string | null;
|
||||||
|
latest_response_id: string | null;
|
||||||
|
response_received_on: string | null;
|
||||||
|
committed_correction_on: string | null;
|
||||||
|
finding_id: string;
|
||||||
|
finding_code: string;
|
||||||
|
finding_title: string;
|
||||||
|
finding_status: string;
|
||||||
|
severity: number | null;
|
||||||
|
next_control_on: string | null;
|
||||||
|
asset_id: string;
|
||||||
|
asset_code: string;
|
||||||
|
asset_name: string;
|
||||||
|
asset_type_name: string;
|
||||||
|
admin_state: FieldBriefingActState;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const byAct = new Map<string, FieldBriefingAct>();
|
||||||
|
for (const row of rows) {
|
||||||
|
let act = byAct.get(row.act_id);
|
||||||
|
if (!act) {
|
||||||
|
act = {
|
||||||
|
actId: row.act_id,
|
||||||
|
actCode: row.act_code,
|
||||||
|
occurredAt: row.occurred_at,
|
||||||
|
adminState: row.admin_state,
|
||||||
|
responseDueOn: row.response_due_on,
|
||||||
|
responseReceivedOn: row.response_received_on,
|
||||||
|
committedCorrectionOn: row.committed_correction_on,
|
||||||
|
latestResponseId: row.latest_response_id,
|
||||||
|
findings: [],
|
||||||
|
};
|
||||||
|
byAct.set(row.act_id, act);
|
||||||
|
}
|
||||||
|
act.findings.push({
|
||||||
|
id: row.finding_id,
|
||||||
|
code: row.finding_code,
|
||||||
|
title: row.finding_title,
|
||||||
|
status: row.finding_status,
|
||||||
|
severity: row.severity,
|
||||||
|
nextControlOn: row.next_control_on,
|
||||||
|
asset: {
|
||||||
|
id: row.asset_id,
|
||||||
|
code: row.asset_code,
|
||||||
|
name: row.asset_name,
|
||||||
|
typeName: row.asset_type_name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const acts = [...byAct.values()];
|
||||||
|
const assetIds = new Set<string>();
|
||||||
|
let findingCount = 0;
|
||||||
|
for (const act of acts) {
|
||||||
|
findingCount += act.findings.length;
|
||||||
|
for (const finding of act.findings) assetIds.add(finding.asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inspection: {
|
||||||
|
id: visit.id,
|
||||||
|
code: visit.code,
|
||||||
|
status: visit.status,
|
||||||
|
plannedStartAt: visit.plannedStartAt,
|
||||||
|
area: visit.areaId ? { id: visit.areaId, code: visit.areaCode, name: visit.areaName } : null,
|
||||||
|
operatorCompany: visit.companyId ? { id: visit.companyId, code: visit.companyCode, name: visit.companyName } : null,
|
||||||
|
},
|
||||||
|
plannedOn,
|
||||||
|
summary: {
|
||||||
|
acts: acts.length,
|
||||||
|
findings: findingCount,
|
||||||
|
inventoryItems: assetIds.size,
|
||||||
|
responseOverdue: acts.filter((item) => item.adminState === 'ACT_RESPONSE_OVERDUE').length,
|
||||||
|
commitmentOverdue: acts.filter((item) => item.adminState === 'COMPANY_COMMITMENT_OVERDUE').length,
|
||||||
|
verificationPending: acts.filter((item) => item.adminState === 'VERIFICATION_PENDING').length,
|
||||||
|
},
|
||||||
|
acts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { PhaseADataModule } from '../core-data/phase-a-data.module';
|
||||||
|
import { RolesController } from './roles/roles.controller';
|
||||||
|
import { RolesService } from './roles/roles.service';
|
||||||
|
import { UsersController } from './users/users.controller';
|
||||||
|
import { UsersService } from './users/users.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PhaseADataModule, AuditModule, AuthModule],
|
||||||
|
controllers: [UsersController, RolesController],
|
||||||
|
providers: [UsersService, RolesService],
|
||||||
|
})
|
||||||
|
export class AdministrationModule {}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { isIP } from 'node:net';
|
||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../../common/http/request-context';
|
||||||
|
import { AuditSource } from '../../database/entities';
|
||||||
|
|
||||||
|
const REQUIRED_RECOVERY_PERMISSIONS = [
|
||||||
|
'roles.manage',
|
||||||
|
'users.assign_roles',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function administrationAuditContext(
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const candidateIp = request.ip || request.socket.remoteAddress || '';
|
||||||
|
const rawUserAgent = request.header('user-agent')?.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
actorUserId: principal.userId,
|
||||||
|
actorUsername: principal.username,
|
||||||
|
requestId: request.requestId,
|
||||||
|
source: AuditSource.WEB,
|
||||||
|
ip: isIP(candidateIp) ? candidateIp : null,
|
||||||
|
userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertAdministrativeRecoveryRemains(
|
||||||
|
manager: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const [row] = (await manager.query(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::integer AS count
|
||||||
|
FROM (
|
||||||
|
SELECT user_account.id
|
||||||
|
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 = ANY($1::varchar[])
|
||||||
|
GROUP BY user_account.id
|
||||||
|
HAVING COUNT(DISTINCT permission.code) = $2
|
||||||
|
) administrators
|
||||||
|
`,
|
||||||
|
[REQUIRED_RECOVERY_PERMISSIONS, REQUIRED_RECOVERY_PERMISSIONS.length],
|
||||||
|
)) as Array<{ count: number }>;
|
||||||
|
|
||||||
|
if (!row || Number(row.count) < 1) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'LAST_ADMINISTRATOR_PROTECTED',
|
||||||
|
message:
|
||||||
|
'Debe permanecer al menos un usuario activo capaz de administrar roles y asignaciones',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: unknown }).code === '23505'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateRoleDto {
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
|
)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(80)
|
||||||
|
@Matches(/^[a-z][a-z0-9_-]+$/)
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(1000)
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
permissionIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ArrayUnique, IsArray, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class ReplaceRolePermissionsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
permissionIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateRoleDto {
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(120)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(1000)
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../../common/http/request-context';
|
||||||
|
import { CreateRoleDto } from './dto/create-role.dto';
|
||||||
|
import { ReplaceRolePermissionsDto } from './dto/replace-role-permissions.dto';
|
||||||
|
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||||
|
import { RolesService } from './roles.service';
|
||||||
|
|
||||||
|
@Controller('roles')
|
||||||
|
export class RolesController {
|
||||||
|
constructor(private readonly roles: RolesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('roles.read')
|
||||||
|
list() {
|
||||||
|
return this.roles.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('permissions')
|
||||||
|
@RequirePermissions('roles.read')
|
||||||
|
listPermissions() {
|
||||||
|
return this.roles.listPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('roles.manage')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateRoleDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.roles.create(dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions('roles.read')
|
||||||
|
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.roles.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions('roles.manage')
|
||||||
|
update(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: UpdateRoleDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.roles.update(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/permissions')
|
||||||
|
@RequirePermissions('roles.manage')
|
||||||
|
replacePermissions(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: ReplaceRolePermissionsDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.roles.replacePermissions(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager, In } from 'typeorm';
|
||||||
|
import { AuditService } from '../../audit/audit.service';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../../common/http/request-context';
|
||||||
|
import {
|
||||||
|
AuditAction,
|
||||||
|
Permission,
|
||||||
|
Role,
|
||||||
|
RolePermission,
|
||||||
|
} from '../../database/entities';
|
||||||
|
import {
|
||||||
|
administrationAuditContext,
|
||||||
|
assertAdministrativeRecoveryRemains,
|
||||||
|
isUniqueViolation,
|
||||||
|
} from '../common/administration-audit';
|
||||||
|
import type { CreateRoleDto } from './dto/create-role.dto';
|
||||||
|
import type { ReplaceRolePermissionsDto } from './dto/replace-role-permissions.dto';
|
||||||
|
import type { UpdateRoleDto } from './dto/update-role.dto';
|
||||||
|
|
||||||
|
export interface PermissionView {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdministrativeRoleView {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
isSystem: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
userCount: number;
|
||||||
|
permissions: PermissionView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'ROLE_NOT_FOUND',
|
||||||
|
message: 'Rol no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionSelectionInvalid(): BadRequestException {
|
||||||
|
return new BadRequestException({
|
||||||
|
code: 'PERMISSION_NOT_FOUND',
|
||||||
|
message: 'Uno o más permisos no existen',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(): Promise<{ data: AdministrativeRoleView[] }> {
|
||||||
|
const rows = (await this.dataSource.query(this.roleViewQuery(''), [])) as
|
||||||
|
AdministrativeRoleView[];
|
||||||
|
return { data: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPermissions(): Promise<{ data: PermissionView[] }> {
|
||||||
|
const rows = (await this.dataSource.query(
|
||||||
|
`
|
||||||
|
SELECT id, code, description
|
||||||
|
FROM permissions
|
||||||
|
ORDER BY code ASC
|
||||||
|
`,
|
||||||
|
)) as PermissionView[];
|
||||||
|
return { data: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<AdministrativeRoleView> {
|
||||||
|
return this.dataSource.transaction((manager) =>
|
||||||
|
this.loadRoleView(manager, id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateRoleDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AdministrativeRoleView> {
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const permissions = await this.resolvePermissions(
|
||||||
|
manager,
|
||||||
|
dto.permissionIds,
|
||||||
|
);
|
||||||
|
const role = manager.getRepository(Role).create({
|
||||||
|
code: dto.code.trim().toLowerCase(),
|
||||||
|
name: dto.name.trim(),
|
||||||
|
description: dto.description.trim(),
|
||||||
|
isSystem: false,
|
||||||
|
});
|
||||||
|
await manager.getRepository(Role).save(role);
|
||||||
|
await this.insertPermissions(manager, role.id, permissions);
|
||||||
|
|
||||||
|
const created = await this.loadRoleView(manager, role.id);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ROLE_CREATED,
|
||||||
|
entityType: 'role',
|
||||||
|
entityId: role.id,
|
||||||
|
afterData: { ...created },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) throw this.roleConflict();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateRoleDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AdministrativeRoleView> {
|
||||||
|
if (dto.name === undefined && dto.description === undefined) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'NO_CHANGES',
|
||||||
|
message: 'No se recibieron cambios',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const role = await this.lockRole(manager, id);
|
||||||
|
const before = await this.loadRoleView(manager, id);
|
||||||
|
if (dto.name !== undefined) role.name = dto.name.trim();
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
role.description = dto.description.trim();
|
||||||
|
}
|
||||||
|
await manager.getRepository(Role).save(role);
|
||||||
|
|
||||||
|
const updated = await this.loadRoleView(manager, id);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ROLE_UPDATED,
|
||||||
|
entityType: 'role',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: {
|
||||||
|
name: before.name,
|
||||||
|
description: before.description,
|
||||||
|
},
|
||||||
|
afterData: {
|
||||||
|
name: updated.name,
|
||||||
|
description: updated.description,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async replacePermissions(
|
||||||
|
id: string,
|
||||||
|
dto: ReplaceRolePermissionsDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AdministrativeRoleView> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.lockRole(manager, id);
|
||||||
|
const permissions = await this.resolvePermissions(
|
||||||
|
manager,
|
||||||
|
dto.permissionIds,
|
||||||
|
);
|
||||||
|
const before = await this.loadRoleView(manager, id);
|
||||||
|
const beforeIds = before.permissions
|
||||||
|
.map((permission) => permission.id)
|
||||||
|
.sort();
|
||||||
|
const afterIds = permissions.map((permission) => permission.id).sort();
|
||||||
|
if (beforeIds.join(',') === afterIds.join(',')) return before;
|
||||||
|
|
||||||
|
await manager.getRepository(RolePermission).delete({ roleId: id });
|
||||||
|
await this.insertPermissions(manager, id, permissions);
|
||||||
|
await assertAdministrativeRecoveryRemains(manager);
|
||||||
|
|
||||||
|
const updated = await this.loadRoleView(manager, id);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ROLE_PERMISSIONS_CHANGED,
|
||||||
|
entityType: 'role',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: { permissions: before.permissions },
|
||||||
|
afterData: { permissions: updated.permissions },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockRole(manager: EntityManager, id: string): Promise<Role> {
|
||||||
|
const role = await manager
|
||||||
|
.getRepository(Role)
|
||||||
|
.createQueryBuilder('role')
|
||||||
|
.where('role.id = :id', { id })
|
||||||
|
.setLock('pessimistic_write')
|
||||||
|
.getOne();
|
||||||
|
if (!role) throw roleNotFound();
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolvePermissions(
|
||||||
|
manager: EntityManager,
|
||||||
|
permissionIds: string[],
|
||||||
|
): Promise<Permission[]> {
|
||||||
|
const uniqueIds = [...new Set(permissionIds)];
|
||||||
|
if (uniqueIds.length === 0) return [];
|
||||||
|
const permissions = await manager.getRepository(Permission).find({
|
||||||
|
where: { id: In(uniqueIds) },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
if (permissions.length !== uniqueIds.length) {
|
||||||
|
throw permissionSelectionInvalid();
|
||||||
|
}
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async insertPermissions(
|
||||||
|
manager: EntityManager,
|
||||||
|
roleId: string,
|
||||||
|
permissions: Permission[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (permissions.length === 0) return;
|
||||||
|
const assignments = permissions.map((permission) =>
|
||||||
|
manager.getRepository(RolePermission).create({
|
||||||
|
roleId,
|
||||||
|
permissionId: permission.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await manager.getRepository(RolePermission).save(assignments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadRoleView(
|
||||||
|
manager: EntityManager,
|
||||||
|
id: string,
|
||||||
|
): Promise<AdministrativeRoleView> {
|
||||||
|
const rows = (await manager.query(
|
||||||
|
this.roleViewQuery('WHERE role.id = $1'),
|
||||||
|
[id],
|
||||||
|
)) as AdministrativeRoleView[];
|
||||||
|
if (!rows[0]) throw roleNotFound();
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private roleViewQuery(where: string): string {
|
||||||
|
return `
|
||||||
|
SELECT
|
||||||
|
role.id,
|
||||||
|
role.code,
|
||||||
|
role.name,
|
||||||
|
role.description,
|
||||||
|
role.is_system AS "isSystem",
|
||||||
|
role.created_at AS "createdAt",
|
||||||
|
role.updated_at AS "updatedAt",
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM user_roles user_role
|
||||||
|
WHERE user_role.role_id = role.id
|
||||||
|
) AS "userCount",
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT JSONB_AGG(
|
||||||
|
JSONB_BUILD_OBJECT(
|
||||||
|
'id', permission.id,
|
||||||
|
'code', permission.code,
|
||||||
|
'description', permission.description
|
||||||
|
) ORDER BY permission.code
|
||||||
|
)
|
||||||
|
FROM role_permissions role_permission
|
||||||
|
INNER JOIN permissions permission
|
||||||
|
ON permission.id = role_permission.permission_id
|
||||||
|
WHERE role_permission.role_id = role.id
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
) AS permissions
|
||||||
|
FROM roles role
|
||||||
|
${where}
|
||||||
|
ORDER BY role.is_system DESC, role.code ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private roleConflict(): ConflictException {
|
||||||
|
return new ConflictException({
|
||||||
|
code: 'ROLE_ALREADY_EXISTS',
|
||||||
|
message: 'Ya existe un rol con ese código',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsEnum } from 'class-validator';
|
||||||
|
import { UserStatus } from '../../../database/entities';
|
||||||
|
|
||||||
|
export class ChangeUserStatusDto {
|
||||||
|
@IsEnum(UserStatus)
|
||||||
|
status!: UserStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
|
)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(80)
|
||||||
|
@Matches(/^[a-zA-Z0-9._-]+$/)
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
|
)
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(320)
|
||||||
|
email?: string | null;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(12)
|
||||||
|
@MaxLength(128)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(120)
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(120)
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
mustChangePassword = true;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
roleIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { UserStatus } from '../../../database/entities';
|
||||||
|
|
||||||
|
export class ListUsersQueryDto {
|
||||||
|
@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(UserStatus)
|
||||||
|
status?: UserStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ArrayUnique, IsArray, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class ReplaceUserRolesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
roleIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class ResetUserPasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(12)
|
||||||
|
@MaxLength(128)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
mustChangePassword = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateUserDto {
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
|
)
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(80)
|
||||||
|
@Matches(/^[a-zA-Z0-9._-]+$/)
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
|
)
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(320)
|
||||||
|
email?: string | null;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(120)
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(120)
|
||||||
|
lastName?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../../common/http/request-context';
|
||||||
|
import { ChangeUserStatusDto } from './dto/change-user-status.dto';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { ListUsersQueryDto } from './dto/list-users-query.dto';
|
||||||
|
import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
|
||||||
|
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
|
||||||
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('users.read')
|
||||||
|
list(@Query() query: ListUsersQueryDto) {
|
||||||
|
return this.users.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('users.create')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateUserDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.users.create(dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions('users.read')
|
||||||
|
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.users.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions('users.update')
|
||||||
|
update(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: UpdateUserDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.users.update(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
@RequirePermissions('users.change_status')
|
||||||
|
changeStatus(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: ChangeUserStatusDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.users.changeStatus(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reset-password')
|
||||||
|
@RequirePermissions('users.update')
|
||||||
|
resetPassword(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: ResetUserPasswordDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.users.resetPassword(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/roles')
|
||||||
|
@RequirePermissions('users.assign_roles')
|
||||||
|
replaceRoles(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: ReplaceUserRolesDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.users.replaceRoles(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager, In } from 'typeorm';
|
||||||
|
import { AuditService } from '../../audit/audit.service';
|
||||||
|
import { PasswordService } from '../../auth/services/password.service';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../../common/http/request-context';
|
||||||
|
import { AuthSessionsRepository } from '../../core-data/repositories/auth-sessions.repository';
|
||||||
|
import {
|
||||||
|
AuditAction,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
UserRole,
|
||||||
|
UserStatus,
|
||||||
|
} from '../../database/entities';
|
||||||
|
import {
|
||||||
|
administrationAuditContext,
|
||||||
|
assertAdministrativeRecoveryRemains,
|
||||||
|
isUniqueViolation,
|
||||||
|
} from '../common/administration-audit';
|
||||||
|
import type { ChangeUserStatusDto } from './dto/change-user-status.dto';
|
||||||
|
import type { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import type { ListUsersQueryDto } from './dto/list-users-query.dto';
|
||||||
|
import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
|
||||||
|
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
|
||||||
|
import type { UpdateUserDto } from './dto/update-user.dto';
|
||||||
|
|
||||||
|
export interface UserRoleView {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdministrativeUserView {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
status: UserStatus;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
failedLoginAttempts: number;
|
||||||
|
lockedUntil: Date | null;
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
passwordChangedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
roles: UserRoleView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserViewRow extends AdministrativeUserView {
|
||||||
|
total?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'USER_NOT_FOUND',
|
||||||
|
message: 'Usuario no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleSelectionInvalid(): BadRequestException {
|
||||||
|
return new BadRequestException({
|
||||||
|
code: 'ROLE_NOT_FOUND',
|
||||||
|
message: 'Uno o más roles no existen',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly passwords: PasswordService,
|
||||||
|
private readonly sessions: AuthSessionsRepository,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(query: ListUsersQueryDto) {
|
||||||
|
const page = query.page;
|
||||||
|
const pageSize = query.pageSize;
|
||||||
|
const filters: string[] = [];
|
||||||
|
const parameters: unknown[] = [];
|
||||||
|
const search = query.search?.trim();
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
parameters.push(`%${search}%`);
|
||||||
|
filters.push(`
|
||||||
|
(
|
||||||
|
user_account.username ILIKE $${parameters.length}
|
||||||
|
OR user_account.email ILIKE $${parameters.length}
|
||||||
|
OR user_account.first_name ILIKE $${parameters.length}
|
||||||
|
OR user_account.last_name ILIKE $${parameters.length}
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
parameters.push(query.status);
|
||||||
|
filters.push(`user_account.status = $${parameters.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters.push(pageSize, (page - 1) * pageSize);
|
||||||
|
const limitParameter = parameters.length - 1;
|
||||||
|
const offsetParameter = parameters.length;
|
||||||
|
const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const rows = (await this.dataSource.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
user_account.id,
|
||||||
|
user_account.username,
|
||||||
|
user_account.email,
|
||||||
|
user_account.first_name AS "firstName",
|
||||||
|
user_account.last_name AS "lastName",
|
||||||
|
user_account.status,
|
||||||
|
user_account.must_change_password AS "mustChangePassword",
|
||||||
|
user_account.failed_login_attempts AS "failedLoginAttempts",
|
||||||
|
user_account.locked_until AS "lockedUntil",
|
||||||
|
user_account.last_login_at AS "lastLoginAt",
|
||||||
|
user_account.password_changed_at AS "passwordChangedAt",
|
||||||
|
user_account.created_at AS "createdAt",
|
||||||
|
user_account.updated_at AS "updatedAt",
|
||||||
|
COALESCE(
|
||||||
|
JSONB_AGG(
|
||||||
|
JSONB_BUILD_OBJECT(
|
||||||
|
'id', role.id,
|
||||||
|
'code', role.code,
|
||||||
|
'name', role.name
|
||||||
|
) ORDER BY role.code
|
||||||
|
) FILTER (WHERE role.id IS NOT NULL),
|
||||||
|
'[]'::jsonb
|
||||||
|
) AS roles,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM users user_account
|
||||||
|
LEFT JOIN user_roles user_role
|
||||||
|
ON user_role.user_id = user_account.id
|
||||||
|
LEFT JOIN roles role ON role.id = user_role.role_id
|
||||||
|
${where}
|
||||||
|
GROUP BY user_account.id
|
||||||
|
ORDER BY user_account.created_at DESC, user_account.username ASC
|
||||||
|
LIMIT $${limitParameter} OFFSET $${offsetParameter}
|
||||||
|
`,
|
||||||
|
parameters,
|
||||||
|
)) as UserViewRow[];
|
||||||
|
|
||||||
|
const total = rows.length > 0 ? Number(rows[0].total ?? 0) : 0;
|
||||||
|
return {
|
||||||
|
data: rows.map(({ total: _total, ...row }) => row),
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<AdministrativeUserView> {
|
||||||
|
return this.dataSource.transaction(async (manager) =>
|
||||||
|
this.loadUserView(manager, id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateUserDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AdministrativeUserView> {
|
||||||
|
const passwordHash = await this.passwords.hash(dto.password);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const roles = await this.resolveRoles(manager, dto.roleIds);
|
||||||
|
const user = manager.getRepository(User).create({
|
||||||
|
username: dto.username.trim().toLowerCase(),
|
||||||
|
email: dto.email?.trim().toLowerCase() || null,
|
||||||
|
passwordHash,
|
||||||
|
firstName: dto.firstName.trim(),
|
||||||
|
lastName: dto.lastName.trim(),
|
||||||
|
status: UserStatus.ACTIVE,
|
||||||
|
mustChangePassword: dto.mustChangePassword,
|
||||||
|
failedLoginAttempts: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
lastLoginAt: null,
|
||||||
|
passwordChangedAt: null,
|
||||||
|
createdBy: principal.userId,
|
||||||
|
updatedBy: principal.userId,
|
||||||
|
});
|
||||||
|
await manager.getRepository(User).save(user);
|
||||||
|
await this.insertUserRoles(
|
||||||
|
manager,
|
||||||
|
user.id,
|
||||||
|
roles,
|
||||||
|
principal.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = await this.loadUserView(manager, user.id);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.USER_CREATED,
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: user.id,
|
||||||
|
afterData: { ...created },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) throw this.userConflict();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateUserDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AdministrativeUserView> {
|
||||||
|
if (
|
||||||
|
dto.username === undefined &&
|
||||||
|
dto.email === undefined &&
|
||||||
|
dto.firstName === undefined &&
|
||||||
|
dto.lastName === undefined
|
||||||
|
) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'NO_CHANGES',
|
||||||
|
message: 'No se recibieron cambios',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const user = await this.lockUser(manager, id);
|
||||||
|
const before = await this.loadUserView(manager, id);
|
||||||
|
|
||||||
|
if (dto.username !== undefined) {
|
||||||
|
user.username = dto.username.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
if (dto.email !== undefined) {
|
||||||
|
user.email = dto.email?.trim().toLowerCase() || null;
|
||||||
|
}
|
||||||
|
if (dto.firstName !== undefined) user.firstName = dto.firstName.trim();
|
||||||
|
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
|
||||||
|
user.updatedBy = principal.userId;
|
||||||
|
await manager.getRepository(User).save(user);
|
||||||
|
|
||||||
|
const updated = await this.loadUserView(manager, id);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.USER_UPDATED,
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: { ...before },
|
||||||
|
afterData: { ...updated },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) throw this.userConflict();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeStatus(
|
||||||
|
id: string,
|
||||||
|
dto: ChangeUserStatusDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AdministrativeUserView> {
|
||||||
|
if (id === principal.userId && dto.status === UserStatus.INACTIVE) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'SELF_DEACTIVATION_FORBIDDEN',
|
||||||
|
message: 'No puede desactivar su propio usuario',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const user = await this.lockUser(manager, id);
|
||||||
|
const before = await this.loadUserView(manager, id);
|
||||||
|
if (user.status === dto.status) return before;
|
||||||
|
|
||||||
|
user.status = dto.status;
|
||||||
|
user.updatedBy = principal.userId;
|
||||||
|
if (dto.status === UserStatus.ACTIVE) {
|
||||||
|
user.failedLoginAttempts = 0;
|
||||||
|
user.lockedUntil = null;
|
||||||
|
}
|
||||||
|
await manager.getRepository(User).save(user);
|
||||||
|
|
||||||
|
if (dto.status === UserStatus.INACTIVE) {
|
||||||
|
await this.sessions.revokeUserSessions(id, undefined, manager);
|
||||||
|
}
|
||||||
|
await assertAdministrativeRecoveryRemains(manager);
|
||||||
|
|
||||||
|
const updated = await this.loadUserView(manager, id);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.USER_STATUS_CHANGED,
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: { status: before.status },
|
||||||
|
afterData: { status: updated.status },
|
||||||
|
metadata: {
|
||||||
|
sessionsRevoked: dto.status === UserStatus.INACTIVE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(
|
||||||
|
id: string,
|
||||||
|
dto: ResetUserPasswordDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AdministrativeUserView> {
|
||||||
|
const passwordHash = await this.passwords.hash(dto.password);
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const user = await this.lockUser(manager, id);
|
||||||
|
const before = await this.loadUserView(manager, id);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
user.passwordHash = passwordHash;
|
||||||
|
user.passwordChangedAt = now;
|
||||||
|
user.mustChangePassword = dto.mustChangePassword;
|
||||||
|
user.failedLoginAttempts = 0;
|
||||||
|
user.lockedUntil = null;
|
||||||
|
user.updatedBy = principal.userId;
|
||||||
|
await manager.getRepository(User).save(user);
|
||||||
|
await this.sessions.revokeUserSessions(id, undefined, manager);
|
||||||
|
|
||||||
|
const updated = await this.loadUserView(manager, id);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.USER_PASSWORD_RESET,
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: {
|
||||||
|
mustChangePassword: before.mustChangePassword,
|
||||||
|
failedLoginAttempts: before.failedLoginAttempts,
|
||||||
|
lockedUntil: before.lockedUntil,
|
||||||
|
},
|
||||||
|
afterData: {
|
||||||
|
mustChangePassword: updated.mustChangePassword,
|
||||||
|
failedLoginAttempts: updated.failedLoginAttempts,
|
||||||
|
lockedUntil: updated.lockedUntil,
|
||||||
|
passwordChangedAt: updated.passwordChangedAt,
|
||||||
|
},
|
||||||
|
metadata: { sessionsRevoked: true, passwordValueRecorded: false },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceRoles(
|
||||||
|
id: string,
|
||||||
|
dto: ReplaceUserRolesDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AdministrativeUserView> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.lockUser(manager, id);
|
||||||
|
const roles = await this.resolveRoles(manager, dto.roleIds);
|
||||||
|
const before = await this.loadUserView(manager, id);
|
||||||
|
const beforeIds = before.roles.map((role) => role.id).sort();
|
||||||
|
const afterIds = roles.map((role) => role.id).sort();
|
||||||
|
if (beforeIds.join(',') === afterIds.join(',')) return before;
|
||||||
|
|
||||||
|
await manager.getRepository(UserRole).delete({ userId: id });
|
||||||
|
await this.insertUserRoles(manager, id, roles, principal.userId);
|
||||||
|
await assertAdministrativeRecoveryRemains(manager);
|
||||||
|
|
||||||
|
const updated = await this.loadUserView(manager, id);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.USER_ROLES_CHANGED,
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: { roles: before.roles },
|
||||||
|
afterData: { roles: updated.roles },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRoles(
|
||||||
|
manager: EntityManager,
|
||||||
|
roleIds: string[],
|
||||||
|
): Promise<Role[]> {
|
||||||
|
const uniqueIds = [...new Set(roleIds)];
|
||||||
|
if (uniqueIds.length === 0) return [];
|
||||||
|
const roles = await manager.getRepository(Role).find({
|
||||||
|
where: { id: In(uniqueIds) },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
if (roles.length !== uniqueIds.length) throw roleSelectionInvalid();
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async insertUserRoles(
|
||||||
|
manager: EntityManager,
|
||||||
|
userId: string,
|
||||||
|
roles: Role[],
|
||||||
|
assignedBy: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (roles.length === 0) return;
|
||||||
|
const assignments = roles.map((role) =>
|
||||||
|
manager.getRepository(UserRole).create({
|
||||||
|
userId,
|
||||||
|
roleId: role.id,
|
||||||
|
assignedBy,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await manager.getRepository(UserRole).save(assignments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockUser(manager: EntityManager, id: string): Promise<User> {
|
||||||
|
const user = await manager
|
||||||
|
.getRepository(User)
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.where('user.id = :id', { id })
|
||||||
|
.setLock('pessimistic_write')
|
||||||
|
.getOne();
|
||||||
|
if (!user) throw userNotFound();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadUserView(
|
||||||
|
manager: EntityManager,
|
||||||
|
id: string,
|
||||||
|
): Promise<AdministrativeUserView> {
|
||||||
|
const [row] = (await manager.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
user_account.id,
|
||||||
|
user_account.username,
|
||||||
|
user_account.email,
|
||||||
|
user_account.first_name AS "firstName",
|
||||||
|
user_account.last_name AS "lastName",
|
||||||
|
user_account.status,
|
||||||
|
user_account.must_change_password AS "mustChangePassword",
|
||||||
|
user_account.failed_login_attempts AS "failedLoginAttempts",
|
||||||
|
user_account.locked_until AS "lockedUntil",
|
||||||
|
user_account.last_login_at AS "lastLoginAt",
|
||||||
|
user_account.password_changed_at AS "passwordChangedAt",
|
||||||
|
user_account.created_at AS "createdAt",
|
||||||
|
user_account.updated_at AS "updatedAt",
|
||||||
|
COALESCE(
|
||||||
|
JSONB_AGG(
|
||||||
|
JSONB_BUILD_OBJECT(
|
||||||
|
'id', role.id,
|
||||||
|
'code', role.code,
|
||||||
|
'name', role.name
|
||||||
|
) ORDER BY role.code
|
||||||
|
) FILTER (WHERE role.id IS NOT NULL),
|
||||||
|
'[]'::jsonb
|
||||||
|
) AS roles
|
||||||
|
FROM users user_account
|
||||||
|
LEFT JOIN user_roles user_role
|
||||||
|
ON user_role.user_id = user_account.id
|
||||||
|
LEFT JOIN roles role ON role.id = user_role.role_id
|
||||||
|
WHERE user_account.id = $1
|
||||||
|
GROUP BY user_account.id
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
)) as AdministrativeUserView[];
|
||||||
|
if (!row) throw userNotFound();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private userConflict(): ConflictException {
|
||||||
|
return new ConflictException({
|
||||||
|
code: 'USER_ALREADY_EXISTS',
|
||||||
|
message: 'El usuario o email ya está registrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
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 { AdministrationModule } from './administration/administration.module';
|
||||||
|
import { AuthorizationModule } from './authorization/authorization.module';
|
||||||
|
import { PermissionsGuard } from './authorization/guards/permissions.guard';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { AccessTokenGuard } from './auth/guards/access-token.guard';
|
||||||
|
import { CsrfGuard } from './auth/guards/csrf.guard';
|
||||||
|
import { PhaseADataModule } from './core-data/phase-a-data.module';
|
||||||
|
import { 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 { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
|
||||||
|
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
|
||||||
|
import { ActAdministrationModule } from './act-administration/act-administration.module';
|
||||||
|
|
||||||
|
function required(config: ConfigService, key: string): string {
|
||||||
|
const value = config.get<string>(key);
|
||||||
|
if (!value) throw new Error(`Missing required environment variable: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
ignoreEnvFile: true,
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
type: 'postgres',
|
||||||
|
host: required(config, 'DB_HOST'),
|
||||||
|
port: Number(config.get<string>('DB_PORT') ?? 5432),
|
||||||
|
database: required(config, 'DB_NAME'),
|
||||||
|
username: required(config, 'DB_APP_USER'),
|
||||||
|
password: required(config, 'DB_APP_PASSWORD'),
|
||||||
|
autoLoadEntities: true,
|
||||||
|
synchronize: false,
|
||||||
|
migrationsRun: false,
|
||||||
|
logging: false,
|
||||||
|
applicationName: 'dhv2-api',
|
||||||
|
connectTimeoutMS: 5000,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
ttl: 60_000,
|
||||||
|
limit: 120,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
PhaseADataModule,
|
||||||
|
AuditModule,
|
||||||
|
AuthorizationModule,
|
||||||
|
AuthModule,
|
||||||
|
AdministrationModule,
|
||||||
|
DashboardModule,
|
||||||
|
AssetMasterModule,
|
||||||
|
SurveyPlanningModule,
|
||||||
|
SurveyExecutionModule,
|
||||||
|
InspectionVisitsModule,
|
||||||
|
InspectionActsModule,
|
||||||
|
InspectionFindingsModule,
|
||||||
|
InspectionClosingModule,
|
||||||
|
InspectionReportsModule,
|
||||||
|
InspectionVerificationsModule,
|
||||||
|
ActAdministrationModule,
|
||||||
|
AssetImportsModule,
|
||||||
|
],
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [
|
||||||
|
HealthService,
|
||||||
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: AccessTokenGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: CsrfGuard },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { execFile as execFileCallback } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
const execFile = promisify(execFileCallback);
|
||||||
|
|
||||||
|
export const MAX_ASSET_IMPORT_BYTES = 25 * 1024 * 1024;
|
||||||
|
const MAX_XLSX_UNCOMPRESSED_BYTES = 120 * 1024 * 1024;
|
||||||
|
const MAX_XLSX_ENTRIES = 2500;
|
||||||
|
const MAX_ROWS_PER_SHEET = 50_000;
|
||||||
|
const MAX_COLUMNS = 120;
|
||||||
|
|
||||||
|
export interface UploadedImportFile {
|
||||||
|
originalname: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedSheet {
|
||||||
|
name: string;
|
||||||
|
rows: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedWorkbook {
|
||||||
|
kind: 'XLSX' | 'CSV';
|
||||||
|
sheets: ParsedSheet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function importFileError(code: string, message: string): BadRequestException {
|
||||||
|
return new BadRequestException({ code, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectImportFile(file: UploadedImportFile | undefined): { extension: '.xlsx' | '.csv'; mimeType: string } {
|
||||||
|
if (!file?.buffer?.length) throw importFileError('IMPORT_FILE_REQUIRED', 'Seleccioná un archivo XLSX o CSV');
|
||||||
|
if (file.buffer.length > MAX_ASSET_IMPORT_BYTES) throw importFileError('IMPORT_FILE_TOO_LARGE', 'El archivo supera el límite de 25 MB');
|
||||||
|
const lower = file.originalname.toLowerCase();
|
||||||
|
if (lower.endsWith('.xlsx')) {
|
||||||
|
if (!(file.buffer[0] === 0x50 && file.buffer[1] === 0x4b)) {
|
||||||
|
throw importFileError('INVALID_XLSX_FILE', 'El contenido no corresponde a un archivo XLSX válido');
|
||||||
|
}
|
||||||
|
return { extension: '.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' };
|
||||||
|
}
|
||||||
|
if (lower.endsWith('.csv')) {
|
||||||
|
if (file.buffer.includes(0)) throw importFileError('INVALID_CSV_FILE', 'El CSV contiene datos binarios no admitidos');
|
||||||
|
return { extension: '.csv', mimeType: 'text/csv' };
|
||||||
|
}
|
||||||
|
throw importFileError('UNSUPPORTED_IMPORT_FILE', 'Sólo se admiten archivos .xlsx y .csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
||||||
|
.replace(/&#([0-9]+);/g, (_match, decimal: string) => String.fromCodePoint(Number.parseInt(decimal, 10)))
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripXmlText(xml: string): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const match of xml.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)) parts.push(decodeXml(match[1] ?? ''));
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnIndex(reference: string): number {
|
||||||
|
const match = /^([A-Z]+)\d+$/i.exec(reference);
|
||||||
|
if (!match) return -1;
|
||||||
|
let result = 0;
|
||||||
|
for (const char of match[1]!.toUpperCase()) result = result * 26 + (char.charCodeAt(0) - 64);
|
||||||
|
return result - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zipList(filePath: string): Promise<string[]> {
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
({ stdout } = await execFile('unzip', ['-Z1', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }));
|
||||||
|
} catch {
|
||||||
|
throw importFileError('XLSX_UNZIP_UNAVAILABLE', 'No se pudo inspeccionar el XLSX. Verificá que el archivo no esté dañado');
|
||||||
|
}
|
||||||
|
const entries = stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
|
||||||
|
if (entries.length > MAX_XLSX_ENTRIES) throw importFileError('XLSX_TOO_COMPLEX', 'El XLSX contiene demasiados archivos internos');
|
||||||
|
if (entries.some((entry) => entry.startsWith('/') || entry.split('/').includes('..'))) {
|
||||||
|
throw importFileError('INVALID_XLSX_PATH', 'El XLSX contiene rutas internas inválidas');
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertZipSize(filePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFile('unzip', ['-l', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
|
||||||
|
const summary = stdout.split(/\r?\n/).reverse().find((line) => /\bfiles?\b/.test(line));
|
||||||
|
const bytes = summary ? Number(/^\s*(\d+)/.exec(summary)?.[1] ?? 0) : 0;
|
||||||
|
if (Number.isFinite(bytes) && bytes > MAX_XLSX_UNCOMPRESSED_BYTES) {
|
||||||
|
throw importFileError('XLSX_UNCOMPRESSED_TOO_LARGE', 'El contenido descomprimido del XLSX supera el límite de seguridad');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BadRequestException) throw error;
|
||||||
|
throw importFileError('INVALID_XLSX_FILE', 'No se pudo leer la estructura interna del XLSX');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zipEntry(filePath: string, entry: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFile('unzip', ['-p', filePath, entry], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: MAX_XLSX_UNCOMPRESSED_BYTES,
|
||||||
|
});
|
||||||
|
return stdout;
|
||||||
|
} catch {
|
||||||
|
throw importFileError('INVALID_XLSX_FILE', `No se pudo leer ${entry} dentro del XLSX`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function workbookSheets(workbookXml: string, relationshipsXml: string): Array<{ name: string; path: string }> {
|
||||||
|
const relationTargets = new Map<string, string>();
|
||||||
|
for (const relation of relationshipsXml.matchAll(/<Relationship\b([^>]*)\/?\s*>/g)) {
|
||||||
|
const attributes = relation[1] ?? '';
|
||||||
|
const id = /\bId="([^"]+)"/.exec(attributes)?.[1];
|
||||||
|
const target = /\bTarget="([^"]+)"/.exec(attributes)?.[1];
|
||||||
|
if (id && target) relationTargets.set(id, target);
|
||||||
|
}
|
||||||
|
const result: Array<{ name: string; path: string }> = [];
|
||||||
|
for (const sheet of workbookXml.matchAll(/<sheet\b([^>]*)\/?\s*>/g)) {
|
||||||
|
const attributes = sheet[1] ?? '';
|
||||||
|
const name = decodeXml(/\bname="([^"]+)"/.exec(attributes)?.[1] ?? 'Hoja');
|
||||||
|
const relationId = /\br:id="([^"]+)"/.exec(attributes)?.[1];
|
||||||
|
if (!relationId) continue;
|
||||||
|
const target = relationTargets.get(relationId);
|
||||||
|
if (!target) continue;
|
||||||
|
const clean = target.replace(/^\//, '');
|
||||||
|
const path = clean.startsWith('xl/') ? clean : `xl/${clean.replace(/^\.\//, '')}`;
|
||||||
|
result.push({ name, path });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSharedStrings(xml: string): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const match of xml.matchAll(/<si(?:\s[^>]*)?>([\s\S]*?)<\/si>/g)) result.push(stripXmlText(match[1] ?? ''));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellValue(cellXml: string, cellType: string | undefined, sharedStrings: string[]): string {
|
||||||
|
if (cellType === 'inlineStr') return stripXmlText(cellXml).trim();
|
||||||
|
const raw = /<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(cellXml)?.[1] ?? '';
|
||||||
|
const value = decodeXml(raw);
|
||||||
|
if (cellType === 's') {
|
||||||
|
const index = Number.parseInt(value, 10);
|
||||||
|
return Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
|
||||||
|
}
|
||||||
|
if (cellType === 'b') return value === '1' ? 'TRUE' : 'FALSE';
|
||||||
|
if (cellType === 'str') return value;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSheetXml(xml: string, sharedStrings: string[]): string[][] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let count = 0;
|
||||||
|
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
|
||||||
|
if (++count > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `La hoja supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`);
|
||||||
|
const values: string[] = [];
|
||||||
|
const rowXml = rowMatch[1] ?? '';
|
||||||
|
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||||
|
const attributes = cellMatch[1] ?? '';
|
||||||
|
const reference = /\br="([A-Z]+\d+)"/i.exec(attributes)?.[1];
|
||||||
|
if (!reference) continue;
|
||||||
|
const index = columnIndex(reference);
|
||||||
|
if (index < 0 || index >= MAX_COLUMNS) continue;
|
||||||
|
const type = /\bt="([^"]+)"/.exec(attributes)?.[1];
|
||||||
|
values[index] = cellValue(cellMatch[2] ?? '', type, sharedStrings).trim();
|
||||||
|
}
|
||||||
|
while (values.length && !values[values.length - 1]) values.pop();
|
||||||
|
rows.push(values.map((value) => value ?? ''));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectDelimiter(firstLines: string[]): ',' | ';' | '\t' {
|
||||||
|
const candidates: Array<',' | ';' | '\t'> = [',', ';', '\t'];
|
||||||
|
let best: ',' | ';' | '\t' = ',';
|
||||||
|
let score = -1;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const current = firstLines.reduce((sum, line) => sum + line.split(candidate).length - 1, 0);
|
||||||
|
if (current > score) { score = current; best = candidate; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCsv(text: string): string[][] {
|
||||||
|
const normalized = text.replace(/^\uFEFF/, '');
|
||||||
|
const sample = normalized.split(/\r?\n/).slice(0, 8);
|
||||||
|
const delimiter = detectDelimiter(sample);
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let value = '';
|
||||||
|
let quoted = false;
|
||||||
|
for (let i = 0; i < normalized.length; i += 1) {
|
||||||
|
const char = normalized[i]!;
|
||||||
|
if (char === '"') {
|
||||||
|
if (quoted && normalized[i + 1] === '"') { value += '"'; i += 1; }
|
||||||
|
else quoted = !quoted;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!quoted && char === delimiter) { row.push(value.trim()); value = ''; continue; }
|
||||||
|
if (!quoted && (char === '\n' || char === '\r')) {
|
||||||
|
if (char === '\r' && normalized[i + 1] === '\n') i += 1;
|
||||||
|
row.push(value.trim()); value = '';
|
||||||
|
if (row.some(Boolean)) rows.push(row);
|
||||||
|
row = [];
|
||||||
|
if (rows.length > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `El archivo supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
value += char;
|
||||||
|
}
|
||||||
|
if (value.length || row.length) { row.push(value.trim()); if (row.some(Boolean)) rows.push(row); }
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseImportWorkbook(filePath: string, extension: '.xlsx' | '.csv'): Promise<ParsedWorkbook> {
|
||||||
|
if (extension === '.csv') {
|
||||||
|
const { readFile } = await import('node:fs/promises');
|
||||||
|
const text = await readFile(filePath, 'utf8');
|
||||||
|
return { kind: 'CSV', sheets: [{ name: 'CSV', rows: parseCsv(text) }] };
|
||||||
|
}
|
||||||
|
await assertZipSize(filePath);
|
||||||
|
const entries = await zipList(filePath);
|
||||||
|
if (!entries.includes('xl/workbook.xml') || !entries.includes('xl/_rels/workbook.xml.rels')) {
|
||||||
|
throw importFileError('INVALID_XLSX_FILE', 'El archivo no contiene una estructura XLSX compatible');
|
||||||
|
}
|
||||||
|
const [workbookXml, relationshipsXml] = await Promise.all([
|
||||||
|
zipEntry(filePath, 'xl/workbook.xml'),
|
||||||
|
zipEntry(filePath, 'xl/_rels/workbook.xml.rels'),
|
||||||
|
]);
|
||||||
|
const sharedStrings = entries.includes('xl/sharedStrings.xml')
|
||||||
|
? parseSharedStrings(await zipEntry(filePath, 'xl/sharedStrings.xml'))
|
||||||
|
: [];
|
||||||
|
const sheets = workbookSheets(workbookXml, relationshipsXml);
|
||||||
|
if (!sheets.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'El XLSX no contiene hojas legibles');
|
||||||
|
const parsed: ParsedSheet[] = [];
|
||||||
|
for (const sheet of sheets.slice(0, 30)) {
|
||||||
|
if (!entries.includes(sheet.path)) continue;
|
||||||
|
const xml = await zipEntry(filePath, sheet.path);
|
||||||
|
parsed.push({ name: sheet.name, rows: parseSheetXml(xml, sharedStrings) });
|
||||||
|
}
|
||||||
|
if (!parsed.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'No se pudo leer ninguna hoja del XLSX');
|
||||||
|
return { kind: 'XLSX', sheets: parsed };
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
export type AssetImportPlanEntityKind = 'DEPARTMENT' | 'ORGANIZATION' | 'AREA' | 'AREA_DEPARTMENT_RELATION' | 'FIELD' | 'OPERATOR_RELATION' | 'LEGAL_RIGHT' | 'LEGAL_RIGHT_ORGANIZATION' | 'INSTALLATION' | 'LOCAL_STRUCTURE' | 'TECHNICAL_ASSET';
|
||||||
|
export type AssetImportPlanAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
|
||||||
|
export type AssetImportPlanItemStatus = 'PLANNED' | 'MATCHED' | 'REVIEW' | 'IGNORED' | 'APPLIED' | 'ROLLED_BACK' | 'FAILED';
|
||||||
|
export type AssetImportPlanStatus = 'REVIEW_REQUIRED' | 'READY' | 'APPLIED' | 'ROLLED_BACK' | 'SUPERSEDED' | 'FAILED';
|
||||||
|
|
||||||
|
export interface AssetImportPlanDraftItem {
|
||||||
|
entityKey: string;
|
||||||
|
entityKind: AssetImportPlanEntityKind;
|
||||||
|
action: AssetImportPlanAction;
|
||||||
|
status: AssetImportPlanItemStatus;
|
||||||
|
assetTypeCode: string | null;
|
||||||
|
displayName: string;
|
||||||
|
generatedCode: string | null;
|
||||||
|
parentEntityKey: string | null;
|
||||||
|
matchedAssetId: string | null;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
sourceRowNumbers: number[];
|
||||||
|
reviewCodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function plainImportKey(value: unknown): string {
|
||||||
|
return String(value ?? '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function organizationImportKey(value: unknown): string {
|
||||||
|
const tokens = plainImportKey(value).split(' ').filter(Boolean);
|
||||||
|
const output: string[] = [];
|
||||||
|
for (let index = 0; index < tokens.length;) {
|
||||||
|
if (tokens[index]!.length !== 1) { output.push(tokens[index]!); index += 1; continue; }
|
||||||
|
const letters: string[] = [];
|
||||||
|
let cursor = index;
|
||||||
|
while (cursor < tokens.length && tokens[cursor]!.length === 1) { letters.push(tokens[cursor]!); cursor += 1; }
|
||||||
|
output.push(letters.length >= 2 ? letters.join('') : letters[0]!);
|
||||||
|
index = cursor;
|
||||||
|
}
|
||||||
|
return output.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function isExplicitlyUnassignedOperator(value: unknown): boolean {
|
||||||
|
return plainImportKey(value) === 'sin empresa operadora';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type NormalizedLegalRightType = 'EXPLOITATION_CONCESSION' | 'EXPLORATION_PERMIT' | 'TRANSPORT_CONCESSION' | 'OTHER';
|
||||||
|
|
||||||
|
export function normalizedLegalRightType(value: unknown): NormalizedLegalRightType | null {
|
||||||
|
const normalized = plainImportKey(value);
|
||||||
|
if (normalized === 'explotacion') return 'EXPLOITATION_CONCESSION';
|
||||||
|
if (normalized === 'exploracion') return 'EXPLORATION_PERMIT';
|
||||||
|
if (normalized === 'transporte' || normalized === 'concesion de transporte') return 'TRANSPORT_CONCESSION';
|
||||||
|
if (!normalized) return null;
|
||||||
|
return 'OTHER';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legalRightTypeLabel(value: NormalizedLegalRightType): string {
|
||||||
|
if (value === 'EXPLOITATION_CONCESSION') return 'Concesión de explotación';
|
||||||
|
if (value === 'EXPLORATION_PERMIT') return 'Permiso de exploración';
|
||||||
|
if (value === 'TRANSPORT_CONCESSION') return 'Concesión de transporte';
|
||||||
|
return 'Otro derecho';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function departmentCode(value: unknown): string {
|
||||||
|
const normalized = String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 70);
|
||||||
|
return normalized || 'SIN-DEPARTAMENTO';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function externalIdNamespace(input: string | null | undefined, originalName: string): string {
|
||||||
|
const seed = (input?.trim() || originalName.replace(/\.[^.]+$/, '').split(/[-_]/)[0] || 'IMPORT').normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
const normalized = seed.toUpperCase().replace(/[^A-Z0-9._/-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
||||||
|
if (normalized.length >= 2 && /^[A-Z0-9]/.test(normalized)) return normalized;
|
||||||
|
return 'IMPORT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatedImportCode(planId: string, typeCode: string, entityKey: string): string {
|
||||||
|
const prefix = typeCode.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'ACT';
|
||||||
|
const digest = createHash('sha256').update(`${planId}\u001f${entityKey}`).digest('hex').slice(0, 12).toUpperCase();
|
||||||
|
return `IMP-${prefix}-${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function technicalFamilyTypeCode(family: unknown, subtype: unknown): string {
|
||||||
|
const normalizedFamily = plainImportKey(family).replace(/ /g, '_');
|
||||||
|
const normalizedSubtype = plainImportKey(subtype).replace(/ /g, '_');
|
||||||
|
const direct = new Set(['tanque','separador','bomba','caldera','antorcha','colector','filtro','calentador','ducto','pozo']);
|
||||||
|
if (direct.has(normalizedFamily)) return normalizedFamily;
|
||||||
|
if (normalizedFamily === 'pileta') return 'pileta_api';
|
||||||
|
if (normalizedFamily === 'defensa_incendios') return 'sistema_defensa_incendios';
|
||||||
|
if (normalizedFamily === 'instalacion' && normalizedSubtype === 'planta') return 'planta';
|
||||||
|
if (normalizedFamily === 'instalacion' && normalizedSubtype === 'bateria') return 'bateria';
|
||||||
|
return 'equipo';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface SourceLocalStructureSuggestion {
|
||||||
|
displayName: string;
|
||||||
|
sourcePath: string[];
|
||||||
|
sourceInstallation: string | null;
|
||||||
|
sourceSubInstallation: string | null;
|
||||||
|
concreteFromLocation: boolean;
|
||||||
|
sourceGroupOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceLocalStructureSuggestion(
|
||||||
|
areaOrField: unknown,
|
||||||
|
installation: unknown,
|
||||||
|
subInstallation: unknown,
|
||||||
|
location: unknown,
|
||||||
|
): SourceLocalStructureSuggestion | null {
|
||||||
|
const areaText = String(areaOrField ?? '').trim();
|
||||||
|
const installationText = String(installation ?? '').trim();
|
||||||
|
const subInstallationText = String(subInstallation ?? '').trim();
|
||||||
|
const locationText = String(location ?? '').trim();
|
||||||
|
const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value));
|
||||||
|
const sourcePath = locationText.split('/').map((segment) => segment.trim()).filter((segment) => !unusable(segment));
|
||||||
|
const areaKey = plainImportKey(areaText);
|
||||||
|
const installationKey = plainImportKey(installationText);
|
||||||
|
const subInstallationKey = plainImportKey(subInstallationText);
|
||||||
|
const genericProvinceKeys = new Set(['mendoza', 'provincia de mendoza']);
|
||||||
|
const genericLocationKeys = new Set([
|
||||||
|
'yacimiento','planta','energia','edilicio','transporte','repositorio','repositorios','op digitales',
|
||||||
|
'bateria','set','pta','ptc','em','pcg','et','oficina','estacion de servicio','taller','almacen',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const contextualSegments = sourcePath.filter((segment, index) => {
|
||||||
|
const key = plainImportKey(segment);
|
||||||
|
if (!key) return false;
|
||||||
|
if (index === 0 && genericProvinceKeys.has(key)) return false;
|
||||||
|
if (areaKey && key === areaKey) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const concrete = [...contextualSegments].reverse().find((segment) => {
|
||||||
|
const key = plainImportKey(segment);
|
||||||
|
if (!key) return false;
|
||||||
|
if (installationKey && key === installationKey) return false;
|
||||||
|
if (subInstallationKey && key === subInstallationKey) return false;
|
||||||
|
if (/^pozo\b/.test(key)) return false;
|
||||||
|
return !genericLocationKeys.has(key);
|
||||||
|
}) ?? null;
|
||||||
|
|
||||||
|
if (concrete) {
|
||||||
|
return {
|
||||||
|
displayName: concrete.slice(0, 200),
|
||||||
|
sourcePath,
|
||||||
|
sourceInstallation: unusable(installationText) ? null : installationText,
|
||||||
|
sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText,
|
||||||
|
concreteFromLocation: true,
|
||||||
|
sourceGroupOnly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryParts = [installationText, subInstallationText]
|
||||||
|
.filter((value) => !unusable(value))
|
||||||
|
.filter((value, index, values) => values.findIndex((candidate) => plainImportKey(candidate) === plainImportKey(value)) === index);
|
||||||
|
if (!categoryParts.length) return null;
|
||||||
|
return {
|
||||||
|
displayName: categoryParts.join(' / ').slice(0, 200),
|
||||||
|
sourcePath,
|
||||||
|
sourceInstallation: unusable(installationText) ? null : installationText,
|
||||||
|
sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText,
|
||||||
|
concreteFromLocation: false,
|
||||||
|
sourceGroupOnly: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function sourceContainerTypeCode(installation: unknown, subInstallation?: unknown): string {
|
||||||
|
const parent = plainImportKey(installation);
|
||||||
|
const sub = plainImportKey(subInstallation);
|
||||||
|
if (sub === 'bateria') return 'bateria';
|
||||||
|
if (['pta', 'ptc', 'pcg'].includes(sub)) return 'planta';
|
||||||
|
if (sub === 'estacion de servicio') return 'estacion';
|
||||||
|
if (/\bplanta\b/.test(parent)) return 'planta';
|
||||||
|
if (/\bbateria\b/.test(parent)) return 'bateria';
|
||||||
|
if (/\bsubestacion\b/.test(parent)) return 'subestacion';
|
||||||
|
if (/\bestacion\b/.test(parent)) return 'estacion';
|
||||||
|
if (/\blocacion\b/.test(parent)) return 'locacion';
|
||||||
|
return 'instalacion';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceContainerName(installation: unknown, subInstallation: unknown, location?: unknown): string | null {
|
||||||
|
const sub = String(subInstallation ?? '').trim();
|
||||||
|
const parent = String(installation ?? '').trim();
|
||||||
|
const locationText = String(location ?? '').trim();
|
||||||
|
const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value));
|
||||||
|
const genericSub = new Set(['bateria','set','pta','ptc','em','pcg','et','transporte','oficina','repositorio','repositorios','estacion de servicio','taller','op digitales','almacen']);
|
||||||
|
const subKey = plainImportKey(sub);
|
||||||
|
if (!unusable(sub) && !genericSub.has(subKey)) return sub;
|
||||||
|
|
||||||
|
const segments = locationText.split('/').map((segment) => segment.trim()).filter(Boolean);
|
||||||
|
const prefixes: Record<string, RegExp> = {
|
||||||
|
bateria: /^BAT[A-Z0-9-]/i,
|
||||||
|
pta: /^PTA[A-Z0-9-]/i,
|
||||||
|
ptc: /^(PTC[A-Z0-9-]|ESTACION DE BOMBEO)/i,
|
||||||
|
pcg: /^PCG[A-Z0-9-]/i,
|
||||||
|
};
|
||||||
|
const prefix = prefixes[subKey];
|
||||||
|
if (prefix) {
|
||||||
|
const candidate = segments.find((segment) => plainImportKey(segment) !== subKey && prefix.test(segment));
|
||||||
|
if (candidate) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unusable(parent) && !['planta','bateria','estacion','subestacion','yacimiento','locacion','energia','edilicio'].includes(plainImportKey(parent))) return parent;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planItemHash(items: Array<Pick<AssetImportPlanDraftItem, 'entityKey' | 'entityKind' | 'action' | 'status' | 'assetTypeCode' | 'displayName' | 'generatedCode' | 'parentEntityKey' | 'matchedAssetId' | 'payload' | 'sourceRowNumbers' | 'reviewCodes'>>): string {
|
||||||
|
const canonical = items
|
||||||
|
.map((item) => ({
|
||||||
|
entityKey: item.entityKey,
|
||||||
|
entityKind: item.entityKind,
|
||||||
|
action: item.action,
|
||||||
|
status: item.status,
|
||||||
|
assetTypeCode: item.assetTypeCode,
|
||||||
|
displayName: item.displayName,
|
||||||
|
generatedCode: item.generatedCode,
|
||||||
|
parentEntityKey: item.parentEntityKey,
|
||||||
|
matchedAssetId: item.matchedAssetId,
|
||||||
|
payload: item.payload,
|
||||||
|
sourceRowNumbers: [...item.sourceRowNumbers].sort((a, b) => a - b),
|
||||||
|
reviewCodes: [...item.reviewCodes].sort(),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.entityKey.localeCompare(b.entityKey));
|
||||||
|
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLAN_DEPENDENCY_REVIEW_CODES = new Set([
|
||||||
|
'PLAN_DEPARTMENT_REVIEW_REQUIRED',
|
||||||
|
'PLAN_LEGAL_RIGHT_REVIEW_REQUIRED',
|
||||||
|
'PLAN_AREA_REVIEW_REQUIRED',
|
||||||
|
'PLAN_ORGANIZATION_REVIEW_REQUIRED',
|
||||||
|
'PLAN_TERRITORY_CONTEXT_REQUIRED',
|
||||||
|
'PLAN_CONTEXT_DECISION_REQUIRED',
|
||||||
|
'PLAN_CONTAINER_REVIEW_REQUIRED',
|
||||||
|
'PLAN_PARENT_NOT_RESOLVED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isPlanDependencyReview(item: Pick<AssetImportPlanDraftItem, 'action' | 'reviewCodes'>): boolean {
|
||||||
|
return item.action === 'REVIEW'
|
||||||
|
&& item.reviewCodes.length > 0
|
||||||
|
&& item.reviewCodes.every((code) => PLAN_DEPENDENCY_REVIEW_CODES.has(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planDependencyKeys(item: Pick<AssetImportPlanDraftItem, 'entityKey' | 'parentEntityKey' | 'payload'>): string[] {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
if (item.parentEntityKey) keys.add(item.parentEntityKey);
|
||||||
|
for (const key of ['areaEntityKey','organizationEntityKey','departmentEntityKey','legalRightEntityKey','operationalAreaEntityKey','operatorEntityKey','localStructureEntityKey']) {
|
||||||
|
const value = item.payload[key];
|
||||||
|
if (typeof value === 'string' && value.trim()) keys.add(value.trim());
|
||||||
|
}
|
||||||
|
keys.delete(item.entityKey);
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safePlanItems<T extends Pick<AssetImportPlanDraftItem, 'entityKey' | 'parentEntityKey' | 'payload' | 'action' | 'status'>>(items: T[]): T[] {
|
||||||
|
const byKey = new Map(items.map((item) => [item.entityKey, item]));
|
||||||
|
const memo = new Map<string, boolean>();
|
||||||
|
const visiting = new Set<string>();
|
||||||
|
const isSafe = (item: T): boolean => {
|
||||||
|
if (item.status === 'APPLIED') return true;
|
||||||
|
if (item.action === 'REVIEW') return false;
|
||||||
|
const cached = memo.get(item.entityKey);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
if (visiting.has(item.entityKey)) return false;
|
||||||
|
visiting.add(item.entityKey);
|
||||||
|
const safe = planDependencyKeys(item).every((key) => {
|
||||||
|
const dependency = byKey.get(key);
|
||||||
|
return !dependency || isSafe(dependency);
|
||||||
|
});
|
||||||
|
visiting.delete(item.entityKey);
|
||||||
|
memo.set(item.entityKey, safe);
|
||||||
|
return safe;
|
||||||
|
};
|
||||||
|
return items.filter((item) => isSafe(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planStatusForItems(items: Array<Pick<AssetImportPlanDraftItem, 'action'>>): AssetImportPlanStatus {
|
||||||
|
return items.some((item) => item.action === 'REVIEW') ? 'REVIEW_REQUIRED' : 'READY';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizePlanItems(items: AssetImportPlanDraftItem[]): Record<string, unknown> {
|
||||||
|
const actionCounts = { create: 0, match: 0, review: 0, ignore: 0 };
|
||||||
|
let directReviewItems = 0;
|
||||||
|
let dependencyReviewItems = 0;
|
||||||
|
let appliedCreateItems = 0;
|
||||||
|
let pendingCreateItems = 0;
|
||||||
|
const byKind: Record<string, { create: number; match: number; review: number; ignore: number; total: number }> = {};
|
||||||
|
for (const item of items) {
|
||||||
|
const action = item.action.toLowerCase() as keyof typeof actionCounts;
|
||||||
|
actionCounts[action] += 1;
|
||||||
|
if (item.action === 'CREATE') {
|
||||||
|
if (item.status === 'APPLIED') appliedCreateItems += 1;
|
||||||
|
else pendingCreateItems += 1;
|
||||||
|
}
|
||||||
|
if (item.action === 'REVIEW') {
|
||||||
|
if (isPlanDependencyReview(item)) dependencyReviewItems += 1;
|
||||||
|
else directReviewItems += 1;
|
||||||
|
}
|
||||||
|
const current = byKind[item.entityKind] ?? { create: 0, match: 0, review: 0, ignore: 0, total: 0 };
|
||||||
|
current[action] += 1;
|
||||||
|
current.total += 1;
|
||||||
|
byKind[item.entityKind] = current;
|
||||||
|
}
|
||||||
|
const safeCreateItems = safePlanItems(items).filter((item) => item.action === 'CREATE' && item.status !== 'APPLIED').length;
|
||||||
|
return {
|
||||||
|
totalItems: items.length,
|
||||||
|
createItems: actionCounts.create,
|
||||||
|
matchItems: actionCounts.match,
|
||||||
|
reviewItems: actionCounts.review,
|
||||||
|
directReviewItems,
|
||||||
|
dependencyReviewItems,
|
||||||
|
ignoreItems: actionCounts.ignore,
|
||||||
|
appliedCreateItems,
|
||||||
|
pendingCreateItems,
|
||||||
|
safeCreateItems,
|
||||||
|
blockedCreateItems: Math.max(0, pendingCreateItems - safeCreateItems),
|
||||||
|
partialApplied: appliedCreateItems > 0 && actionCounts.review > 0,
|
||||||
|
byKind,
|
||||||
|
blocked: actionCounts.review > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { ParsedSheet, ParsedWorkbook } from './asset-import-parser';
|
||||||
|
|
||||||
|
export type AssetImportProfileCode = 'MENDOZA_INVENTORY_V1' | 'MENDOZA_YACIMIENTOS_V1' | 'UNKNOWN';
|
||||||
|
export type AssetImportRowStatus = 'READY' | 'WARNING' | 'CONFLICT' | 'IGNORED';
|
||||||
|
export type AssetImportSuggestedAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
|
||||||
|
|
||||||
|
export interface ImportProfileDetection {
|
||||||
|
profileCode: AssetImportProfileCode;
|
||||||
|
confidence: number;
|
||||||
|
sheetName: string;
|
||||||
|
headerRow: number;
|
||||||
|
columnMap: Record<string, number>;
|
||||||
|
detectedHeaders: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportNormalizedRow {
|
||||||
|
profileCode: AssetImportProfileCode;
|
||||||
|
rowNumber: number;
|
||||||
|
sheetName: string;
|
||||||
|
raw: Record<string, string>;
|
||||||
|
normalized: Record<string, unknown>;
|
||||||
|
status: AssetImportRowStatus;
|
||||||
|
suggestedAction: AssetImportSuggestedAction;
|
||||||
|
issues: string[];
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAnalysis {
|
||||||
|
detection: ImportProfileDetection;
|
||||||
|
rows: ImportNormalizedRow[];
|
||||||
|
summary: {
|
||||||
|
totalRows: number;
|
||||||
|
readyRows: number;
|
||||||
|
warningRows: number;
|
||||||
|
conflictRows: number;
|
||||||
|
ignoredRows: number;
|
||||||
|
issueCounts: Record<string, number>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function plain(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function compact(value: string): string {
|
||||||
|
return plain(value).replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const inventoryAliases: Record<string, string[]> = {
|
||||||
|
item: ['item', 'n item', 'numero item'],
|
||||||
|
areaOrField: ['area yacimiento', 'area/yacimiento', 'area y yacimiento', 'area yacimiento '],
|
||||||
|
installation: ['instalacion', 'intalacion'],
|
||||||
|
subInstallation: ['sub instalacion', 'subinstalacion'],
|
||||||
|
equipment: ['equipo'],
|
||||||
|
equipmentDenomination: ['denominacion del equipo', 'denominacion equipo'],
|
||||||
|
location: ['ubicacion del equipo o instalacion', 'ubicacion equipo o instalacion', 'ubicacion'],
|
||||||
|
inventoryId: ['n id inventario', 'n° id inventario', 'nº id inventario', 'id inventario', 'numero id inventario'],
|
||||||
|
quantity: ['cantidad'],
|
||||||
|
technicalSpecs: ['especificaciones tecnicas', 'especificacion tecnica'],
|
||||||
|
sourceStatus: ['estado en servicio fuera de servicio', 'estado', 'estado servicio'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldAliases: Record<string, string[]> = {
|
||||||
|
field: ['yacimiento', 'nombre yacimiento'],
|
||||||
|
area: ['area', 'area hidrocarburifera'],
|
||||||
|
department: ['departamento'],
|
||||||
|
rightType: ['tipo concesion', 'tipo de concesion', 'tipo permiso', 'tipo'],
|
||||||
|
operator: ['operadora', 'operador', 'empresa operadora'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function aliasScore(header: string, aliases: string[]): number {
|
||||||
|
const normalized = plain(header);
|
||||||
|
const normalizedCompact = compact(header);
|
||||||
|
let best = 0;
|
||||||
|
for (const alias of aliases) {
|
||||||
|
const a = plain(alias);
|
||||||
|
const ac = compact(alias);
|
||||||
|
if (normalized === a || normalizedCompact === ac) best = Math.max(best, 10 + a.length / 100);
|
||||||
|
else if (normalized.includes(a) || a.includes(normalized)) best = Math.max(best, 6 + Math.min(a.length, normalized.length) / 100);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHeaders(headers: string[], aliases: Record<string, string[]>): { map: Record<string, number>; score: number } {
|
||||||
|
const candidates: Array<{ field: string; index: number; score: number }> = [];
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
Object.entries(aliases).forEach(([field, values]) => {
|
||||||
|
const score = aliasScore(header, values);
|
||||||
|
if (score > 0) candidates.push({ field, index, score });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
candidates.sort((a, b) => b.score - a.score);
|
||||||
|
const usedFields = new Set<string>();
|
||||||
|
const usedIndexes = new Set<number>();
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
let score = 0;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (usedFields.has(candidate.field) || usedIndexes.has(candidate.index)) continue;
|
||||||
|
usedFields.add(candidate.field);
|
||||||
|
usedIndexes.add(candidate.index);
|
||||||
|
map[candidate.field] = candidate.index;
|
||||||
|
score += candidate.score;
|
||||||
|
}
|
||||||
|
return { map, score };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestHeader(sheet: ParsedSheet, aliases: Record<string, string[]>): { row: number; map: Record<string, number>; score: number; headers: string[] } {
|
||||||
|
let best = { row: 0, map: {} as Record<string, number>, score: -1, headers: [] as string[] };
|
||||||
|
for (let index = 0; index < Math.min(sheet.rows.length, 30); index += 1) {
|
||||||
|
const headers = sheet.rows[index] ?? [];
|
||||||
|
const mapped = mapHeaders(headers, aliases);
|
||||||
|
if (mapped.score > best.score) best = { row: index + 1, map: mapped.map, score: mapped.score, headers };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectImportProfile(workbook: ParsedWorkbook): ImportProfileDetection {
|
||||||
|
let best: ImportProfileDetection = { profileCode: 'UNKNOWN', confidence: 0, sheetName: workbook.sheets[0]?.name ?? 'Hoja', headerRow: 1, columnMap: {}, detectedHeaders: [] };
|
||||||
|
for (const sheet of workbook.sheets) {
|
||||||
|
const inventory = bestHeader(sheet, inventoryAliases);
|
||||||
|
const inventoryRequired = ['areaOrField', 'installation', 'equipment', 'inventoryId'];
|
||||||
|
const inventoryHits = inventoryRequired.filter((field) => inventory.map[field] !== undefined).length;
|
||||||
|
const inventoryConfidence = Math.min(100, Math.round((inventory.score / 95) * 100));
|
||||||
|
if (inventoryHits >= 3 && inventoryConfidence > best.confidence) {
|
||||||
|
best = { profileCode: 'MENDOZA_INVENTORY_V1', confidence: inventoryConfidence, sheetName: sheet.name, headerRow: inventory.row, columnMap: inventory.map, detectedHeaders: inventory.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = bestHeader(sheet, fieldAliases);
|
||||||
|
const fieldRequired = ['field', 'area', 'operator'];
|
||||||
|
const fieldHits = fieldRequired.filter((field) => fields.map[field] !== undefined).length;
|
||||||
|
const fieldConfidence = Math.min(100, Math.round((fields.score / 55) * 100));
|
||||||
|
if (fieldHits >= 2 && fieldConfidence > best.confidence) {
|
||||||
|
best = { profileCode: 'MENDOZA_YACIMIENTOS_V1', confidence: fieldConfidence, sheetName: sheet.name, headerRow: fields.row, columnMap: fields.map, detectedHeaders: fields.headers };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(row: string[], index: number | undefined): string {
|
||||||
|
return index === undefined ? '' : (row[index] ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: string): number | null {
|
||||||
|
if (!value.trim()) return null;
|
||||||
|
const normalized = value.trim().replace(/\s+/g, '').replace(',', '.');
|
||||||
|
const number = Number(normalized);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoryStatusSuggestion(value: string): { operationalStatus?: string; conditionStatus?: string; ambiguous: boolean } {
|
||||||
|
const normalized = plain(value);
|
||||||
|
if (!normalized) return { ambiguous: false };
|
||||||
|
if (['en servicio', 'servicio', 'operativo', 'operativa'].includes(normalized)) return { operationalStatus: 'IN_SERVICE', ambiguous: false };
|
||||||
|
if (['fuera de servicio', 'f servicio', 'f serv', 'fs', 'f s'].includes(normalized)) return { operationalStatus: 'OUT_OF_SERVICE', ambiguous: false };
|
||||||
|
if (['bueno', 'buena'].includes(normalized)) return { conditionStatus: 'GOOD', ambiguous: true };
|
||||||
|
if (['regular'].includes(normalized)) return { conditionStatus: 'FAIR', ambiguous: true };
|
||||||
|
if (['malo', 'mala'].includes(normalized)) return { conditionStatus: 'POOR', ambiguous: true };
|
||||||
|
if (['si', 'no', 's', 'n'].includes(normalized)) return { ambiguous: true };
|
||||||
|
return { ambiguous: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitClassType(value: string): { sourceClass: string | null; sourceSubtype: string | null } {
|
||||||
|
const classMatch = /clase\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||||
|
const typeMatch = /tipo\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||||
|
return { sourceClass: classMatch, sourceSubtype: typeMatch };
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitManufacturerModel(value: string): { manufacturer: string | null; model: string | null } {
|
||||||
|
const manufacturer = /fabricante\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||||
|
const model = /modelo\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||||
|
if (manufacturer || model) return { manufacturer, model };
|
||||||
|
const parts = value.split('|').map((part) => part.trim()).filter(Boolean);
|
||||||
|
if (parts.length >= 3) return { manufacturer: parts[1] ?? null, model: parts[2] ?? null };
|
||||||
|
return { manufacturer: null, model: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TechnicalNormalizationSuggestion {
|
||||||
|
family: string | null;
|
||||||
|
subtype: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function technicalNormalizationSuggestion(
|
||||||
|
equipment: string,
|
||||||
|
denomination: string,
|
||||||
|
sourceClass: string | null,
|
||||||
|
sourceSubtype: string | null,
|
||||||
|
): TechnicalNormalizationSuggestion {
|
||||||
|
const source = plain(`${sourceClass ?? ''} ${sourceSubtype ?? ''} ${equipment} ${denomination}`);
|
||||||
|
const classCode = plain(sourceClass ?? '');
|
||||||
|
const subtypeCode = plain(sourceSubtype ?? '');
|
||||||
|
|
||||||
|
if (/rectificador/.test(plain(equipment))) return { family: 'equipo_electrico', subtype: 'rectificador' };
|
||||||
|
const equipmentOnly = plain(equipment);
|
||||||
|
if (/^psv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'seguridad' };
|
||||||
|
if (/^vpsv$|^vpv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'presion_vacio' };
|
||||||
|
if (/^aib$/.test(equipmentOnly)) return { family: 'sistema_extraccion', subtype: 'aib' };
|
||||||
|
if (/seccionador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'seccionador' };
|
||||||
|
if (/interruptor/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'interruptor' };
|
||||||
|
if (/reconectador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'reconectador' };
|
||||||
|
if (/^colector$/.test(equipmentOnly)) return { family: 'colector', subtype: null };
|
||||||
|
if (/punto de medicion/.test(equipmentOnly)) return { family: 'instrumentacion', subtype: 'punto_medicion' };
|
||||||
|
if (/sistema rci|^rci$/.test(equipmentOnly)) return { family: 'defensa_incendios', subtype: null };
|
||||||
|
if (/aeroenfriador/.test(equipmentOnly)) return { family: 'aeroenfriador', subtype: null };
|
||||||
|
if (/^bateria$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'bateria' };
|
||||||
|
if (/^planta$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'planta' };
|
||||||
|
if (classCode === 'bba' || /\bbomba\b/.test(source)) {
|
||||||
|
if (/\bcen\b|centrifug/.test(source)) return { family: 'bomba', subtype: 'centrifuga' };
|
||||||
|
if (/\btx\b|triplex|quintuplex|alternativa/.test(source)) return { family: 'bomba', subtype: 'alternativa' };
|
||||||
|
if (/tornillo/.test(source)) return { family: 'bomba', subtype: 'tornillo' };
|
||||||
|
if (/diafragma/.test(source)) return { family: 'bomba', subtype: 'diafragma' };
|
||||||
|
return { family: 'bomba', subtype: null };
|
||||||
|
}
|
||||||
|
if (classCode === 'tk' || /\btanque\b|\btk\b/.test(source)) return { family: 'tanque', subtype: null };
|
||||||
|
if (classCode === 'sep' || /separador/.test(source)) {
|
||||||
|
if (/sep b|bifasic/.test(source)) return { family: 'separador', subtype: 'bifasico' };
|
||||||
|
if (/sep g|gas/.test(source)) return { family: 'separador', subtype: 'gas' };
|
||||||
|
return { family: 'separador', subtype: null };
|
||||||
|
}
|
||||||
|
if (classCode === 'cald' || /\bcaldera\b/.test(source)) return { family: 'caldera', subtype: null };
|
||||||
|
if (classCode === 'cal' || /calentador|hot oil/.test(source)) return { family: 'calentador', subtype: null };
|
||||||
|
if (classCode === 'ant' || /antorcha|flare/.test(source)) return { family: 'antorcha', subtype: /frio/.test(source) ? 'venteo_frio' : null };
|
||||||
|
if (classCode === 'fil' || /\bfiltro\b/.test(source)) return { family: 'filtro', subtype: /arena/.test(source) ? 'arena' : null };
|
||||||
|
if (classCode === 'tra' || /transformador/.test(source)) return { family: 'equipo_electrico', subtype: 'transformador' };
|
||||||
|
if (classCode === 'moe' || /motor electr/.test(source)) return { family: 'motor', subtype: 'electrico' };
|
||||||
|
if (classCode === 'moex' || /motor de combustion|motor a explosion/.test(source)) return { family: 'motor', subtype: 'combustion' };
|
||||||
|
if (classCode === 'com' || /compresor|soplador/.test(source)) return { family: 'compresor', subtype: null };
|
||||||
|
if (classCode === 'va' || /valvula/.test(source)) {
|
||||||
|
if (/vpv|presion y vacio/.test(source)) return { family: 'valvula', subtype: 'presion_vacio' };
|
||||||
|
if (/\bvs\b|seguridad/.test(source)) return { family: 'valvula', subtype: 'seguridad' };
|
||||||
|
if (/\bvr\b|reguladora/.test(source)) return { family: 'valvula', subtype: 'reguladora' };
|
||||||
|
return { family: 'valvula', subtype: null };
|
||||||
|
}
|
||||||
|
if (classCode === 'caud' || /caudalimetro/.test(source)) return { family: 'instrumentacion', subtype: 'caudalimetro' };
|
||||||
|
if (classCode === 'eg' || /generador|motogenerador/.test(source)) return { family: 'generador', subtype: null };
|
||||||
|
if (classCode === 'cel' || /\bcelda\b/.test(source)) return { family: 'equipo_electrico', subtype: 'celda' };
|
||||||
|
if (classCode === 'pil' || /pileta/.test(source)) return { family: 'pileta', subtype: null };
|
||||||
|
if (classCode === 'aib' || /aparato individual de bombeo|rotaflex/.test(source)) {
|
||||||
|
const subtype = /rotaflex/.test(source) ? 'rotaflex' : /mark ii/.test(source) ? 'mark_ii' : /convencional/.test(source) ? 'convencional' : null;
|
||||||
|
return { family: 'sistema_extraccion', subtype };
|
||||||
|
}
|
||||||
|
if (classCode === 'pcp' || /\bpcp\b/.test(source)) return { family: 'sistema_extraccion', subtype: 'pcp' };
|
||||||
|
if (/\bbes\b|electrosumerg/.test(source)) return { family: 'sistema_extraccion', subtype: 'bes' };
|
||||||
|
if (/\bpozo\b/.test(source)) return { family: 'pozo', subtype: null };
|
||||||
|
if (/oleoducto/.test(source)) return { family: 'ducto', subtype: 'oleoducto' };
|
||||||
|
if (/gasoducto/.test(source)) return { family: 'ducto', subtype: 'gasoducto' };
|
||||||
|
if (/acueducto/.test(source)) return { family: 'ducto', subtype: 'acueducto' };
|
||||||
|
if (/caneria/.test(source)) return { family: 'ducto', subtype: 'caneria' };
|
||||||
|
return { family: null, subtype: null };
|
||||||
|
}
|
||||||
|
function rawObject(headers: string[], row: string[]): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
if (!header.trim() && !row[index]?.trim()) return;
|
||||||
|
result[header.trim() || `Columna ${index + 1}`] = row[index]?.trim() ?? '';
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprint(value: Record<string, unknown>): string {
|
||||||
|
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInventory(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] {
|
||||||
|
const headers = sheet.rows[detection.headerRow - 1] ?? [];
|
||||||
|
const result: ImportNormalizedRow[] = [];
|
||||||
|
const map = detection.columnMap;
|
||||||
|
for (let index = detection.headerRow; index < sheet.rows.length; index += 1) {
|
||||||
|
const row = sheet.rows[index] ?? [];
|
||||||
|
if (!row.some((value) => value.trim())) continue;
|
||||||
|
const areaOrField = text(row, map.areaOrField);
|
||||||
|
const installation = text(row, map.installation);
|
||||||
|
const subInstallation = text(row, map.subInstallation);
|
||||||
|
const equipment = text(row, map.equipment);
|
||||||
|
const denomination = text(row, map.equipmentDenomination);
|
||||||
|
const location = text(row, map.location);
|
||||||
|
const inventoryId = text(row, map.inventoryId);
|
||||||
|
const quantityRaw = text(row, map.quantity);
|
||||||
|
const technicalSpecs = text(row, map.technicalSpecs);
|
||||||
|
const sourceStatus = text(row, map.sourceStatus);
|
||||||
|
const item = text(row, map.item);
|
||||||
|
const isHeaderRepeat = compact(areaOrField) === compact(headers[map.areaOrField ?? -1] ?? '') && compact(equipment) === compact(headers[map.equipment ?? -1] ?? '');
|
||||||
|
if (isHeaderRepeat) continue;
|
||||||
|
const issues: string[] = [];
|
||||||
|
if (!areaOrField) issues.push('MISSING_AREA_OR_YACIMIENTO');
|
||||||
|
if (!equipment && !denomination) issues.push('MISSING_EQUIPMENT_DESCRIPTION');
|
||||||
|
if (!inventoryId) issues.push('MISSING_INVENTORY_ID');
|
||||||
|
const quantity = asNumber(quantityRaw);
|
||||||
|
if (quantity !== null && (!Number.isInteger(quantity) || quantity <= 0)) issues.push('INVALID_QUANTITY');
|
||||||
|
if (quantity !== null && quantity > 1) issues.push('GROUPED_QUANTITY');
|
||||||
|
const sourceState = inventoryStatusSuggestion(sourceStatus);
|
||||||
|
if (sourceState.ambiguous && sourceStatus) issues.push('SOURCE_STATUS_REQUIRES_MAPPING');
|
||||||
|
const classType = splitClassType(denomination);
|
||||||
|
const manufacturerModel = splitManufacturerModel(technicalSpecs);
|
||||||
|
const technical = technicalNormalizationSuggestion(equipment, denomination, classType.sourceClass, classType.sourceSubtype);
|
||||||
|
const normalized: Record<string, unknown> = {
|
||||||
|
item: item || null,
|
||||||
|
areaOrField: areaOrField || null,
|
||||||
|
installation: installation || null,
|
||||||
|
subInstallation: subInstallation || null,
|
||||||
|
equipment: equipment || null,
|
||||||
|
sourceClassification: denomination || null,
|
||||||
|
location: location || null,
|
||||||
|
inventoryId: inventoryId || null,
|
||||||
|
quantity: quantity ?? (quantityRaw || null),
|
||||||
|
technicalSpecs: technicalSpecs || null,
|
||||||
|
sourceStatus: sourceStatus || null,
|
||||||
|
operationalStatusSuggestion: sourceState.operationalStatus ?? null,
|
||||||
|
conditionStatusSuggestion: sourceState.conditionStatus ?? null,
|
||||||
|
sourceClass: classType.sourceClass,
|
||||||
|
sourceSubtype: classType.sourceSubtype,
|
||||||
|
manufacturer: manufacturerModel.manufacturer,
|
||||||
|
model: manufacturerModel.model,
|
||||||
|
familySuggestion: technical.family,
|
||||||
|
normalizedFamily: technical.family,
|
||||||
|
normalizedSubtype: technical.subtype,
|
||||||
|
};
|
||||||
|
const conflict = issues.includes('MISSING_EQUIPMENT_DESCRIPTION') || issues.includes('INVALID_QUANTITY');
|
||||||
|
const status: AssetImportRowStatus = conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY';
|
||||||
|
result.push({
|
||||||
|
profileCode: 'MENDOZA_INVENTORY_V1',
|
||||||
|
rowNumber: index + 1,
|
||||||
|
sheetName: sheet.name,
|
||||||
|
raw: rawObject(headers, row),
|
||||||
|
normalized,
|
||||||
|
status,
|
||||||
|
suggestedAction: conflict ? 'REVIEW' : 'CREATE',
|
||||||
|
issues,
|
||||||
|
fingerprint: fingerprint(normalized),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return applyBatchDuplicateRules(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBatchDuplicateRules(rows: ImportNormalizedRow[]): ImportNormalizedRow[] {
|
||||||
|
const groups = new Map<string, ImportNormalizedRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const id = String(row.normalized.inventoryId ?? '').trim().toLowerCase();
|
||||||
|
if (!id) continue;
|
||||||
|
const area = plain(String(row.normalized.areaOrField ?? ''));
|
||||||
|
const key = `${area}|${id}`;
|
||||||
|
const current = groups.get(key) ?? [];
|
||||||
|
current.push(row);
|
||||||
|
groups.set(key, current);
|
||||||
|
}
|
||||||
|
for (const group of groups.values()) {
|
||||||
|
if (group.length < 2) continue;
|
||||||
|
const locations = new Set(group.map((row) => plain(`${row.normalized.installation ?? ''}|${row.normalized.subInstallation ?? ''}|${row.normalized.location ?? ''}`)));
|
||||||
|
const issue = locations.size > 1 ? 'INVENTORY_ID_MULTIPLE_LOCATIONS' : 'DUPLICATE_INVENTORY_ID_IN_BATCH';
|
||||||
|
for (const row of group) {
|
||||||
|
if (!row.issues.includes(issue)) row.issues.push(issue);
|
||||||
|
if (issue === 'INVENTORY_ID_MULTIPLE_LOCATIONS') {
|
||||||
|
row.status = 'CONFLICT';
|
||||||
|
row.suggestedAction = 'REVIEW';
|
||||||
|
} else if (row.status === 'READY') {
|
||||||
|
row.status = 'WARNING';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFields(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] {
|
||||||
|
const headers = sheet.rows[detection.headerRow - 1] ?? [];
|
||||||
|
const result: ImportNormalizedRow[] = [];
|
||||||
|
const map = detection.columnMap;
|
||||||
|
for (let index = detection.headerRow; index < sheet.rows.length; index += 1) {
|
||||||
|
const row = sheet.rows[index] ?? [];
|
||||||
|
if (!row.some((value) => value.trim())) continue;
|
||||||
|
const field = text(row, map.field);
|
||||||
|
const area = text(row, map.area);
|
||||||
|
const department = text(row, map.department);
|
||||||
|
const rightType = text(row, map.rightType);
|
||||||
|
const operator = text(row, map.operator);
|
||||||
|
const issues: string[] = [];
|
||||||
|
if (!field) issues.push('MISSING_FIELD');
|
||||||
|
if (!area) issues.push('MISSING_AREA');
|
||||||
|
if (!operator) issues.push('MISSING_OPERATOR');
|
||||||
|
const normalized: Record<string, unknown> = {
|
||||||
|
field: field || null,
|
||||||
|
area: area || null,
|
||||||
|
department: department || null,
|
||||||
|
rightType: rightType || null,
|
||||||
|
operator: operator || null,
|
||||||
|
};
|
||||||
|
const conflict = !field || !area;
|
||||||
|
result.push({
|
||||||
|
profileCode: 'MENDOZA_YACIMIENTOS_V1',
|
||||||
|
rowNumber: index + 1,
|
||||||
|
sheetName: sheet.name,
|
||||||
|
raw: rawObject(headers, row),
|
||||||
|
normalized,
|
||||||
|
status: conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY',
|
||||||
|
suggestedAction: conflict ? 'REVIEW' : 'CREATE',
|
||||||
|
issues,
|
||||||
|
fingerprint: fingerprint(normalized),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(rows: ImportNormalizedRow[]): ImportAnalysis['summary'] {
|
||||||
|
const issueCounts: Record<string, number> = {};
|
||||||
|
rows.forEach((row) => row.issues.forEach((issue) => { issueCounts[issue] = (issueCounts[issue] ?? 0) + 1; }));
|
||||||
|
return {
|
||||||
|
totalRows: rows.length,
|
||||||
|
readyRows: rows.filter((row) => row.status === 'READY').length,
|
||||||
|
warningRows: rows.filter((row) => row.status === 'WARNING').length,
|
||||||
|
conflictRows: rows.filter((row) => row.status === 'CONFLICT').length,
|
||||||
|
ignoredRows: rows.filter((row) => row.status === 'IGNORED').length,
|
||||||
|
issueCounts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeImportWorkbook(workbook: ParsedWorkbook, forcedProfile?: AssetImportProfileCode): ImportAnalysis {
|
||||||
|
const detected = detectImportProfile(workbook);
|
||||||
|
const profileCode = forcedProfile && forcedProfile !== 'UNKNOWN' ? forcedProfile : detected.profileCode;
|
||||||
|
if (profileCode === 'UNKNOWN') {
|
||||||
|
return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } };
|
||||||
|
}
|
||||||
|
const aliases = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? fieldAliases : inventoryAliases;
|
||||||
|
let selected: { sheet: ParsedSheet; header: ReturnType<typeof bestHeader> } | null = null;
|
||||||
|
for (const candidate of workbook.sheets) {
|
||||||
|
const header = bestHeader(candidate, aliases);
|
||||||
|
if (!selected || header.score > selected.header.score) selected = { sheet: candidate, header };
|
||||||
|
}
|
||||||
|
if (!selected) {
|
||||||
|
return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } };
|
||||||
|
}
|
||||||
|
const denominator = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? 55 : 95;
|
||||||
|
const confidence = Math.min(100, Math.max(0, Math.round((selected.header.score / denominator) * 100)));
|
||||||
|
const detection: ImportProfileDetection = { profileCode, confidence, sheetName: selected.sheet.name, headerRow: selected.header.row, columnMap: selected.header.map, detectedHeaders: selected.header.headers };
|
||||||
|
const rows = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? normalizeFields(selected.sheet, detection) : normalizeInventory(selected.sheet, detection);
|
||||||
|
return { detection, rows, summary: summarize(rows) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileLabel(code: AssetImportProfileCode): string {
|
||||||
|
if (code === 'MENDOZA_INVENTORY_V1') return 'Inventario de instalaciones · Mendoza';
|
||||||
|
if (code === 'MENDOZA_YACIMIENTOS_V1') return 'Tabla Área / Yacimiento · Mendoza';
|
||||||
|
return 'Formato no reconocido';
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { AssetImportsService, MAX_ASSET_IMPORT_BYTES, type UploadedImportFile } from './asset-imports.service';
|
||||||
|
import { ApplyAssetImportPlanDto } from './dto/apply-asset-import-plan.dto';
|
||||||
|
import { CancelAssetImportDto } from './dto/cancel-asset-import.dto';
|
||||||
|
import { CreateAssetImportPlanDto } from './dto/create-asset-import-plan.dto';
|
||||||
|
import { ResolveAssetImportPlanItemDto } from './dto/resolve-asset-import-plan-item.dto';
|
||||||
|
import { RollbackAssetImportPlanDto } from './dto/rollback-asset-import-plan.dto';
|
||||||
|
import { ListAssetImportPlanItemsQueryDto } from './dto/list-asset-import-plan-items-query.dto';
|
||||||
|
import { ListAssetImportReviewsQueryDto } from './dto/list-asset-import-reviews-query.dto';
|
||||||
|
import { ListAssetImportRowsQueryDto } from './dto/list-asset-import-rows-query.dto';
|
||||||
|
import { ListAssetImportsQueryDto } from './dto/list-asset-imports-query.dto';
|
||||||
|
import { UploadAssetImportDto } from './dto/upload-asset-import.dto';
|
||||||
|
|
||||||
|
@Controller('asset-imports')
|
||||||
|
export class AssetImportsController {
|
||||||
|
constructor(private readonly imports: AssetImportsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('asset_imports.read')
|
||||||
|
list(@Query() query: ListAssetImportsQueryDto) {
|
||||||
|
return this.imports.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('asset_imports.manage')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ASSET_IMPORT_BYTES, files: 1 } }))
|
||||||
|
upload(
|
||||||
|
@Body() dto: UploadAssetImportDto,
|
||||||
|
@UploadedFile() file: UploadedImportFile | undefined,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.imports.upload(dto, file, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('context/organizations')
|
||||||
|
@RequirePermissions('asset_imports.read')
|
||||||
|
organizations(@Query('search') search?: string) {
|
||||||
|
return this.imports.organizationOptions(search);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('reviews')
|
||||||
|
@RequirePermissions('asset_imports.read')
|
||||||
|
reviews(@Query() query: ListAssetImportReviewsQueryDto) {
|
||||||
|
return this.imports.reviews(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions('asset_imports.read')
|
||||||
|
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.imports.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/plan')
|
||||||
|
@RequirePermissions('asset_imports.read')
|
||||||
|
plan(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.imports.plan(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/plan/items')
|
||||||
|
@RequirePermissions('asset_imports.read')
|
||||||
|
planItems(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Query() query: ListAssetImportPlanItemsQueryDto,
|
||||||
|
) {
|
||||||
|
return this.imports.planItems(id, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/plan')
|
||||||
|
@RequirePermissions('asset_imports.manage')
|
||||||
|
generatePlan(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: CreateAssetImportPlanDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.imports.generatePlan(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/plan/items/:itemId/resolve')
|
||||||
|
@RequirePermissions('asset_imports.manage')
|
||||||
|
resolvePlanItem(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Param('itemId', new ParseUUIDPipe({ version: '4' })) itemId: string,
|
||||||
|
@Body() dto: ResolveAssetImportPlanItemDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.imports.resolvePlanItem(id, itemId, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/apply-safe')
|
||||||
|
@RequirePermissions('asset_imports.apply')
|
||||||
|
applySafePlan(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: ApplyAssetImportPlanDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.imports.applySafePlan(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/apply')
|
||||||
|
@RequirePermissions('asset_imports.apply')
|
||||||
|
applyPlan(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: ApplyAssetImportPlanDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.imports.applyPlan(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/rollback')
|
||||||
|
@RequirePermissions('asset_imports.apply')
|
||||||
|
rollbackPlan(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: RollbackAssetImportPlanDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.imports.rollbackPlan(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/rows')
|
||||||
|
@RequirePermissions('asset_imports.read')
|
||||||
|
rows(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Query() query: ListAssetImportRowsQueryDto,
|
||||||
|
) {
|
||||||
|
return this.imports.rows(id, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reconcile')
|
||||||
|
@RequirePermissions('asset_imports.manage')
|
||||||
|
reconcile(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.imports.reconcile(id, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/cancel')
|
||||||
|
@RequirePermissions('asset_imports.manage')
|
||||||
|
cancel(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: CancelAssetImportDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.imports.cancel(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { AssetImportsController } from './asset-imports.controller';
|
||||||
|
import { AssetImportsService } from './asset-imports.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuditModule],
|
||||||
|
controllers: [AssetImportsController],
|
||||||
|
providers: [AssetImportsService],
|
||||||
|
})
|
||||||
|
export class AssetImportsModule {}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class ApplyAssetImportPlanDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[0-9a-f]{64}$/)
|
||||||
|
planHash!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CancelAssetImportDto {
|
||||||
|
@IsString() @MinLength(5) @MaxLength(500) reason!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsOptional, IsString, IsUUID, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAssetImportPlanDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operatorAssetId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
@Matches(/^[A-Z0-9][A-Z0-9._/-]{1,79}$/i)
|
||||||
|
externalIdNamespace?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
const entityKinds = [
|
||||||
|
'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION',
|
||||||
|
'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export class ListAssetImportPlanItemsQueryDto {
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50;
|
||||||
|
@IsOptional() @IsIn(entityKinds) entityKind?: typeof entityKinds[number];
|
||||||
|
@IsOptional() @IsIn(['CREATE','MATCH','REVIEW','IGNORE']) action?: 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ListAssetImportReviewsQueryDto {
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 30;
|
||||||
|
@IsOptional() @IsIn(['ALL','DIRECT','DEPENDENCY']) kind: 'ALL' | 'DIRECT' | 'DEPENDENCY' = 'ALL';
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ListAssetImportRowsQueryDto {
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50;
|
||||||
|
@IsOptional() @IsIn(['READY','WARNING','CONFLICT','IGNORED']) status?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(160) search?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ListAssetImportsQueryDto {
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 20;
|
||||||
|
@IsOptional() @IsIn(['ANALYZED','REVIEW_REQUIRED','CANCELLED','FAILED']) status?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { IsIn, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
|
export class ResolveAssetImportPlanItemDto {
|
||||||
|
@IsIn(['CREATE', 'MATCH', 'IGNORE'])
|
||||||
|
action!: 'CREATE' | 'MATCH' | 'IGNORE';
|
||||||
|
|
||||||
|
@ValidateIf((value: ResolveAssetImportPlanItemDto) => value.action === 'MATCH')
|
||||||
|
@IsUUID('4')
|
||||||
|
matchedAssetId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(1000)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RollbackAssetImportPlanDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(1000)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
import type { AssetImportProfileCode } from '../asset-import-profiles';
|
||||||
|
|
||||||
|
export class UploadAssetImportDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(240)
|
||||||
|
sourceLabel?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['MENDOZA_INVENTORY_V1', 'MENDOZA_YACIMIENTOS_V1'])
|
||||||
|
profileCode?: Exclude<AssetImportProfileCode, 'UNKNOWN'>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AssetAttributeDataType,
|
||||||
|
AssetAttributeDefinition,
|
||||||
|
} from '../database/entities';
|
||||||
|
|
||||||
|
export interface NormalizedAttributeValue {
|
||||||
|
definitionId: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidAttribute(message: string, definitionId?: string): never {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVALID_ASSET_ATTRIBUTE',
|
||||||
|
message,
|
||||||
|
...(definitionId ? { definitionId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissing(value: unknown): boolean {
|
||||||
|
return value === undefined || value === null || value === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidCalendarDate(value: string): boolean {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||||
|
const [year, month, day] = value.split('-').map(Number);
|
||||||
|
const parsed = new Date(Date.UTC(year!, month! - 1, day));
|
||||||
|
return (
|
||||||
|
parsed.getUTCFullYear() === year &&
|
||||||
|
parsed.getUTCMonth() === month! - 1 &&
|
||||||
|
parsed.getUTCDate() === day
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeValue(
|
||||||
|
definition: AssetAttributeDefinition,
|
||||||
|
value: unknown,
|
||||||
|
): unknown {
|
||||||
|
switch (definition.dataType) {
|
||||||
|
case AssetAttributeDataType.TEXT:
|
||||||
|
if (typeof value !== 'string' || value.length > 4000) {
|
||||||
|
return invalidAttribute(
|
||||||
|
`El atributo ${definition.name} debe ser un texto de hasta 4000 caracteres`,
|
||||||
|
definition.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
|
||||||
|
case AssetAttributeDataType.NUMBER:
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return invalidAttribute(
|
||||||
|
`El atributo ${definition.name} debe ser numérico`,
|
||||||
|
definition.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
|
||||||
|
case AssetAttributeDataType.BOOLEAN:
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return invalidAttribute(
|
||||||
|
`El atributo ${definition.name} debe ser verdadero o falso`,
|
||||||
|
definition.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
|
||||||
|
case AssetAttributeDataType.DATE:
|
||||||
|
if (typeof value !== 'string' || !isValidCalendarDate(value)) {
|
||||||
|
return invalidAttribute(
|
||||||
|
`El atributo ${definition.name} debe ser una fecha válida`,
|
||||||
|
definition.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
|
||||||
|
case AssetAttributeDataType.DATETIME:
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
!value.trim() ||
|
||||||
|
!Number.isFinite(Date.parse(value))
|
||||||
|
) {
|
||||||
|
return invalidAttribute(
|
||||||
|
`El atributo ${definition.name} debe ser una fecha y hora válida`,
|
||||||
|
definition.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Date(value).toISOString();
|
||||||
|
|
||||||
|
case AssetAttributeDataType.SELECT: {
|
||||||
|
const options = definition.options ?? [];
|
||||||
|
if (typeof value !== 'string' || !options.includes(value)) {
|
||||||
|
return invalidAttribute(
|
||||||
|
`El atributo ${definition.name} no contiene una opción válida`,
|
||||||
|
definition.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAssetAttributeValues(
|
||||||
|
definitions: AssetAttributeDefinition[],
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
): NormalizedAttributeValue[] {
|
||||||
|
const activeDefinitions = definitions.filter((definition) => definition.isActive);
|
||||||
|
const byId = new Map(activeDefinitions.map((definition) => [definition.id, definition]));
|
||||||
|
|
||||||
|
for (const definitionId of Object.keys(values)) {
|
||||||
|
if (!byId.has(definitionId)) {
|
||||||
|
invalidAttribute('Se recibió un atributo que no pertenece al tipo de activo', definitionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: NormalizedAttributeValue[] = [];
|
||||||
|
for (const definition of activeDefinitions) {
|
||||||
|
const value = values[definition.id];
|
||||||
|
if (isMissing(value)) {
|
||||||
|
if (definition.isRequired) {
|
||||||
|
invalidAttribute(`El atributo ${definition.name} es obligatorio`, definition.id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized.push({
|
||||||
|
definitionId: definition.id,
|
||||||
|
value: normalizeValue(definition, value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../common/http/request-context';
|
||||||
|
import { AssetGeometriesService } from './asset-geometries.service';
|
||||||
|
import { MapAssetsQueryDto } from './dto/map-assets-query.dto';
|
||||||
|
import { UpsertAssetGeometryDto } from './dto/upsert-asset-geometry.dto';
|
||||||
|
|
||||||
|
@Controller('assets')
|
||||||
|
export class AssetGeometriesController {
|
||||||
|
constructor(private readonly geometries: AssetGeometriesService) {}
|
||||||
|
|
||||||
|
@Get(':id/geometry')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
get(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
) {
|
||||||
|
return this.geometries.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/geometry')
|
||||||
|
@RequirePermissions('assets.update_geometry')
|
||||||
|
upsert(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: UpsertAssetGeometryDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.geometries.upsert(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/geometry')
|
||||||
|
@RequirePermissions('assets.update_geometry')
|
||||||
|
remove(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.geometries.remove(id, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('map/assets')
|
||||||
|
export class MapAssetsController {
|
||||||
|
constructor(private readonly geometries: AssetGeometriesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
map(@Query() query: MapAssetsQueryDto) {
|
||||||
|
return this.geometries.map(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { 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 {
|
||||||
|
Asset,
|
||||||
|
AssetGeometrySource,
|
||||||
|
AssetGeometryType,
|
||||||
|
AssetVersionChangeType,
|
||||||
|
AuditAction,
|
||||||
|
} from '../database/entities';
|
||||||
|
import {
|
||||||
|
parseBoundingBox,
|
||||||
|
validateGeoJsonGeometry,
|
||||||
|
type GeoJsonGeometry,
|
||||||
|
} from './asset-geometry-validator';
|
||||||
|
import type { MapAssetsQueryDto } from './dto/map-assets-query.dto';
|
||||||
|
import type { UpsertAssetGeometryDto } from './dto/upsert-asset-geometry.dto';
|
||||||
|
import { AssetHistoryService } from './asset-history.service';
|
||||||
|
|
||||||
|
export interface AssetGeometryView {
|
||||||
|
assetId: string;
|
||||||
|
geometry: GeoJsonGeometry;
|
||||||
|
geometryType: AssetGeometryType;
|
||||||
|
source: AssetGeometrySource;
|
||||||
|
accuracyM: number | null;
|
||||||
|
capturedAt: Date | null;
|
||||||
|
deviceLabel: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapAssetRow {
|
||||||
|
id: string;
|
||||||
|
geometry: GeoJsonGeometry;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
typeId: string;
|
||||||
|
typeCode: string;
|
||||||
|
typeName: string;
|
||||||
|
parentId: string | null;
|
||||||
|
parentName: string | null;
|
||||||
|
informationStatus: string;
|
||||||
|
geometryType: AssetGeometryType;
|
||||||
|
accuracyM: number | string | null;
|
||||||
|
capturedAt: Date | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'ASSET_NOT_FOUND',
|
||||||
|
message: 'Activo no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetGeometriesService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly history: AssetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async get(assetId: string): Promise<{ data: AssetGeometryView | null }> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.requireAsset(manager, assetId);
|
||||||
|
return { data: await this.loadGeometry(manager, assetId) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(
|
||||||
|
assetId: string,
|
||||||
|
dto: UpsertAssetGeometryDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AssetGeometryView> {
|
||||||
|
const geometry = validateGeoJsonGeometry(dto.geometry);
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.requireAsset(manager, assetId, true);
|
||||||
|
const before = await this.loadGeometry(manager, assetId);
|
||||||
|
const source = principal.transport === 'bearer'
|
||||||
|
? AssetGeometrySource.ANDROID
|
||||||
|
: AssetGeometrySource.WEB;
|
||||||
|
|
||||||
|
await manager.query(
|
||||||
|
`INSERT INTO asset_geometries (
|
||||||
|
asset_id, geometry, geometry_type, source, accuracy_m,
|
||||||
|
captured_at, device_label, updated_by
|
||||||
|
) VALUES (
|
||||||
|
$1,
|
||||||
|
ST_SetSRID(ST_GeomFromGeoJSON($2::text), 4326),
|
||||||
|
$3, $4, $5, $6, $7, $8
|
||||||
|
)
|
||||||
|
ON CONFLICT (asset_id) DO UPDATE SET
|
||||||
|
geometry = EXCLUDED.geometry,
|
||||||
|
geometry_type = EXCLUDED.geometry_type,
|
||||||
|
source = EXCLUDED.source,
|
||||||
|
accuracy_m = EXCLUDED.accuracy_m,
|
||||||
|
captured_at = EXCLUDED.captured_at,
|
||||||
|
device_label = EXCLUDED.device_label,
|
||||||
|
updated_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = EXCLUDED.updated_by`,
|
||||||
|
[
|
||||||
|
assetId,
|
||||||
|
JSON.stringify(geometry),
|
||||||
|
geometry.type,
|
||||||
|
source,
|
||||||
|
dto.accuracyM ?? null,
|
||||||
|
dto.capturedAt ? new Date(dto.capturedAt) : null,
|
||||||
|
dto.deviceLabel?.trim() || null,
|
||||||
|
principal.userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const updated = await this.loadGeometry(manager, assetId);
|
||||||
|
if (!updated) throw new Error('Asset geometry was not persisted');
|
||||||
|
const versionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
assetId,
|
||||||
|
AssetVersionChangeType.GEOMETRY_UPDATED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_GEOMETRY_UPDATED,
|
||||||
|
entityType: 'asset',
|
||||||
|
entityId: assetId,
|
||||||
|
beforeData: before ? this.auditGeometry(before) : null,
|
||||||
|
afterData: this.auditGeometry(updated),
|
||||||
|
metadata: { versionNumber },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(
|
||||||
|
assetId: string,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<{ status: 'removed' | 'absent' }> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.requireAsset(manager, assetId, true);
|
||||||
|
const before = await this.loadGeometry(manager, assetId);
|
||||||
|
if (!before) return { status: 'absent' };
|
||||||
|
await manager.query('DELETE FROM asset_geometries WHERE asset_id = $1', [assetId]);
|
||||||
|
const versionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
assetId,
|
||||||
|
AssetVersionChangeType.GEOMETRY_REMOVED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_GEOMETRY_REMOVED,
|
||||||
|
entityType: 'asset',
|
||||||
|
entityId: assetId,
|
||||||
|
beforeData: this.auditGeometry(before),
|
||||||
|
afterData: { geometry: null },
|
||||||
|
metadata: { versionNumber },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return { status: 'removed' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async map(query: MapAssetsQueryDto) {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const parameters: unknown[] = [];
|
||||||
|
const add = (value: unknown): string => {
|
||||||
|
parameters.push(value);
|
||||||
|
return `$${parameters.length}`;
|
||||||
|
};
|
||||||
|
const bbox = parseBoundingBox(query.bbox);
|
||||||
|
if (bbox) {
|
||||||
|
const placeholders = bbox.map((value) => add(value));
|
||||||
|
conditions.push(
|
||||||
|
`ST_Intersects(geometry.geometry, ST_MakeEnvelope(${placeholders.join(', ')}, 4326))`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (query.typeId) conditions.push(`asset.asset_type_id = ${add(query.typeId)}`);
|
||||||
|
if (query.status) conditions.push(`asset.information_status = ${add(query.status)}`);
|
||||||
|
if (query.geometryType) conditions.push(`geometry.geometry_type = ${add(query.geometryType)}`);
|
||||||
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const rows = (await this.dataSource.query(
|
||||||
|
`SELECT
|
||||||
|
asset.id,
|
||||||
|
ST_AsGeoJSON(geometry.geometry)::jsonb AS geometry,
|
||||||
|
asset.code,
|
||||||
|
asset.name,
|
||||||
|
asset_type.id AS "typeId",
|
||||||
|
asset_type.code AS "typeCode",
|
||||||
|
asset_type.name AS "typeName",
|
||||||
|
parent.id AS "parentId",
|
||||||
|
parent.name AS "parentName",
|
||||||
|
asset.information_status AS "informationStatus",
|
||||||
|
geometry.geometry_type AS "geometryType",
|
||||||
|
geometry.accuracy_m AS "accuracyM",
|
||||||
|
geometry.captured_at AS "capturedAt",
|
||||||
|
geometry.updated_at AS "updatedAt"
|
||||||
|
FROM asset_geometries geometry
|
||||||
|
INNER JOIN assets asset ON asset.id = geometry.asset_id
|
||||||
|
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||||
|
LEFT JOIN assets parent ON parent.id = asset.parent_id
|
||||||
|
${where}
|
||||||
|
ORDER BY asset.name, asset.code
|
||||||
|
LIMIT 5001`,
|
||||||
|
parameters,
|
||||||
|
)) as MapAssetRow[];
|
||||||
|
const truncated = rows.length > 5000;
|
||||||
|
const visible = truncated ? rows.slice(0, 5000) : rows;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: visible.map((row) => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
id: row.id,
|
||||||
|
geometry: row.geometry,
|
||||||
|
properties: {
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
typeId: row.typeId,
|
||||||
|
typeCode: row.typeCode,
|
||||||
|
typeName: row.typeName,
|
||||||
|
parentId: row.parentId,
|
||||||
|
parentName: row.parentName,
|
||||||
|
informationStatus: row.informationStatus,
|
||||||
|
geometryType: row.geometryType,
|
||||||
|
accuracyM: row.accuracyM == null ? null : Number(row.accuracyM),
|
||||||
|
capturedAt: row.capturedAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
meta: { count: visible.length, truncated },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAsset(
|
||||||
|
manager: EntityManager,
|
||||||
|
id: string,
|
||||||
|
lock = false,
|
||||||
|
): Promise<void> {
|
||||||
|
if (lock) {
|
||||||
|
const [row] = (await manager.query(
|
||||||
|
'SELECT 1 FROM assets WHERE id = $1 FOR UPDATE',
|
||||||
|
[id],
|
||||||
|
)) as unknown[];
|
||||||
|
if (!row) throw assetNotFound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exists = await manager.getRepository(Asset).exist({ where: { id } });
|
||||||
|
if (!exists) throw assetNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadGeometry(
|
||||||
|
manager: EntityManager,
|
||||||
|
assetId: string,
|
||||||
|
): Promise<AssetGeometryView | null> {
|
||||||
|
const [row] = (await manager.query(
|
||||||
|
`SELECT
|
||||||
|
asset_id AS "assetId",
|
||||||
|
ST_AsGeoJSON(geometry)::jsonb AS geometry,
|
||||||
|
geometry_type AS "geometryType",
|
||||||
|
source,
|
||||||
|
accuracy_m::double precision AS "accuracyM",
|
||||||
|
captured_at AS "capturedAt",
|
||||||
|
device_label AS "deviceLabel",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
updated_at AS "updatedAt",
|
||||||
|
updated_by AS "updatedBy"
|
||||||
|
FROM asset_geometries
|
||||||
|
WHERE asset_id = $1`,
|
||||||
|
[assetId],
|
||||||
|
)) as AssetGeometryView[];
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private auditGeometry(view: AssetGeometryView): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
geometry: view.geometry,
|
||||||
|
geometryType: view.geometryType,
|
||||||
|
source: view.source,
|
||||||
|
accuracyM: view.accuracyM,
|
||||||
|
capturedAt: view.capturedAt,
|
||||||
|
deviceLabel: view.deviceLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { AssetGeometryType } from '../database/entities';
|
||||||
|
|
||||||
|
export interface GeoJsonGeometry {
|
||||||
|
type: AssetGeometryType;
|
||||||
|
coordinates: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Position = [number, number];
|
||||||
|
|
||||||
|
function invalid(message: string): never {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVALID_ASSET_GEOMETRY',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function position(value: unknown, label: string): Position {
|
||||||
|
if (!Array.isArray(value) || value.length < 2) {
|
||||||
|
return invalid(`${label} debe contener longitud y latitud`);
|
||||||
|
}
|
||||||
|
const longitude = Number(value[0]);
|
||||||
|
const latitude = Number(value[1]);
|
||||||
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
|
||||||
|
return invalid(`${label} contiene coordenadas no numéricas`);
|
||||||
|
}
|
||||||
|
if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
|
||||||
|
return invalid(`${label} está fuera del rango geográfico válido`);
|
||||||
|
}
|
||||||
|
return [longitude, latitude];
|
||||||
|
}
|
||||||
|
|
||||||
|
function positions(value: unknown, minimum: number, label: string): Position[] {
|
||||||
|
if (!Array.isArray(value) || value.length < minimum) {
|
||||||
|
return invalid(`${label} necesita al menos ${minimum} vértices`);
|
||||||
|
}
|
||||||
|
if (value.length > 10_000) return invalid(`${label} supera el máximo de 10000 vértices`);
|
||||||
|
return value.map((item, index) => position(item, `${label} · vértice ${index + 1}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function samePosition(first: Position, last: Position): boolean {
|
||||||
|
return first[0] === last[0] && first[1] === last[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateGeoJsonGeometry(input: GeoJsonGeometry): GeoJsonGeometry {
|
||||||
|
if (!Object.values(AssetGeometryType).includes(input.type)) {
|
||||||
|
return invalid('El tipo de geometría no está permitido');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.type === AssetGeometryType.POINT) {
|
||||||
|
return { type: input.type, coordinates: position(input.coordinates, 'El punto') };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.type === AssetGeometryType.LINESTRING) {
|
||||||
|
const line = positions(input.coordinates, 2, 'La línea');
|
||||||
|
if (new Set(line.map((item) => item.join(','))).size < 2) {
|
||||||
|
return invalid('La línea necesita al menos dos posiciones diferentes');
|
||||||
|
}
|
||||||
|
return { type: input.type, coordinates: line };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(input.coordinates) || input.coordinates.length < 1) {
|
||||||
|
return invalid('El polígono necesita al menos un anillo');
|
||||||
|
}
|
||||||
|
if (input.coordinates.length > 20) return invalid('El polígono supera el máximo de 20 anillos');
|
||||||
|
const rings = input.coordinates.map((ring, ringIndex) => {
|
||||||
|
const normalized = positions(ring, 4, `Anillo ${ringIndex + 1}`);
|
||||||
|
if (!samePosition(normalized[0]!, normalized[normalized.length - 1]!)) {
|
||||||
|
return invalid(`El anillo ${ringIndex + 1} debe estar cerrado`);
|
||||||
|
}
|
||||||
|
if (new Set(normalized.slice(0, -1).map((item) => item.join(','))).size < 3) {
|
||||||
|
return invalid(`El anillo ${ringIndex + 1} necesita tres posiciones diferentes`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
return { type: input.type, coordinates: rings };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBoundingBox(value?: string): [number, number, number, number] | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const numbers = value.split(',').map(Number);
|
||||||
|
if (numbers.length !== 4 || numbers.some((item) => !Number.isFinite(item))) {
|
||||||
|
return invalid('El área visible del mapa no es válida');
|
||||||
|
}
|
||||||
|
const [west, south, east, north] = numbers as [number, number, number, number];
|
||||||
|
if (
|
||||||
|
west < -180 || east > 180 || south < -90 || north > 90 ||
|
||||||
|
west >= east || south >= north
|
||||||
|
) {
|
||||||
|
return invalid('El área visible del mapa está fuera de rango');
|
||||||
|
}
|
||||||
|
return [west, south, east, north];
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { AssetHistoryService } from './asset-history.service';
|
||||||
|
import {
|
||||||
|
AssetVersionPageQueryDto,
|
||||||
|
ListAssetVersionsQueryDto,
|
||||||
|
} from './dto/list-asset-versions-query.dto';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AssetHistoryController {
|
||||||
|
constructor(private readonly history: AssetHistoryService) {}
|
||||||
|
|
||||||
|
@Get('asset-versions')
|
||||||
|
@RequirePermissions('assets.read_history')
|
||||||
|
list(@Query() query: ListAssetVersionsQueryDto) {
|
||||||
|
return this.history.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('assets/:id/versions')
|
||||||
|
@RequirePermissions('assets.read_history')
|
||||||
|
listForAsset(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Query() query: AssetVersionPageQueryDto,
|
||||||
|
) {
|
||||||
|
return this.history.listForAsset(id, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('assets/:id/versions/:versionNumber')
|
||||||
|
@RequirePermissions('assets.read_history')
|
||||||
|
getVersion(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Param('versionNumber', new ParseIntPipe()) versionNumber: number,
|
||||||
|
) {
|
||||||
|
return this.history.getVersion(id, versionNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../common/http/request-context';
|
||||||
|
import {
|
||||||
|
AssetVersionChangeType,
|
||||||
|
AuditSource,
|
||||||
|
} from '../database/entities';
|
||||||
|
import { changedSnapshotFields } from './asset-version-diff';
|
||||||
|
import type {
|
||||||
|
AssetVersionPageQueryDto,
|
||||||
|
ListAssetVersionsQueryDto,
|
||||||
|
} from './dto/list-asset-versions-query.dto';
|
||||||
|
|
||||||
|
export interface AssetVersionSummary {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
assetCode: string;
|
||||||
|
assetName: string;
|
||||||
|
typeId: string;
|
||||||
|
typeName: string;
|
||||||
|
informationStatus: string;
|
||||||
|
operationalStatus: string;
|
||||||
|
versionNumber: number;
|
||||||
|
changeType: AssetVersionChangeType;
|
||||||
|
changedFields: string[];
|
||||||
|
occurredAt: Date;
|
||||||
|
actorUserId: string | null;
|
||||||
|
actorUsername: string | null;
|
||||||
|
source: AuditSource;
|
||||||
|
requestId: string | null;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetVersionDetail extends AssetVersionSummary {
|
||||||
|
snapshot: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'ASSET_NOT_FOUND',
|
||||||
|
message: 'Activo no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'ASSET_VERSION_NOT_FOUND',
|
||||||
|
message: 'Versión de activo no encontrada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetHistoryService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async capture(
|
||||||
|
manager: EntityManager,
|
||||||
|
assetId: string,
|
||||||
|
changeType: AssetVersionChangeType,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<number> {
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE assets
|
||||||
|
SET current_version = COALESCE(current_version, 0) + 1
|
||||||
|
WHERE id = $1`,
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
const [versionRow] = (await manager.query(
|
||||||
|
`SELECT current_version
|
||||||
|
FROM assets
|
||||||
|
WHERE id = $1`,
|
||||||
|
[assetId],
|
||||||
|
)) as Array<{ current_version: number | string | null }>;
|
||||||
|
if (!versionRow) throw assetNotFound();
|
||||||
|
|
||||||
|
const versionNumber = Number(versionRow.current_version);
|
||||||
|
if (!Number.isInteger(versionNumber) || versionNumber < 1) {
|
||||||
|
throw new Error(`Versión de activo inválida después de incrementar: ${versionRow.current_version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await this.loadCurrentSnapshot(manager, assetId);
|
||||||
|
const [previousRow] = (await manager.query(
|
||||||
|
`SELECT snapshot
|
||||||
|
FROM asset_versions
|
||||||
|
WHERE asset_id = $1 AND version_number < $2
|
||||||
|
ORDER BY version_number DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[assetId, versionNumber],
|
||||||
|
)) as Array<{ snapshot: Record<string, unknown> }>;
|
||||||
|
const changedFields = changedSnapshotFields(previousRow?.snapshot ?? null, snapshot);
|
||||||
|
const source = principal.transport === 'bearer'
|
||||||
|
? AuditSource.ANDROID
|
||||||
|
: AuditSource.WEB;
|
||||||
|
|
||||||
|
await manager.query(
|
||||||
|
`INSERT INTO asset_versions (
|
||||||
|
asset_id, version_number, change_type, changed_fields, snapshot,
|
||||||
|
actor_user_id, actor_username, source, request_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
assetId,
|
||||||
|
versionNumber,
|
||||||
|
changeType,
|
||||||
|
changedFields,
|
||||||
|
snapshot,
|
||||||
|
principal.userId,
|
||||||
|
principal.username,
|
||||||
|
source,
|
||||||
|
request.requestId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return versionNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ListAssetVersionsQueryDto) {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const parameters: unknown[] = [];
|
||||||
|
const add = (value: unknown): string => {
|
||||||
|
parameters.push(value);
|
||||||
|
return `$${parameters.length}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.search?.trim()) {
|
||||||
|
const search = add(`%${query.search.trim()}%`);
|
||||||
|
conditions.push(`(
|
||||||
|
version.snapshot->>'code' ILIKE ${search}
|
||||||
|
OR version.snapshot->>'name' ILIKE ${search}
|
||||||
|
OR version.actor_username ILIKE ${search}
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
if (query.typeId) {
|
||||||
|
conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
|
||||||
|
}
|
||||||
|
if (query.changeType) {
|
||||||
|
conditions.push(`version.change_type = ${add(query.changeType)}`);
|
||||||
|
}
|
||||||
|
if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`);
|
||||||
|
if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`);
|
||||||
|
|
||||||
|
return this.listWithConditions(query.page, query.pageSize, conditions, parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForAsset(assetId: string, query: AssetVersionPageQueryDto) {
|
||||||
|
await this.requireAsset(assetId);
|
||||||
|
return this.listWithConditions(
|
||||||
|
query.page,
|
||||||
|
query.pageSize,
|
||||||
|
['version.asset_id = $1'],
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> {
|
||||||
|
await this.requireAsset(assetId);
|
||||||
|
const [row] = (await this.dataSource.query(
|
||||||
|
`${this.selectSummary()}, version.snapshot
|
||||||
|
FROM asset_versions version
|
||||||
|
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
|
||||||
|
WHERE version.asset_id = $1 AND version.version_number = $2`,
|
||||||
|
[assetId, versionNumber],
|
||||||
|
)) as AssetVersionDetail[];
|
||||||
|
if (!row) throw versionNotFound();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listWithConditions(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
conditions: string[],
|
||||||
|
parameters: unknown[],
|
||||||
|
) {
|
||||||
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
const [countRow] = (await this.dataSource.query(
|
||||||
|
`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`,
|
||||||
|
parameters,
|
||||||
|
)) as Array<{ total: number }>;
|
||||||
|
const total = Number(countRow?.total ?? 0);
|
||||||
|
const paginated = [...parameters, pageSize, (page - 1) * pageSize];
|
||||||
|
const limit = `$${parameters.length + 1}`;
|
||||||
|
const offset = `$${parameters.length + 2}`;
|
||||||
|
const data = (await this.dataSource.query(
|
||||||
|
`${this.selectSummary()}
|
||||||
|
FROM asset_versions version
|
||||||
|
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
|
||||||
|
${where}
|
||||||
|
ORDER BY version.occurred_at DESC, version.version_number DESC
|
||||||
|
LIMIT ${limit} OFFSET ${offset}`,
|
||||||
|
paginated,
|
||||||
|
)) as AssetVersionSummary[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: total === 0 ? 0 : Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectSummary(): string {
|
||||||
|
return `SELECT
|
||||||
|
version.id,
|
||||||
|
version.asset_id AS "assetId",
|
||||||
|
version.snapshot->>'code' AS "assetCode",
|
||||||
|
version.snapshot->>'name' AS "assetName",
|
||||||
|
version.snapshot #>> '{type,id}' AS "typeId",
|
||||||
|
version.snapshot #>> '{type,name}' AS "typeName",
|
||||||
|
version.snapshot->>'informationStatus' AS "informationStatus",
|
||||||
|
version.snapshot->>'operationalStatus' AS "operationalStatus",
|
||||||
|
version.version_number AS "versionNumber",
|
||||||
|
version.change_type AS "changeType",
|
||||||
|
version.changed_fields AS "changedFields",
|
||||||
|
version.occurred_at AS "occurredAt",
|
||||||
|
version.actor_user_id AS "actorUserId",
|
||||||
|
version.actor_username AS "actorUsername",
|
||||||
|
version.source,
|
||||||
|
version.request_id AS "requestId",
|
||||||
|
(version.version_number = current_asset.current_version) AS "isCurrent"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAsset(assetId: string): Promise<void> {
|
||||||
|
const [row] = (await this.dataSource.query(
|
||||||
|
'SELECT 1 FROM assets WHERE id = $1',
|
||||||
|
[assetId],
|
||||||
|
)) as unknown[];
|
||||||
|
if (!row) throw assetNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCurrentSnapshot(
|
||||||
|
manager: EntityManager,
|
||||||
|
assetId: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const [row] = (await manager.query(
|
||||||
|
`SELECT JSONB_BUILD_OBJECT(
|
||||||
|
'id', asset.id,
|
||||||
|
'code', asset.code,
|
||||||
|
'name', asset.name,
|
||||||
|
'commonName', asset.common_name,
|
||||||
|
'description', asset.description,
|
||||||
|
'type', JSONB_BUILD_OBJECT(
|
||||||
|
'id', asset_type.id,
|
||||||
|
'code', asset_type.code,
|
||||||
|
'name', asset_type.name,
|
||||||
|
'operationalRole', asset_type.operational_role
|
||||||
|
),
|
||||||
|
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id', parent.id,
|
||||||
|
'code', parent.code,
|
||||||
|
'name', parent.name
|
||||||
|
) END,
|
||||||
|
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id', operational_area.id,
|
||||||
|
'code', operational_area.code,
|
||||||
|
'name', operational_area.name
|
||||||
|
) END,
|
||||||
|
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id', operator_company.id,
|
||||||
|
'code', operator_company.code,
|
||||||
|
'name', operator_company.name
|
||||||
|
) END,
|
||||||
|
'informationStatus', asset.information_status,
|
||||||
|
'operationalStatus', asset.operational_status,
|
||||||
|
'attributes', COALESCE((
|
||||||
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||||
|
'definitionId', definition.id,
|
||||||
|
'code', definition.code,
|
||||||
|
'name', definition.name,
|
||||||
|
'dataType', definition.data_type,
|
||||||
|
'isRequired', definition.is_required,
|
||||||
|
'unit', definition.unit,
|
||||||
|
'options', definition.options,
|
||||||
|
'sortOrder', definition.sort_order,
|
||||||
|
'value', value.value
|
||||||
|
) ORDER BY definition.sort_order, definition.name)
|
||||||
|
FROM asset_attribute_definitions definition
|
||||||
|
LEFT JOIN asset_attribute_values value
|
||||||
|
ON value.definition_id = definition.id
|
||||||
|
AND value.asset_id = asset.id
|
||||||
|
WHERE definition.asset_type_id = asset.asset_type_id
|
||||||
|
AND definition.is_active = true
|
||||||
|
), '[]'::jsonb),
|
||||||
|
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'assetId', geometry.asset_id,
|
||||||
|
'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,
|
||||||
|
'geometryType', geometry.geometry_type,
|
||||||
|
'source', geometry.source,
|
||||||
|
'accuracyM', geometry.accuracy_m::double precision,
|
||||||
|
'capturedAt', geometry.captured_at,
|
||||||
|
'deviceLabel', geometry.device_label,
|
||||||
|
'createdAt', geometry.created_at,
|
||||||
|
'updatedAt', geometry.updated_at,
|
||||||
|
'updatedBy', geometry.updated_by
|
||||||
|
) END,
|
||||||
|
'media', COALESCE((
|
||||||
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||||
|
'id', media.id,
|
||||||
|
'kind', media.kind,
|
||||||
|
'originalName', media.original_name,
|
||||||
|
'mimeType', media.mime_type,
|
||||||
|
'sizeBytes', media.size_bytes,
|
||||||
|
'sha256', media.sha256,
|
||||||
|
'title', media.title,
|
||||||
|
'description', media.description,
|
||||||
|
'capturedAt', media.captured_at,
|
||||||
|
'latitude', media.latitude,
|
||||||
|
'longitude', media.longitude,
|
||||||
|
'accuracyM', media.accuracy_m,
|
||||||
|
'source', media.source,
|
||||||
|
'uploadedBy', media.uploaded_by,
|
||||||
|
'createdAt', media.created_at,
|
||||||
|
'updatedAt', media.updated_at
|
||||||
|
) ORDER BY media.created_at, media.id)
|
||||||
|
FROM asset_media media
|
||||||
|
WHERE media.asset_id = asset.id
|
||||||
|
AND media.deleted_at IS NULL
|
||||||
|
), '[]'::jsonb),
|
||||||
|
'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id),
|
||||||
|
'organizationMemberships', COALESCE((
|
||||||
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',m.id,'parentOrganizationId',m.parent_organization_id,'memberOrganizationId',m.member_organization_id,'role',m.role,'participationPercent',m.participation_percent::double precision,'validFrom',m.valid_from,'validUntil',m.valid_until,'sourceDocumentId',m.source_document_id,'notes',m.notes,'endReason',m.end_reason) ORDER BY m.valid_until NULLS FIRST,m.valid_from DESC)
|
||||||
|
FROM organization_memberships m WHERE m.parent_organization_id=asset.id OR m.member_organization_id=asset.id
|
||||||
|
),'[]'::jsonb),
|
||||||
|
'externalIdentifiers', COALESCE((
|
||||||
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',i.id,'namespace',i.namespace,'value',i.value,'validFrom',i.valid_from,'validUntil',i.valid_until,'sourceDocumentId',i.source_document_id,'notes',i.notes,'endReason',i.end_reason) ORDER BY i.valid_until NULLS FIRST,i.namespace,i.valid_from DESC) FROM asset_external_identifiers i WHERE i.asset_id=asset.id
|
||||||
|
),'[]'::jsonb),
|
||||||
|
'sourceDocuments', COALESCE((
|
||||||
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('linkId',l.id,'relationType',l.relation_type,'documentId',d.id,'documentType',d.document_type,'documentNumber',d.document_number,'title',d.title,'issuer',d.issuer,'documentDate',d.document_date,'externalReference',d.external_reference) ORDER BY d.document_date DESC NULLS LAST,d.created_at DESC) FROM asset_source_documents l JOIN source_documents d ON d.id=l.document_id WHERE l.asset_id=asset.id
|
||||||
|
),'[]'::jsonb),
|
||||||
|
'legalRights', COALESCE((
|
||||||
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id
|
||||||
|
),'[]'::jsonb),
|
||||||
|
'provenance', JSONB_BUILD_OBJECT(
|
||||||
|
'origin', asset.data_origin,
|
||||||
|
'sourceName', asset.source_name,
|
||||||
|
'sourceReference', asset.source_reference,
|
||||||
|
'observedAt', asset.source_observed_at,
|
||||||
|
'notes', asset.source_notes,
|
||||||
|
'verifiedAt', asset.provenance_verified_at,
|
||||||
|
'verifiedBy', asset.provenance_verified_by,
|
||||||
|
'updatedAt', asset.provenance_updated_at,
|
||||||
|
'updatedBy', asset.provenance_updated_by
|
||||||
|
),
|
||||||
|
'createdAt', asset.created_at,
|
||||||
|
'updatedAt', asset.updated_at,
|
||||||
|
'createdBy', asset.created_by,
|
||||||
|
'updatedBy', asset.updated_by,
|
||||||
|
'currentVersion', asset.current_version
|
||||||
|
) AS snapshot
|
||||||
|
FROM assets asset
|
||||||
|
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||||
|
LEFT JOIN assets parent ON parent.id = asset.parent_id
|
||||||
|
LEFT JOIN assets operational_area ON operational_area.id = asset.operational_area_id
|
||||||
|
LEFT JOIN assets operator_company ON operator_company.id = asset.operator_company_id
|
||||||
|
LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id
|
||||||
|
WHERE asset.id = $1`,
|
||||||
|
[assetId],
|
||||||
|
)) as Array<{ snapshot: Record<string, unknown> }>;
|
||||||
|
if (!row) throw assetNotFound();
|
||||||
|
return row.snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import {
|
||||||
|
AssetAttributeDataType,
|
||||||
|
AssetTypeOperationalRole,
|
||||||
|
} from '../database/entities';
|
||||||
|
|
||||||
|
export interface MasterBootstrapAttributePreset {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
dataType: AssetAttributeDataType;
|
||||||
|
isRequired: boolean;
|
||||||
|
unit: string | null;
|
||||||
|
options: string[] | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterBootstrapTypePreset {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
canBeRoot: boolean;
|
||||||
|
operationalRole: AssetTypeOperationalRole;
|
||||||
|
allowedParentCodes: string[];
|
||||||
|
attributes: MasterBootstrapAttributePreset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (code: string, name: string, sortOrder: number, unit: string | null = null): MasterBootstrapAttributePreset => ({
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
dataType: AssetAttributeDataType.TEXT,
|
||||||
|
isRequired: false,
|
||||||
|
unit,
|
||||||
|
options: null,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const number = (code: string, name: string, sortOrder: number, unit: string | null = null): MasterBootstrapAttributePreset => ({
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
dataType: AssetAttributeDataType.NUMBER,
|
||||||
|
isRequired: false,
|
||||||
|
unit,
|
||||||
|
options: null,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const date = (code: string, name: string, sortOrder: number): MasterBootstrapAttributePreset => ({
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
dataType: AssetAttributeDataType.DATE,
|
||||||
|
isRequired: false,
|
||||||
|
unit: null,
|
||||||
|
options: null,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const select = (code: string, name: string, options: string[], sortOrder: number): MasterBootstrapAttributePreset => ({
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
dataType: AssetAttributeDataType.SELECT,
|
||||||
|
isRequired: false,
|
||||||
|
unit: null,
|
||||||
|
options,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MASTER_BOOTSTRAP_PRESET_CODE = 'mendoza-hidrocarburos-v2';
|
||||||
|
export const MASTER_BOOTSTRAP_PRESET_NAME = 'Hidrocarburos · Mendoza';
|
||||||
|
|
||||||
|
export const MASTER_BOOTSTRAP_CORE_CODES = [
|
||||||
|
'area',
|
||||||
|
'empresa',
|
||||||
|
'yacimiento',
|
||||||
|
'instalacion',
|
||||||
|
'estacion',
|
||||||
|
'subestacion',
|
||||||
|
'pozo',
|
||||||
|
'equipo',
|
||||||
|
'ducto',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const MASTER_BOOTSTRAP_TYPES: MasterBootstrapTypePreset[] = [
|
||||||
|
{
|
||||||
|
code: 'area',
|
||||||
|
name: 'Área',
|
||||||
|
description: 'Área hidrocarburífera administrada como ancla territorial. Concesiones, permisos y titulares se registran por separado en la capa legal.',
|
||||||
|
canBeRoot: true,
|
||||||
|
operationalRole: AssetTypeOperationalRole.AREA,
|
||||||
|
allowedParentCodes: [],
|
||||||
|
attributes: [
|
||||||
|
text('identificacion_oficial', 'Identificación oficial', 10),
|
||||||
|
text('cuenca', 'Cuenca', 20),
|
||||||
|
text('departamento', 'Departamento', 30),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'empresa',
|
||||||
|
name: 'Organización',
|
||||||
|
description: 'Entidad jurídica u organización administrada (empresa, UTE u otra figura). Su rol como operadora, titular o participante se registra mediante relaciones históricas.',
|
||||||
|
canBeRoot: true,
|
||||||
|
operationalRole: AssetTypeOperationalRole.COMPANY,
|
||||||
|
allowedParentCodes: [],
|
||||||
|
attributes: [
|
||||||
|
text('cuit', 'CUIT', 10),
|
||||||
|
text('razon_social', 'Razón social', 20),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'yacimiento',
|
||||||
|
name: 'Yacimiento',
|
||||||
|
description: 'Unidad territorial u operativa dentro de un Área. Es opcional porque la documentación también utiliza el campo combinado Área/Yacimiento.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area'],
|
||||||
|
attributes: [text('identificacion_oficial', 'Identificación oficial', 10)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'estructura_local',
|
||||||
|
name: 'Estructura local (fuente)',
|
||||||
|
description: 'Nodo estructural provisional que conserva la nomenclatura propia de cada operadora o Área/Yacimiento. No implica una clasificación física normalizada por DH hasta su revisión.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
|
||||||
|
attributes: [
|
||||||
|
text('nivel_fuente', 'Nivel informado por la fuente', 10),
|
||||||
|
text('clasificacion_fuente', 'Clasificación local informada', 20),
|
||||||
|
text('ruta_fuente', 'Ruta / nomenclatura de origen', 30),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'locacion',
|
||||||
|
name: 'Locación',
|
||||||
|
description: 'Sitio físico dentro de un área o yacimiento que puede agrupar pozos, instalaciones o equipos.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area', 'yacimiento'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'instalacion',
|
||||||
|
name: 'Instalación de superficie',
|
||||||
|
description: 'Tipo genérico de instalación de proceso, tratamiento, almacenamiento o apoyo. Se conserva como alternativa cuando no exista un tipo técnico más específico.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
|
||||||
|
attributes: [text('tipo_instalacion', 'Tipo de instalación', 10)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'planta',
|
||||||
|
name: 'Planta',
|
||||||
|
description: 'Instalación de superficie administrada como unidad de proceso, tratamiento, entrega o almacenamiento.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
|
||||||
|
attributes: [text('funcion_planta', 'Función / denominación operativa', 10)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'bateria',
|
||||||
|
name: 'Batería',
|
||||||
|
description: 'Batería hidrocarburífera administrada como instalación y contenedor de sistemas/equipos.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'estacion',
|
||||||
|
name: 'Estación',
|
||||||
|
description: 'Estación operativa perteneciente a un área, yacimiento, locación o instalación.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'subestacion',
|
||||||
|
name: 'Subestación',
|
||||||
|
description: 'Subestación o unidad subordinada dentro de una instalación o estación.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'zona_bombas',
|
||||||
|
name: 'Zona de bombas',
|
||||||
|
description: 'Sector o conjunto físico donde se agrupan bombas. Se distingue de cada equipo Bomba individual.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'sistema_drenaje',
|
||||||
|
name: 'Sistema de drenaje',
|
||||||
|
description: 'Sistema de drenaje de una instalación. Puede contener piletas u otros elementos que requieran identidad propia.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'sistema_electrico_iluminacion',
|
||||||
|
name: 'Sistema eléctrico / iluminación',
|
||||||
|
description: 'Sistema eléctrico y de iluminación administrable cuando requiere historial e inspección propios.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'sistema_defensa_incendios',
|
||||||
|
name: 'Defensa contra incendios',
|
||||||
|
description: 'Sistema de defensa contra incendios, incluyendo red y equipos asociados cuando corresponda.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'cargadero_descargadero',
|
||||||
|
name: 'Cargadero / descargadero de camiones',
|
||||||
|
description: 'Instalación utilizada para carga o descarga de camiones, inspeccionable como unidad.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'pileta_api',
|
||||||
|
name: 'Pileta API',
|
||||||
|
description: 'Pileta API identificable dentro de una instalación o sistema de drenaje.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['sistema_drenaje', 'instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'pozo',
|
||||||
|
name: 'Pozo',
|
||||||
|
description: 'Pozo hidrocarburífero. El método o función se administra como atributo para no duplicar el Maestro en varios tipos de pozo.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
|
||||||
|
attributes: [
|
||||||
|
text('identificacion_oficial', 'Identificación oficial', 10),
|
||||||
|
text('tipo_pozo', 'Tipo de pozo informado', 20),
|
||||||
|
select('metodo_extraccion', 'Método / función operativa', [
|
||||||
|
'Bombeo mecánico',
|
||||||
|
'Bombeo electrosumergible',
|
||||||
|
'Bombeo de cavidad progresiva (PCP)',
|
||||||
|
'Surgente / productor de gas',
|
||||||
|
'Inyector de agua',
|
||||||
|
'Otro / a validar',
|
||||||
|
], 30),
|
||||||
|
number('profundidad', 'Profundidad', 40, 'm'),
|
||||||
|
text('estado_operativo', 'Estado operativo informado', 50),
|
||||||
|
date('ultima_intervencion', 'Última intervención informada', 60),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'equipo',
|
||||||
|
name: 'Equipo',
|
||||||
|
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en una familia técnica específica.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'],
|
||||||
|
attributes: [
|
||||||
|
text('fabricante', 'Fabricante', 10),
|
||||||
|
text('modelo', 'Modelo', 20),
|
||||||
|
text('numero_serie', 'Número de serie', 30),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'tanque',
|
||||||
|
name: 'Tanque',
|
||||||
|
description: 'Tanque de almacenamiento o proceso administrado como equipo individual.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [
|
||||||
|
number('capacidad_nominal', 'Capacidad nominal', 10, 'm³'),
|
||||||
|
text('producto_servicio', 'Producto / servicio', 20),
|
||||||
|
text('fabricante', 'Fabricante', 30),
|
||||||
|
text('modelo', 'Modelo', 40),
|
||||||
|
text('numero_serie', 'Número de serie', 50),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'separador',
|
||||||
|
name: 'Separador',
|
||||||
|
description: 'Separador de proceso administrado como equipo individual.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'bomba',
|
||||||
|
name: 'Bomba',
|
||||||
|
description: 'Bomba individual. Puede pertenecer a una zona de bombas, sistema contra incendios, pozo u otra instalación.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero', 'pozo'],
|
||||||
|
attributes: [
|
||||||
|
text('fabricante', 'Fabricante', 10),
|
||||||
|
text('modelo', 'Modelo', 20),
|
||||||
|
text('numero_serie', 'Número de serie', 30),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'caldera',
|
||||||
|
name: 'Caldera',
|
||||||
|
description: 'Caldera administrada como equipo individual con controles documentales y operativos propios.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'antorcha',
|
||||||
|
name: 'Antorcha',
|
||||||
|
description: 'Antorcha administrada como equipo o instalación individual.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'colector',
|
||||||
|
name: 'Colector',
|
||||||
|
description: 'Colector administrado como activo individual cuando requiere trazabilidad propia.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'filtro',
|
||||||
|
name: 'Filtro',
|
||||||
|
description: 'Filtro administrado como equipo individual.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'equipo_flotacion',
|
||||||
|
name: 'Equipo de flotación',
|
||||||
|
description: 'Equipo de flotación administrado como activo individual.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'fwko',
|
||||||
|
name: 'FWKO',
|
||||||
|
description: 'Free Water Knock Out administrado como equipo individual.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'tratador',
|
||||||
|
name: 'Tratador',
|
||||||
|
description: 'Tratador administrado como equipo individual.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'calentador',
|
||||||
|
name: 'Calentador',
|
||||||
|
description: 'Calentador administrado como equipo individual.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ducto',
|
||||||
|
name: 'Ducto / Cañería',
|
||||||
|
description: 'Tramo de ducto o cañería administrable e inspeccionable dentro de un área, yacimiento, locación o instalación.',
|
||||||
|
canBeRoot: false,
|
||||||
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
|
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
|
||||||
|
attributes: [
|
||||||
|
text('servicio', 'Servicio / fluido', 10),
|
||||||
|
text('diametro_nominal', 'Diámetro nominal', 20),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||||
|
if (
|
||||||
|
preset.operationalRole === AssetTypeOperationalRole.GENERIC
|
||||||
|
&& !['yacimiento', 'locacion', 'estructura_local', 'pozo', 'ducto', 'colector'].includes(preset.code)
|
||||||
|
&& !preset.allowedParentCodes.includes('estructura_local')
|
||||||
|
) {
|
||||||
|
preset.allowedParentCodes.push('estructura_local');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { AssetTypesController } from './asset-types.controller';
|
||||||
|
import { AssetTypesService } from './asset-types.service';
|
||||||
|
import { AssetsController } from './assets.controller';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
import {
|
||||||
|
AssetGeometriesController,
|
||||||
|
MapAssetsController,
|
||||||
|
} from './asset-geometries.controller';
|
||||||
|
import { AssetGeometriesService } from './asset-geometries.service';
|
||||||
|
import { AssetHistoryController } from './asset-history.controller';
|
||||||
|
import { AssetHistoryService } from './asset-history.service';
|
||||||
|
import { AssetMediaController } from './asset-media.controller';
|
||||||
|
import { AssetMediaService } from './asset-media.service';
|
||||||
|
import { AssetProvenanceController } from './asset-provenance.controller';
|
||||||
|
import { AssetProvenanceService } from './asset-provenance.service';
|
||||||
|
import { AssetTemporalController } from './asset-temporal.controller';
|
||||||
|
import { AssetTemporalService } from './asset-temporal.service';
|
||||||
|
import { AssetOperationalRelationsController } from './asset-operational-relations.controller';
|
||||||
|
import { AssetOperationalRelationsService } from './asset-operational-relations.service';
|
||||||
|
import { AssetRegistryController } from './asset-registry.controller';
|
||||||
|
import { AssetRegistryService } from './asset-registry.service';
|
||||||
|
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuditModule],
|
||||||
|
controllers: [
|
||||||
|
AssetTypesController,
|
||||||
|
AssetsController,
|
||||||
|
AssetGeometriesController,
|
||||||
|
MapAssetsController,
|
||||||
|
AssetHistoryController,
|
||||||
|
AssetMediaController,
|
||||||
|
AssetProvenanceController,
|
||||||
|
AssetTemporalController,
|
||||||
|
AssetOperationalRelationsController,
|
||||||
|
AssetRegistryController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AssetTypesService,
|
||||||
|
AssetsService,
|
||||||
|
AssetGeometriesService,
|
||||||
|
AssetHistoryService,
|
||||||
|
AssetMediaService,
|
||||||
|
AssetProvenanceService,
|
||||||
|
AssetTemporalService,
|
||||||
|
AssetOperationalRelationsService,
|
||||||
|
AssetRegistryService,
|
||||||
|
FieldDiscoveryInspectionLinkService,
|
||||||
|
],
|
||||||
|
exports: [AssetHistoryService],
|
||||||
|
})
|
||||||
|
export class AssetMasterModule {}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { AssetMediaKind } from '../database/entities';
|
||||||
|
|
||||||
|
export const MAX_ASSET_MEDIA_BYTES = 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface UploadedAssetFile {
|
||||||
|
buffer: Buffer;
|
||||||
|
originalname: string;
|
||||||
|
mimetype?: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectedAssetFile {
|
||||||
|
originalName: string;
|
||||||
|
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'application/pdf';
|
||||||
|
extension: '.jpg' | '.png' | '.webp' | '.pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidFile(message: string): BadRequestException {
|
||||||
|
return new BadRequestException({ code: 'INVALID_ASSET_FILE', message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectedType(buffer: Buffer): Omit<InspectedAssetFile, 'originalName'> | null {
|
||||||
|
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||||
|
return { mimeType: 'image/jpeg', extension: '.jpg' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buffer.length >= 8
|
||||||
|
&& buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||||
|
) {
|
||||||
|
return { mimeType: 'image/png', extension: '.png' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buffer.length >= 12
|
||||||
|
&& buffer.subarray(0, 4).toString('ascii') === 'RIFF'
|
||||||
|
&& buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return { mimeType: 'image/webp', extension: '.webp' };
|
||||||
|
}
|
||||||
|
if (buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
|
||||||
|
return { mimeType: 'application/pdf', extension: '.pdf' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectAssetFile(
|
||||||
|
file: UploadedAssetFile | undefined,
|
||||||
|
kind: AssetMediaKind,
|
||||||
|
): InspectedAssetFile {
|
||||||
|
if (!file?.buffer || file.size <= 0 || file.buffer.length <= 0) {
|
||||||
|
throw invalidFile('Debe seleccionar un archivo no vacío');
|
||||||
|
}
|
||||||
|
if (file.size > MAX_ASSET_MEDIA_BYTES || file.buffer.length > MAX_ASSET_MEDIA_BYTES) {
|
||||||
|
throw invalidFile('El archivo supera el máximo permitido de 15 MB');
|
||||||
|
}
|
||||||
|
const detected = detectedType(file.buffer);
|
||||||
|
if (!detected) {
|
||||||
|
throw invalidFile('Sólo se permiten JPG, PNG, WebP y PDF válidos');
|
||||||
|
}
|
||||||
|
if (kind === AssetMediaKind.PHOTO && !detected.mimeType.startsWith('image/')) {
|
||||||
|
throw invalidFile('Una fotografía debe ser JPG, PNG o WebP');
|
||||||
|
}
|
||||||
|
if (kind === AssetMediaKind.DOCUMENT && detected.mimeType !== 'application/pdf') {
|
||||||
|
throw invalidFile('Un documento debe ser un archivo PDF');
|
||||||
|
}
|
||||||
|
const originalName = file.originalname
|
||||||
|
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
if (!originalName) throw invalidFile('El nombre original del archivo no es válido');
|
||||||
|
return { ...detected, originalName };
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../common/http/request-context';
|
||||||
|
import { AssetMediaKind } from '../database/entities';
|
||||||
|
import {
|
||||||
|
MAX_ASSET_MEDIA_BYTES,
|
||||||
|
type UploadedAssetFile,
|
||||||
|
} from './asset-media-file';
|
||||||
|
import { AssetMediaService } from './asset-media.service';
|
||||||
|
import { CreateAssetMediaDto } from './dto/create-asset-media.dto';
|
||||||
|
import { UpdateAssetMediaDto } from './dto/update-asset-media.dto';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AssetMediaController {
|
||||||
|
constructor(private readonly media: AssetMediaService) {}
|
||||||
|
|
||||||
|
@Get('assets/:assetId/media')
|
||||||
|
@RequirePermissions('assets.read_media')
|
||||||
|
list(
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
) {
|
||||||
|
return this.media.list(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('assets/:assetId/media')
|
||||||
|
@RequirePermissions('assets.manage_media')
|
||||||
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
limits: { fileSize: MAX_ASSET_MEDIA_BYTES, files: 1 },
|
||||||
|
}))
|
||||||
|
upload(
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
@Body() dto: CreateAssetMediaDto,
|
||||||
|
@UploadedFile() file: UploadedAssetFile | undefined,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.media.upload(assetId, dto, file, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('asset-media/:mediaId')
|
||||||
|
@RequirePermissions('assets.manage_media')
|
||||||
|
update(
|
||||||
|
@Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string,
|
||||||
|
@Body() dto: UpdateAssetMediaDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.media.update(mediaId, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('asset-media/:mediaId')
|
||||||
|
@RequirePermissions('assets.manage_media')
|
||||||
|
remove(
|
||||||
|
@Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.media.remove(mediaId, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('asset-media/:mediaId/content')
|
||||||
|
@RequirePermissions('assets.read_media')
|
||||||
|
async content(
|
||||||
|
@Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string,
|
||||||
|
@Query('download') download: string | undefined,
|
||||||
|
@Res() response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { filePath, media } = await this.media.content(mediaId);
|
||||||
|
const forceDownload = download === '1' || media.kind === AssetMediaKind.DOCUMENT;
|
||||||
|
const disposition = forceDownload ? 'attachment' : 'inline';
|
||||||
|
const fallbackName = media.originalName
|
||||||
|
.replace(/[^\x20-\x7e]/g, '_')
|
||||||
|
.replace(/["\\]/g, '_');
|
||||||
|
response.setHeader('Content-Type', media.mimeType);
|
||||||
|
response.setHeader('Content-Length', String(media.sizeBytes));
|
||||||
|
response.setHeader('Content-Disposition', `${disposition}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(media.originalName)}`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
await new Promise<void>((resolveSend, rejectSend) => {
|
||||||
|
response.sendFile(filePath, (error) => {
|
||||||
|
if (error) rejectSend(error);
|
||||||
|
else resolveSend();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { isAbsolute, parse, resolve } from 'node:path';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
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 {
|
||||||
|
AssetMediaKind,
|
||||||
|
AssetMediaSource,
|
||||||
|
AssetVersionChangeType,
|
||||||
|
AuditAction,
|
||||||
|
} from '../database/entities';
|
||||||
|
import { AssetHistoryService } from './asset-history.service';
|
||||||
|
import {
|
||||||
|
inspectAssetFile,
|
||||||
|
type UploadedAssetFile,
|
||||||
|
} from './asset-media-file';
|
||||||
|
import type { CreateAssetMediaDto } from './dto/create-asset-media.dto';
|
||||||
|
import type { UpdateAssetMediaDto } from './dto/update-asset-media.dto';
|
||||||
|
|
||||||
|
export interface AssetMediaView {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
kind: AssetMediaKind;
|
||||||
|
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: AssetMediaSource;
|
||||||
|
uploadedBy: string | null;
|
||||||
|
uploadedByUsername: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredAssetMedia extends AssetMediaView {
|
||||||
|
storedName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'ASSET_NOT_FOUND',
|
||||||
|
message: 'Activo no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'ASSET_MEDIA_NOT_FOUND',
|
||||||
|
message: 'Archivo de activo no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function coordinateError(): BadRequestException {
|
||||||
|
return new BadRequestException({
|
||||||
|
code: 'INVALID_MEDIA_COORDINATES',
|
||||||
|
message: 'Latitud y longitud deben informarse juntas',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetMediaService {
|
||||||
|
private readonly storageRoot: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly history: AssetHistoryService,
|
||||||
|
config: ConfigService,
|
||||||
|
) {
|
||||||
|
const configured = config.get<string>('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media';
|
||||||
|
if (!isAbsolute(configured)) {
|
||||||
|
throw new Error('ASSET_MEDIA_ROOT must be an absolute path');
|
||||||
|
}
|
||||||
|
this.storageRoot = resolve(configured);
|
||||||
|
if (this.storageRoot === parse(this.storageRoot).root) {
|
||||||
|
throw new Error('ASSET_MEDIA_ROOT cannot be the filesystem root');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(assetId: string): Promise<{ data: AssetMediaView[] }> {
|
||||||
|
await this.requireAsset(this.dataSource.manager, assetId, false);
|
||||||
|
const rows = (await this.dataSource.query(
|
||||||
|
`${this.mediaSelect()}
|
||||||
|
WHERE media.asset_id = $1 AND media.deleted_at IS NULL
|
||||||
|
ORDER BY media.created_at DESC`,
|
||||||
|
[assetId],
|
||||||
|
)) as StoredAssetMedia[];
|
||||||
|
return { data: rows.map(({ storedName: _storedName, ...media }) => media) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(
|
||||||
|
assetId: string,
|
||||||
|
dto: CreateAssetMediaDto,
|
||||||
|
file: UploadedAssetFile | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AssetMediaView> {
|
||||||
|
this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM);
|
||||||
|
const inspected = inspectAssetFile(file, dto.kind);
|
||||||
|
const id = randomUUID();
|
||||||
|
const storedName = `${id}${inspected.extension}`;
|
||||||
|
const filePath = resolve(this.storageRoot, storedName);
|
||||||
|
const source = principal.transport === 'bearer'
|
||||||
|
? AssetMediaSource.ANDROID
|
||||||
|
: AssetMediaSource.WEB;
|
||||||
|
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
||||||
|
|
||||||
|
await mkdir(this.storageRoot, { recursive: true, mode: 0o700 });
|
||||||
|
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.requireAsset(manager, assetId, true);
|
||||||
|
await manager.query(
|
||||||
|
`INSERT INTO asset_media (
|
||||||
|
id, asset_id, kind, original_name, stored_name, mime_type,
|
||||||
|
size_bytes, sha256, title, description, captured_at,
|
||||||
|
latitude, longitude, accuracy_m, source, uploaded_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||||
|
$12, $13, $14, $15, $16
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
assetId,
|
||||||
|
dto.kind,
|
||||||
|
inspected.originalName,
|
||||||
|
storedName,
|
||||||
|
inspected.mimeType,
|
||||||
|
file!.buffer.length,
|
||||||
|
sha256,
|
||||||
|
dto.title?.trim() || null,
|
||||||
|
dto.description?.trim() || null,
|
||||||
|
dto.capturedAt ? new Date(dto.capturedAt) : null,
|
||||||
|
dto.latitude ?? null,
|
||||||
|
dto.longitude ?? null,
|
||||||
|
dto.accuracyM ?? null,
|
||||||
|
source,
|
||||||
|
principal.userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const created = await this.loadActive(manager, id);
|
||||||
|
const versionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
assetId,
|
||||||
|
AssetVersionChangeType.MEDIA_UPLOADED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_MEDIA_UPLOADED,
|
||||||
|
entityType: 'asset_media',
|
||||||
|
entityId: id,
|
||||||
|
afterData: this.auditView(created),
|
||||||
|
metadata: { assetId, versionNumber },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
const { storedName: _storedName, ...view } = created;
|
||||||
|
return view;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await unlink(filePath).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
mediaId: string,
|
||||||
|
dto: UpdateAssetMediaDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AssetMediaView> {
|
||||||
|
if (Object.keys(dto).length === 0) {
|
||||||
|
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||||
|
}
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const before = await this.loadActive(manager, mediaId, true);
|
||||||
|
const latitude = dto.latitude === undefined ? before.latitude : dto.latitude;
|
||||||
|
const longitude = dto.longitude === undefined ? before.longitude : dto.longitude;
|
||||||
|
const accuracyM = dto.accuracyM === undefined ? before.accuracyM : dto.accuracyM;
|
||||||
|
this.validateCoordinates(latitude, longitude, accuracyM);
|
||||||
|
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE asset_media SET
|
||||||
|
title = $2,
|
||||||
|
description = $3,
|
||||||
|
captured_at = $4,
|
||||||
|
latitude = $5,
|
||||||
|
longitude = $6,
|
||||||
|
accuracy_m = $7,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[
|
||||||
|
mediaId,
|
||||||
|
dto.title === undefined ? before.title : dto.title?.trim() || null,
|
||||||
|
dto.description === undefined
|
||||||
|
? before.description
|
||||||
|
: dto.description?.trim() || null,
|
||||||
|
dto.capturedAt === undefined
|
||||||
|
? before.capturedAt
|
||||||
|
: dto.capturedAt ? new Date(dto.capturedAt) : null,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracyM,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const updated = await this.loadActive(manager, mediaId);
|
||||||
|
const versionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
updated.assetId,
|
||||||
|
AssetVersionChangeType.MEDIA_UPDATED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_MEDIA_UPDATED,
|
||||||
|
entityType: 'asset_media',
|
||||||
|
entityId: mediaId,
|
||||||
|
beforeData: this.auditView(before),
|
||||||
|
afterData: this.auditView(updated),
|
||||||
|
metadata: { assetId: updated.assetId, versionNumber },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
const { storedName: _storedName, ...view } = updated;
|
||||||
|
return view;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(
|
||||||
|
mediaId: string,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<{ status: 'removed' }> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const before = await this.loadActive(manager, mediaId, true);
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE asset_media
|
||||||
|
SET deleted_at = CURRENT_TIMESTAMP,
|
||||||
|
deleted_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[mediaId, principal.userId],
|
||||||
|
);
|
||||||
|
const versionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
before.assetId,
|
||||||
|
AssetVersionChangeType.MEDIA_REMOVED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_MEDIA_REMOVED,
|
||||||
|
entityType: 'asset_media',
|
||||||
|
entityId: mediaId,
|
||||||
|
beforeData: this.auditView(before),
|
||||||
|
afterData: { active: false },
|
||||||
|
metadata: { assetId: before.assetId, versionNumber, physicalFileRetained: true },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return { status: 'removed' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async content(mediaId: string): Promise<{
|
||||||
|
filePath: string;
|
||||||
|
media: StoredAssetMedia;
|
||||||
|
}> {
|
||||||
|
const media = await this.loadActive(this.dataSource.manager, mediaId);
|
||||||
|
const filePath = resolve(this.storageRoot, media.storedName);
|
||||||
|
if (!filePath.startsWith(`${this.storageRoot}/`)) {
|
||||||
|
throw new InternalServerErrorException({
|
||||||
|
code: 'INVALID_MEDIA_STORAGE_PATH',
|
||||||
|
message: 'Ruta de almacenamiento inválida',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(filePath);
|
||||||
|
if (!fileStat.isFile() || fileStat.size !== media.sizeBytes) throw new Error('size mismatch');
|
||||||
|
} catch {
|
||||||
|
throw new InternalServerErrorException({
|
||||||
|
code: 'ASSET_MEDIA_FILE_MISSING',
|
||||||
|
message: 'El archivo físico no está disponible',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { filePath, media };
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateCoordinates(
|
||||||
|
latitude: number | null | undefined,
|
||||||
|
longitude: number | null | undefined,
|
||||||
|
accuracyM: number | null | undefined,
|
||||||
|
): void {
|
||||||
|
const hasLatitude = latitude !== null && latitude !== undefined;
|
||||||
|
const hasLongitude = longitude !== null && longitude !== undefined;
|
||||||
|
if (hasLatitude !== hasLongitude) throw coordinateError();
|
||||||
|
if (accuracyM !== null && accuracyM !== undefined && !hasLatitude) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'MEDIA_ACCURACY_WITHOUT_COORDINATES',
|
||||||
|
message: 'La precisión GPS requiere latitud y longitud',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAsset(
|
||||||
|
manager: EntityManager,
|
||||||
|
assetId: string,
|
||||||
|
lock: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const suffix = lock ? ' FOR UPDATE' : '';
|
||||||
|
const [row] = (await manager.query(
|
||||||
|
`SELECT 1 FROM assets WHERE id = $1${suffix}`,
|
||||||
|
[assetId],
|
||||||
|
)) as unknown[];
|
||||||
|
if (!row) throw assetNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadActive(
|
||||||
|
manager: EntityManager,
|
||||||
|
mediaId: string,
|
||||||
|
lock = false,
|
||||||
|
): Promise<StoredAssetMedia> {
|
||||||
|
const [row] = (await manager.query(
|
||||||
|
`${this.mediaSelect()}
|
||||||
|
WHERE media.id = $1 AND media.deleted_at IS NULL
|
||||||
|
${lock ? 'FOR UPDATE OF media' : ''}`,
|
||||||
|
[mediaId],
|
||||||
|
)) as StoredAssetMedia[];
|
||||||
|
if (!row) throw mediaNotFound();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mediaSelect(): string {
|
||||||
|
return `SELECT
|
||||||
|
media.id,
|
||||||
|
media.asset_id AS "assetId",
|
||||||
|
media.kind,
|
||||||
|
media.original_name AS "originalName",
|
||||||
|
media.stored_name AS "storedName",
|
||||||
|
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.uploaded_by AS "uploadedBy",
|
||||||
|
uploader.username AS "uploadedByUsername",
|
||||||
|
media.created_at AS "createdAt",
|
||||||
|
media.updated_at AS "updatedAt"
|
||||||
|
FROM asset_media media
|
||||||
|
LEFT JOIN users uploader ON uploader.id = media.uploaded_by`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private auditView(media: StoredAssetMedia): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: media.id,
|
||||||
|
assetId: media.assetId,
|
||||||
|
kind: media.kind,
|
||||||
|
originalName: media.originalName,
|
||||||
|
mimeType: media.mimeType,
|
||||||
|
sizeBytes: media.sizeBytes,
|
||||||
|
sha256: media.sha256,
|
||||||
|
title: media.title,
|
||||||
|
description: media.description,
|
||||||
|
capturedAt: media.capturedAt,
|
||||||
|
latitude: media.latitude,
|
||||||
|
longitude: media.longitude,
|
||||||
|
accuracyM: media.accuracyM,
|
||||||
|
source: media.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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 { AssetOperationalRelationsService } from './asset-operational-relations.service';
|
||||||
|
import { CreateAreaCompanyRelationDto } from './dto/create-area-company-relation.dto';
|
||||||
|
import { EndAreaCompanyRelationDto } from './dto/end-area-company-relation.dto';
|
||||||
|
import { ListAreaCompanyRelationsQueryDto } from './dto/list-area-company-relations-query.dto';
|
||||||
|
import { ListOperationalAreasQueryDto } from './dto/list-operational-areas-query.dto';
|
||||||
|
|
||||||
|
@Controller('asset-operational-relations')
|
||||||
|
export class AssetOperationalRelationsController {
|
||||||
|
constructor(private readonly relations: AssetOperationalRelationsService) {}
|
||||||
|
|
||||||
|
@Get('areas')
|
||||||
|
@RequirePermissions('asset_relations.read')
|
||||||
|
areas(@Query() query: ListOperationalAreasQueryDto) {
|
||||||
|
return this.relations.listAreas(query.parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('companies')
|
||||||
|
@RequirePermissions('asset_relations.read')
|
||||||
|
companies() {
|
||||||
|
return this.relations.listCompanies();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('organizations')
|
||||||
|
@RequirePermissions('asset_relations.read')
|
||||||
|
organizations() {
|
||||||
|
return this.relations.listCompanies();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('areas/:areaId/companies')
|
||||||
|
@RequirePermissions('asset_relations.read')
|
||||||
|
companiesForArea(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) {
|
||||||
|
return this.relations.listCompaniesForArea(areaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('areas/:areaId/organizations')
|
||||||
|
@RequirePermissions('asset_relations.read')
|
||||||
|
organizationsForArea(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) {
|
||||||
|
return this.relations.listCompaniesForArea(areaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('companies/:companyId/areas')
|
||||||
|
@RequirePermissions('asset_relations.read')
|
||||||
|
areasForCompany(@Param('companyId', new ParseUUIDPipe({ version: '4' })) companyId: string) {
|
||||||
|
return this.relations.listAreasForCompany(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('organizations/:organizationId/areas')
|
||||||
|
@RequirePermissions('asset_relations.read')
|
||||||
|
areasForOrganization(@Param('organizationId', new ParseUUIDPipe({ version: '4' })) organizationId: string) {
|
||||||
|
return this.relations.listAreasForCompany(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('asset_relations.read')
|
||||||
|
list(@Query() query: ListAreaCompanyRelationsQueryDto) {
|
||||||
|
return this.relations.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('asset_relations.manage')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateAreaCompanyRelationDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.relations.create(dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/end')
|
||||||
|
@RequirePermissions('asset_relations.manage')
|
||||||
|
end(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: EndAreaCompanyRelationDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.relations.end(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { AreaOrganizationRole, AssetTypeOperationalRole, AuditAction } from '../database/entities';
|
||||||
|
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
|
||||||
|
import type { CreateAreaCompanyRelationDto } from './dto/create-area-company-relation.dto';
|
||||||
|
import type { EndAreaCompanyRelationDto } from './dto/end-area-company-relation.dto';
|
||||||
|
import type { ListAreaCompanyRelationsQueryDto } from './dto/list-area-company-relations-query.dto';
|
||||||
|
|
||||||
|
export interface OperationalAssetSummary {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
commonName: string | null;
|
||||||
|
typeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AreaCompanyRelationView {
|
||||||
|
id: string;
|
||||||
|
area: OperationalAssetSummary;
|
||||||
|
company: OperationalAssetSummary;
|
||||||
|
relationRole: AreaOrganizationRole;
|
||||||
|
participationPercent: number | null;
|
||||||
|
legalInstrument: string | null;
|
||||||
|
sourceDocumentId: string | null;
|
||||||
|
validFrom: Date;
|
||||||
|
validUntil: Date | null;
|
||||||
|
startReason: string;
|
||||||
|
endReason: string | null;
|
||||||
|
createdBy: { id: string; username: string } | null;
|
||||||
|
endedBy: { id: string; username: string } | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
active: boolean;
|
||||||
|
assignedAssetCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetOperationalRelationsService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listAreas(parentId?: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||||
|
if (!parentId) {
|
||||||
|
return { data: await this.listAssetsByRole(AssetTypeOperationalRole.AREA) };
|
||||||
|
}
|
||||||
|
const data = (await this.dataSource.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 current ON parent.id = current.parent_id
|
||||||
|
)
|
||||||
|
SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset_type.name AS "typeName"
|
||||||
|
FROM ancestors
|
||||||
|
INNER JOIN assets asset ON asset.id = ancestors.id
|
||||||
|
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||||
|
WHERE asset_type.operational_role = $2
|
||||||
|
AND asset_type.is_active = true
|
||||||
|
AND asset.information_status <> 'INACTIVE'
|
||||||
|
ORDER BY asset.name, asset.code
|
||||||
|
`, [parentId, AssetTypeOperationalRole.AREA])) as OperationalAssetSummary[];
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCompanies(): Promise<{ data: OperationalAssetSummary[] }> {
|
||||||
|
return { data: await this.listAssetsByRole(AssetTypeOperationalRole.COMPANY) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||||
|
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
|
||||||
|
const data = (await this.dataSource.query(`
|
||||||
|
SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName"
|
||||||
|
FROM (
|
||||||
|
SELECT relation.company_id
|
||||||
|
FROM area_company_relations relation
|
||||||
|
WHERE relation.area_id = $1
|
||||||
|
AND relation.relation_role = 'OPERATOR'
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT asset.operator_company_id AS company_id
|
||||||
|
FROM assets asset
|
||||||
|
WHERE asset.operational_area_id = $1
|
||||||
|
AND asset.operator_company_id IS NOT NULL
|
||||||
|
AND asset.information_status <> 'INACTIVE'
|
||||||
|
) linked
|
||||||
|
INNER JOIN assets company ON company.id = linked.company_id
|
||||||
|
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
|
||||||
|
WHERE company.information_status <> 'INACTIVE'
|
||||||
|
ORDER BY company.name, company.code
|
||||||
|
`, [areaId])) as OperationalAssetSummary[];
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||||
|
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
|
||||||
|
const data = (await this.dataSource.query(`
|
||||||
|
SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName"
|
||||||
|
FROM (
|
||||||
|
SELECT relation.area_id
|
||||||
|
FROM area_company_relations relation
|
||||||
|
WHERE relation.company_id = $1
|
||||||
|
AND relation.relation_role = 'OPERATOR'
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT asset.operational_area_id AS area_id
|
||||||
|
FROM assets asset
|
||||||
|
WHERE asset.operator_company_id = $1
|
||||||
|
AND asset.operational_area_id IS NOT NULL
|
||||||
|
AND asset.information_status <> 'INACTIVE'
|
||||||
|
) linked
|
||||||
|
INNER JOIN assets area ON area.id = linked.area_id
|
||||||
|
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||||
|
WHERE area.information_status <> 'INACTIVE'
|
||||||
|
ORDER BY area.name, area.code
|
||||||
|
`, [companyId])) as OperationalAssetSummary[];
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: ListAreaCompanyRelationsQueryDto): Promise<{ data: AreaCompanyRelationView[] }> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const parameters: unknown[] = [];
|
||||||
|
const add = (value: unknown): string => {
|
||||||
|
parameters.push(value);
|
||||||
|
return `$${parameters.length}`;
|
||||||
|
};
|
||||||
|
if (query.areaId) conditions.push(`relation.area_id = ${add(query.areaId)}`);
|
||||||
|
if (query.companyId) conditions.push(`relation.company_id = ${add(query.companyId)}`);
|
||||||
|
if (!query.includeHistory) conditions.push('relation.valid_until IS NULL');
|
||||||
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
const data = (await this.dataSource.query(
|
||||||
|
`${this.relationSelect(where)} ORDER BY relation.valid_until NULLS FIRST, relation.valid_from DESC`,
|
||||||
|
parameters,
|
||||||
|
)) as AreaCompanyRelationView[];
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateAreaCompanyRelationDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AreaCompanyRelationView> {
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.requireAssetRole(manager, dto.areaId, AssetTypeOperationalRole.AREA);
|
||||||
|
await this.requireAssetRole(manager, dto.companyId, AssetTypeOperationalRole.COMPANY);
|
||||||
|
if (dto.sourceDocumentId) {
|
||||||
|
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
|
||||||
|
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
|
||||||
|
}
|
||||||
|
const [row] = (await manager.query(`
|
||||||
|
INSERT INTO area_company_relations (
|
||||||
|
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id
|
||||||
|
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
|
||||||
|
const created = await this.loadRelation(manager, row.id);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_AREA_COMPANY_RELATION_CREATED,
|
||||||
|
entityType: 'area_company_relation',
|
||||||
|
entityId: row.id,
|
||||||
|
afterData: this.auditView(created),
|
||||||
|
}, manager);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'AREA_COMPANY_RELATION_EXISTS',
|
||||||
|
message: 'La organización ya tiene ese rol activo en el área',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async end(
|
||||||
|
id: string,
|
||||||
|
dto: EndAreaCompanyRelationDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AreaCompanyRelationView> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const before = await this.loadRelation(manager, id, true);
|
||||||
|
if (!before.active) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'AREA_COMPANY_RELATION_ALREADY_ENDED',
|
||||||
|
message: 'La relación ya se encuentra finalizada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (before.assignedAssetCount > 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'AREA_COMPANY_RELATION_IN_USE',
|
||||||
|
message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE area_company_relations
|
||||||
|
SET valid_until = CURRENT_TIMESTAMP,
|
||||||
|
end_reason = $2,
|
||||||
|
ended_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1 AND valid_until IS NULL
|
||||||
|
`, [id, dto.reason, principal.userId]);
|
||||||
|
const updated = await this.loadRelation(manager, id);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_AREA_COMPANY_RELATION_ENDED,
|
||||||
|
entityType: 'area_company_relation',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: this.auditView(before),
|
||||||
|
afterData: this.auditView(updated),
|
||||||
|
}, manager);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listAssetsByRole(role: AssetTypeOperationalRole): Promise<OperationalAssetSummary[]> {
|
||||||
|
return (await this.dataSource.query(`
|
||||||
|
SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset_type.name AS "typeName"
|
||||||
|
FROM assets asset
|
||||||
|
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||||
|
WHERE asset_type.operational_role = $1
|
||||||
|
AND asset_type.is_active = true
|
||||||
|
AND asset.information_status <> 'INACTIVE'
|
||||||
|
ORDER BY asset.name, asset.code
|
||||||
|
`, [role])) as OperationalAssetSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAssetRole(
|
||||||
|
manager: EntityManager,
|
||||||
|
assetId: string,
|
||||||
|
role: AssetTypeOperationalRole,
|
||||||
|
): Promise<void> {
|
||||||
|
const [row] = (await manager.query(`
|
||||||
|
SELECT
|
||||||
|
asset.id,
|
||||||
|
asset.information_status AS "informationStatus",
|
||||||
|
asset_type.operational_role AS role,
|
||||||
|
asset_type.is_active AS "typeActive"
|
||||||
|
FROM assets asset
|
||||||
|
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||||
|
WHERE asset.id = $1
|
||||||
|
`, [assetId])) as Array<{
|
||||||
|
id: string;
|
||||||
|
informationStatus: string;
|
||||||
|
role: AssetTypeOperationalRole;
|
||||||
|
typeActive: boolean;
|
||||||
|
}>;
|
||||||
|
if (!row) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'OPERATIONAL_ASSET_NOT_FOUND',
|
||||||
|
message: role === AssetTypeOperationalRole.AREA ? 'El área seleccionada no existe' : 'La empresa seleccionada no existe',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (row.role !== role) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'OPERATIONAL_ASSET_ROLE_INVALID',
|
||||||
|
message: role === AssetTypeOperationalRole.AREA
|
||||||
|
? 'El activo seleccionado no está configurado como Área'
|
||||||
|
: 'El activo seleccionado no está configurado como Empresa',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!row.typeActive || row.informationStatus === 'INACTIVE') {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'OPERATIONAL_ASSET_INACTIVE',
|
||||||
|
message: role === AssetTypeOperationalRole.AREA
|
||||||
|
? 'El área o su tipo se encuentra inactivo'
|
||||||
|
: 'La empresa o su tipo se encuentra inactivo',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadRelation(
|
||||||
|
manager: EntityManager,
|
||||||
|
id: string,
|
||||||
|
lock = false,
|
||||||
|
): Promise<AreaCompanyRelationView> {
|
||||||
|
if (lock) {
|
||||||
|
const rows = await manager.query(
|
||||||
|
'SELECT id FROM area_company_relations WHERE id = $1 FOR UPDATE',
|
||||||
|
[id],
|
||||||
|
) as unknown[];
|
||||||
|
if (rows.length === 0) throw this.relationNotFound();
|
||||||
|
}
|
||||||
|
const [row] = (await manager.query(
|
||||||
|
`${this.relationSelect('WHERE relation.id = $1')}`,
|
||||||
|
[id],
|
||||||
|
)) as AreaCompanyRelationView[];
|
||||||
|
if (!row) throw this.relationNotFound();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private relationSelect(where: string): string {
|
||||||
|
return `
|
||||||
|
SELECT
|
||||||
|
relation.id,
|
||||||
|
JSONB_BUILD_OBJECT(
|
||||||
|
'id', area.id, 'code', area.code, 'name', area.name, 'commonName', area.common_name, 'typeName', area_type.name
|
||||||
|
) AS area,
|
||||||
|
JSONB_BUILD_OBJECT(
|
||||||
|
'id', company.id, 'code', company.code, 'name', company.name, 'commonName', company.common_name, 'typeName', company_type.name
|
||||||
|
) AS company,
|
||||||
|
relation.relation_role AS "relationRole",
|
||||||
|
relation.participation_percent::double precision AS "participationPercent",
|
||||||
|
relation.legal_instrument AS "legalInstrument",
|
||||||
|
relation.source_document_id AS "sourceDocumentId",
|
||||||
|
relation.valid_from AS "validFrom",
|
||||||
|
relation.valid_until AS "validUntil",
|
||||||
|
relation.start_reason AS "startReason",
|
||||||
|
relation.end_reason AS "endReason",
|
||||||
|
CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id', creator.id, 'username', creator.username
|
||||||
|
) END AS "createdBy",
|
||||||
|
CASE WHEN ender.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id', ender.id, 'username', ender.username
|
||||||
|
) END AS "endedBy",
|
||||||
|
relation.created_at AS "createdAt",
|
||||||
|
relation.updated_at AS "updatedAt",
|
||||||
|
(relation.valid_until IS NULL) AS active,
|
||||||
|
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
|
||||||
|
WHERE asset.operational_area_id = relation.area_id
|
||||||
|
AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount"
|
||||||
|
FROM area_company_relations relation
|
||||||
|
INNER JOIN assets area ON area.id = relation.area_id
|
||||||
|
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||||
|
INNER JOIN assets company ON company.id = relation.company_id
|
||||||
|
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
|
||||||
|
LEFT JOIN users creator ON creator.id = relation.created_by
|
||||||
|
LEFT JOIN users ender ON ender.id = relation.ended_by
|
||||||
|
${where}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private relationNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'AREA_COMPANY_RELATION_NOT_FOUND',
|
||||||
|
message: 'Relación entre área y empresa no encontrada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private auditView(relation: AreaCompanyRelationView): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: relation.id,
|
||||||
|
areaId: relation.area.id,
|
||||||
|
companyId: relation.company.id,
|
||||||
|
relationRole: relation.relationRole,
|
||||||
|
participationPercent: relation.participationPercent,
|
||||||
|
legalInstrument: relation.legalInstrument,
|
||||||
|
sourceDocumentId: relation.sourceDocumentId,
|
||||||
|
validFrom: relation.validFrom,
|
||||||
|
validUntil: relation.validUntil,
|
||||||
|
startReason: relation.startReason,
|
||||||
|
endReason: relation.endReason,
|
||||||
|
active: relation.active,
|
||||||
|
assignedAssetCount: relation.assignedAssetCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../common/http/request-context';
|
||||||
|
import { AssetProvenanceService } from './asset-provenance.service';
|
||||||
|
import { UpdateAssetProvenanceDto } from './dto/update-asset-provenance.dto';
|
||||||
|
|
||||||
|
@Controller('assets/:assetId/provenance')
|
||||||
|
export class AssetProvenanceController {
|
||||||
|
constructor(private readonly provenance: AssetProvenanceService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('assets.read_provenance')
|
||||||
|
get(
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
) {
|
||||||
|
return this.provenance.get(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
@RequirePermissions('assets.manage_provenance')
|
||||||
|
update(
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
@Body() dto: UpdateAssetProvenanceDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.provenance.update(assetId, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('verify')
|
||||||
|
@RequirePermissions('assets.verify_provenance')
|
||||||
|
verify(
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.provenance.verify(assetId, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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 {
|
||||||
|
AssetDataOrigin,
|
||||||
|
AssetVersionChangeType,
|
||||||
|
AuditAction,
|
||||||
|
} from '../database/entities';
|
||||||
|
import { AssetHistoryService } from './asset-history.service';
|
||||||
|
import type { UpdateAssetProvenanceDto } from './dto/update-asset-provenance.dto';
|
||||||
|
|
||||||
|
export interface AssetProvenanceView {
|
||||||
|
assetId: string;
|
||||||
|
origin: AssetDataOrigin;
|
||||||
|
sourceName: string | null;
|
||||||
|
sourceReference: string | null;
|
||||||
|
observedAt: Date | null;
|
||||||
|
notes: string | null;
|
||||||
|
verifiedAt: Date | null;
|
||||||
|
verifiedBy: string | null;
|
||||||
|
verifiedByUsername: string | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedBy: string | null;
|
||||||
|
updatedByUsername: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'ASSET_NOT_FOUND',
|
||||||
|
message: 'Activo no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetProvenanceService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly history: AssetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get(assetId: string): Promise<AssetProvenanceView> {
|
||||||
|
return this.load(this.dataSource.manager, assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
assetId: string,
|
||||||
|
dto: UpdateAssetProvenanceDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AssetProvenanceView> {
|
||||||
|
this.validateSource(dto);
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const before = await this.load(manager, assetId, true);
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE assets SET
|
||||||
|
data_origin = $2,
|
||||||
|
source_name = $3,
|
||||||
|
source_reference = $4,
|
||||||
|
source_observed_at = $5,
|
||||||
|
source_notes = $6,
|
||||||
|
provenance_verified_at = NULL,
|
||||||
|
provenance_verified_by = NULL,
|
||||||
|
provenance_updated_at = CURRENT_TIMESTAMP,
|
||||||
|
provenance_updated_by = $7,
|
||||||
|
updated_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $7
|
||||||
|
WHERE id = $1`,
|
||||||
|
[
|
||||||
|
assetId,
|
||||||
|
dto.origin,
|
||||||
|
dto.sourceName?.trim() || null,
|
||||||
|
dto.sourceReference?.trim() || null,
|
||||||
|
dto.observedAt ? new Date(dto.observedAt) : null,
|
||||||
|
dto.notes?.trim() || null,
|
||||||
|
principal.userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const versionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
assetId,
|
||||||
|
AssetVersionChangeType.PROVENANCE_UPDATED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
const updated = await this.load(manager, assetId);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_PROVENANCE_UPDATED,
|
||||||
|
entityType: 'asset_provenance',
|
||||||
|
entityId: assetId,
|
||||||
|
beforeData: { ...before },
|
||||||
|
afterData: { ...updated },
|
||||||
|
metadata: { versionNumber, verificationCleared: before.verifiedAt !== null },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify(
|
||||||
|
assetId: string,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AssetProvenanceView> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const before = await this.load(manager, assetId, true);
|
||||||
|
if (before.verifiedAt) return before;
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE assets SET
|
||||||
|
provenance_verified_at = CURRENT_TIMESTAMP,
|
||||||
|
provenance_verified_by = $2,
|
||||||
|
provenance_updated_at = CURRENT_TIMESTAMP,
|
||||||
|
provenance_updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $2
|
||||||
|
WHERE id = $1`,
|
||||||
|
[assetId, principal.userId],
|
||||||
|
);
|
||||||
|
const versionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
assetId,
|
||||||
|
AssetVersionChangeType.PROVENANCE_VERIFIED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
const verified = await this.load(manager, assetId);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_PROVENANCE_VERIFIED,
|
||||||
|
entityType: 'asset_provenance',
|
||||||
|
entityId: assetId,
|
||||||
|
beforeData: { ...before },
|
||||||
|
afterData: { ...verified },
|
||||||
|
metadata: { versionNumber },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return verified;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateSource(dto: UpdateAssetProvenanceDto): void {
|
||||||
|
const requiresNamedSource = dto.origin === AssetDataOrigin.PROVIDED_DOCUMENT
|
||||||
|
|| dto.origin === AssetDataOrigin.IMPORT;
|
||||||
|
if (requiresNamedSource && !dto.sourceName?.trim()) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'PROVENANCE_SOURCE_REQUIRED',
|
||||||
|
message: 'La documentación recibida y las importaciones requieren identificar la fuente',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(
|
||||||
|
manager: EntityManager,
|
||||||
|
assetId: string,
|
||||||
|
lock = false,
|
||||||
|
): Promise<AssetProvenanceView> {
|
||||||
|
const [row] = (await manager.query(
|
||||||
|
`SELECT
|
||||||
|
asset.id AS "assetId",
|
||||||
|
asset.data_origin AS origin,
|
||||||
|
asset.source_name AS "sourceName",
|
||||||
|
asset.source_reference AS "sourceReference",
|
||||||
|
asset.source_observed_at AS "observedAt",
|
||||||
|
asset.source_notes AS notes,
|
||||||
|
asset.provenance_verified_at AS "verifiedAt",
|
||||||
|
asset.provenance_verified_by AS "verifiedBy",
|
||||||
|
verifier.username AS "verifiedByUsername",
|
||||||
|
asset.provenance_updated_at AS "updatedAt",
|
||||||
|
asset.provenance_updated_by AS "updatedBy",
|
||||||
|
updater.username AS "updatedByUsername"
|
||||||
|
FROM assets asset
|
||||||
|
LEFT JOIN users verifier ON verifier.id = asset.provenance_verified_by
|
||||||
|
LEFT JOIN users updater ON updater.id = asset.provenance_updated_by
|
||||||
|
WHERE asset.id = $1
|
||||||
|
${lock ? 'FOR UPDATE OF asset' : ''}`,
|
||||||
|
[assetId],
|
||||||
|
)) as AssetProvenanceView[];
|
||||||
|
if (!row) throw assetNotFound();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req } from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { AssetRegistryService } from './asset-registry.service';
|
||||||
|
import { AddAreaLegalRightOrganizationDto } from './dto/add-area-legal-right-organization.dto';
|
||||||
|
import { AddOrganizationMembershipDto } from './dto/add-organization-membership.dto';
|
||||||
|
import { CreateAreaLegalRightDto } from './dto/create-area-legal-right.dto';
|
||||||
|
import { CreateExternalIdentifierDto } from './dto/create-external-identifier.dto';
|
||||||
|
import { CreateSourceDocumentDto } from './dto/create-source-document.dto';
|
||||||
|
import { EndAreaLegalRightOrganizationDto } from './dto/end-area-legal-right-organization.dto';
|
||||||
|
import { EndExternalIdentifierDto } from './dto/end-external-identifier.dto';
|
||||||
|
import { EndOrganizationMembershipDto } from './dto/end-organization-membership.dto';
|
||||||
|
import { LinkAssetSourceDocumentDto } from './dto/link-asset-source-document.dto';
|
||||||
|
import { UpdateAreaLegalRightDto } from './dto/update-area-legal-right.dto';
|
||||||
|
import { UpsertOrganizationProfileDto } from './dto/upsert-organization-profile.dto';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AssetRegistryController {
|
||||||
|
constructor(private readonly registry: AssetRegistryService) {}
|
||||||
|
|
||||||
|
@Get('assets/:assetId/registry') @RequirePermissions('asset_registry.read')
|
||||||
|
get(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string){ return this.registry.get(assetId); }
|
||||||
|
|
||||||
|
@Get('source-documents') @RequirePermissions('asset_registry.read')
|
||||||
|
documents(@Query('search') search?:string){ return this.registry.listSourceDocuments(search); }
|
||||||
|
|
||||||
|
@Post('source-documents') @RequirePermissions('asset_registry.manage')
|
||||||
|
createDocument(@Body() dto:CreateSourceDocumentDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){ return this.registry.createSourceDocument(dto,principal,request); }
|
||||||
|
|
||||||
|
@Patch('assets/:assetId/organization-profile') @RequirePermissions('asset_registry.manage')
|
||||||
|
profile(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Body() dto:UpsertOrganizationProfileDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){ return this.registry.upsertOrganizationProfile(assetId,dto,principal,request); }
|
||||||
|
|
||||||
|
@Post('organizations/:organizationId/memberships') @RequirePermissions('asset_registry.manage')
|
||||||
|
addMembership(@Param('organizationId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:AddOrganizationMembershipDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addOrganizationMembership(id,dto,p,r); }
|
||||||
|
|
||||||
|
@Post('organization-memberships/:id/end') @RequirePermissions('asset_registry.manage')
|
||||||
|
endMembership(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndOrganizationMembershipDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endOrganizationMembership(id,dto,p,r); }
|
||||||
|
|
||||||
|
@Post('assets/:assetId/source-documents/:documentId') @RequirePermissions('asset_registry.manage')
|
||||||
|
link(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Param('documentId',new ParseUUIDPipe({version:'4'})) documentId:string,@Body() dto:LinkAssetSourceDocumentDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.linkDocument(assetId,documentId,dto,p,r); }
|
||||||
|
|
||||||
|
@Post('assets/:assetId/external-identifiers') @RequirePermissions('asset_registry.manage')
|
||||||
|
identifier(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Body() dto:CreateExternalIdentifierDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addExternalIdentifier(assetId,dto,p,r); }
|
||||||
|
|
||||||
|
@Post('asset-external-identifiers/:id/end') @RequirePermissions('asset_registry.manage')
|
||||||
|
endIdentifier(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndExternalIdentifierDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endExternalIdentifier(id,dto,p,r); }
|
||||||
|
|
||||||
|
@Post('areas/:areaId/legal-rights') @RequirePermissions('asset_registry.manage')
|
||||||
|
legalRight(@Param('areaId',new ParseUUIDPipe({version:'4'})) areaId:string,@Body() dto:CreateAreaLegalRightDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.createLegalRight(areaId,dto,p,r); }
|
||||||
|
|
||||||
|
@Patch('area-legal-rights/:rightId') @RequirePermissions('asset_registry.manage')
|
||||||
|
updateRight(@Param('rightId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:UpdateAreaLegalRightDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.updateLegalRight(id,dto,p,r); }
|
||||||
|
|
||||||
|
@Post('area-legal-rights/:rightId/organizations') @RequirePermissions('asset_registry.manage')
|
||||||
|
rightOrg(@Param('rightId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:AddAreaLegalRightOrganizationDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addLegalRightOrganization(id,dto,p,r); }
|
||||||
|
|
||||||
|
@Post('area-legal-right-organizations/:id/end') @RequirePermissions('asset_registry.manage')
|
||||||
|
endRightOrg(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndAreaLegalRightOrganizationDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endLegalRightOrganization(id,dto,p,r); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import {
|
||||||
|
AreaLegalRight,
|
||||||
|
AreaLegalRightOrganization,
|
||||||
|
Asset,
|
||||||
|
AssetExternalIdentifier,
|
||||||
|
AssetSourceDocument,
|
||||||
|
AssetTypeOperationalRole,
|
||||||
|
AssetVersionChangeType,
|
||||||
|
AuditAction,
|
||||||
|
OrganizationKind,
|
||||||
|
OrganizationMembership,
|
||||||
|
OrganizationProfile,
|
||||||
|
SourceDocument,
|
||||||
|
} from '../database/entities';
|
||||||
|
import { AssetHistoryService } from './asset-history.service';
|
||||||
|
import type { AddAreaLegalRightOrganizationDto } from './dto/add-area-legal-right-organization.dto';
|
||||||
|
import type { AddOrganizationMembershipDto } from './dto/add-organization-membership.dto';
|
||||||
|
import type { CreateAreaLegalRightDto } from './dto/create-area-legal-right.dto';
|
||||||
|
import type { CreateExternalIdentifierDto } from './dto/create-external-identifier.dto';
|
||||||
|
import type { CreateSourceDocumentDto } from './dto/create-source-document.dto';
|
||||||
|
import type { EndAreaLegalRightOrganizationDto } from './dto/end-area-legal-right-organization.dto';
|
||||||
|
import type { EndExternalIdentifierDto } from './dto/end-external-identifier.dto';
|
||||||
|
import type { EndOrganizationMembershipDto } from './dto/end-organization-membership.dto';
|
||||||
|
import type { LinkAssetSourceDocumentDto } from './dto/link-asset-source-document.dto';
|
||||||
|
import type { UpdateAreaLegalRightDto } from './dto/update-area-legal-right.dto';
|
||||||
|
import type { UpsertOrganizationProfileDto } from './dto/upsert-organization-profile.dto';
|
||||||
|
|
||||||
|
function registryNotFound(entity: string): NotFoundException {
|
||||||
|
return new NotFoundException({ code: 'ASSET_REGISTRY_NOT_FOUND', message: `${entity} no encontrado` });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetRegistryService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly history: AssetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async get(assetId: string) {
|
||||||
|
await this.requireAsset(this.dataSource.manager, assetId);
|
||||||
|
const [organizationProfile] = await this.dataSource.query(`
|
||||||
|
SELECT asset_id AS "assetId", organization_kind AS "organizationKind", legal_name AS "legalName",
|
||||||
|
tax_id AS "taxId", notification_email AS "notificationEmail", notes, created_at AS "createdAt", updated_at AS "updatedAt", updated_by AS "updatedBy"
|
||||||
|
FROM organization_profiles WHERE asset_id=$1
|
||||||
|
`, [assetId]);
|
||||||
|
const organizationMemberships = await this.dataSource.query(`
|
||||||
|
SELECT m.id,
|
||||||
|
JSONB_BUILD_OBJECT('id',p.id,'code',p.code,'name',p.name) AS parent,
|
||||||
|
JSONB_BUILD_OBJECT('id',member.id,'code',member.code,'name',member.name) AS member,
|
||||||
|
m.role, m.participation_percent::double precision AS "participationPercent",
|
||||||
|
m.valid_from AS "validFrom", m.valid_until AS "validUntil", m.source_document_id AS "sourceDocumentId",
|
||||||
|
m.notes, m.end_reason AS "endReason", m.created_at AS "createdAt", m.updated_at AS "updatedAt"
|
||||||
|
FROM organization_memberships m
|
||||||
|
JOIN assets p ON p.id=m.parent_organization_id
|
||||||
|
JOIN assets member ON member.id=m.member_organization_id
|
||||||
|
WHERE m.parent_organization_id=$1 OR m.member_organization_id=$1
|
||||||
|
ORDER BY m.valid_until NULLS FIRST, m.valid_from DESC
|
||||||
|
`, [assetId]);
|
||||||
|
const externalIdentifiers = await this.dataSource.query(`
|
||||||
|
SELECT id, namespace, value, valid_from AS "validFrom", valid_until AS "validUntil",
|
||||||
|
source_document_id AS "sourceDocumentId", notes, end_reason AS "endReason", created_at AS "createdAt"
|
||||||
|
FROM asset_external_identifiers WHERE asset_id=$1 ORDER BY valid_until NULLS FIRST, namespace, valid_from DESC
|
||||||
|
`, [assetId]);
|
||||||
|
const sourceDocuments = await this.dataSource.query(`
|
||||||
|
SELECT link.id AS "linkId", link.relation_type AS "relationType", link.notes AS "linkNotes",
|
||||||
|
doc.id, doc.document_type AS "documentType", doc.document_number AS "documentNumber", doc.title,
|
||||||
|
doc.issuer, doc.document_date AS "documentDate", doc.external_reference AS "externalReference", doc.notes
|
||||||
|
FROM asset_source_documents link JOIN source_documents doc ON doc.id=link.document_id
|
||||||
|
WHERE link.asset_id=$1 ORDER BY doc.document_date DESC NULLS LAST, doc.created_at DESC
|
||||||
|
`, [assetId]);
|
||||||
|
const legalRights = await this.dataSource.query(`
|
||||||
|
SELECT r.id, r.right_type AS "rightType", r.name, r.instrument_number AS "instrumentNumber",
|
||||||
|
r.valid_from AS "validFrom", r.valid_until AS "validUntil", r.status,
|
||||||
|
r.source_document_id AS "sourceDocumentId", r.notes,
|
||||||
|
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||||
|
'id',o.id,'organizationId',o.organization_id,'organizationName',a.name,'role',o.role,
|
||||||
|
'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,
|
||||||
|
'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason
|
||||||
|
) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC)
|
||||||
|
FROM area_legal_right_organizations o JOIN assets a ON a.id=o.organization_id WHERE o.right_id=r.id),'[]'::jsonb) AS organizations
|
||||||
|
FROM area_legal_rights r WHERE r.area_id=$1 ORDER BY r.valid_until DESC NULLS FIRST, r.valid_from DESC NULLS LAST
|
||||||
|
`, [assetId]);
|
||||||
|
return { organizationProfile: organizationProfile ?? null, organizationMemberships, externalIdentifiers, sourceDocuments, legalRights };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSourceDocuments(search?: string) {
|
||||||
|
const q = search?.trim();
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const where = q ? `WHERE title ILIKE $1 OR document_number ILIKE $1 OR issuer ILIKE $1` : '';
|
||||||
|
if (q) params.push(`%${q}%`);
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT id, document_type AS "documentType", document_number AS "documentNumber", title, issuer,
|
||||||
|
document_date AS "documentDate", external_reference AS "externalReference", notes,
|
||||||
|
created_at AS "createdAt", updated_at AS "updatedAt"
|
||||||
|
FROM source_documents ${where}
|
||||||
|
ORDER BY document_date DESC NULLS LAST, created_at DESC LIMIT 100
|
||||||
|
`, params);
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSourceDocument(dto: CreateSourceDocumentDto, principal: AuthPrincipal, request: RequestWithContext) {
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async manager => {
|
||||||
|
const doc = manager.getRepository(SourceDocument).create({
|
||||||
|
documentType: dto.documentType,
|
||||||
|
documentNumber: dto.documentNumber ?? null,
|
||||||
|
title: dto.title,
|
||||||
|
issuer: dto.issuer ?? null,
|
||||||
|
documentDate: dto.documentDate ?? null,
|
||||||
|
externalReference: dto.externalReference ?? null,
|
||||||
|
notes: dto.notes ?? null,
|
||||||
|
createdBy: principal.userId,
|
||||||
|
updatedBy: principal.userId,
|
||||||
|
});
|
||||||
|
await manager.getRepository(SourceDocument).save(doc);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request), action: AuditAction.SOURCE_DOCUMENT_CREATED,
|
||||||
|
entityType: 'source_document', entityId: doc.id, afterData: { ...doc },
|
||||||
|
}, manager);
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) throw new ConflictException({ code:'SOURCE_DOCUMENT_EXISTS', message:'Ya existe un documento con ese número y emisor' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertOrganizationProfile(assetId: string, dto: UpsertOrganizationProfileDto, principal: AuthPrincipal, request: RequestWithContext) {
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async manager => {
|
||||||
|
const asset = await this.requireRole(manager, assetId, AssetTypeOperationalRole.COMPANY);
|
||||||
|
const repo = manager.getRepository(OrganizationProfile);
|
||||||
|
const before = await repo.findOne({ where: { assetId } });
|
||||||
|
if (before && before.organizationKind !== dto.organizationKind) {
|
||||||
|
const [usage] = await manager.query(`SELECT 1 FROM organization_memberships WHERE (parent_organization_id=$1 OR member_organization_id=$1) AND valid_until IS NULL LIMIT 1`, [assetId]);
|
||||||
|
if (usage) throw new ConflictException({ code:'ORGANIZATION_KIND_IN_USE', message:'No se puede cambiar el tipo de organización mientras tenga una composición UTE activa' });
|
||||||
|
}
|
||||||
|
const profile = repo.create({ ...(before ?? {}), assetId, organizationKind:dto.organizationKind, legalName:dto.legalName ?? asset.name, taxId:dto.taxId ?? null, notificationEmail:dto.notificationEmail ?? null, notes:dto.notes ?? null, updatedBy:principal.userId });
|
||||||
|
await repo.save(profile);
|
||||||
|
const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.REGISTRY_UPDATED, principal, request);
|
||||||
|
await this.audit.record({ ...administrationAuditContext(principal,request), action:AuditAction.ASSET_REGISTRY_UPDATED, entityType:'organization_profile', entityId:assetId, beforeData:before ? { ...before }:null, afterData:{ ...profile }, metadata:{versionNumber} }, manager);
|
||||||
|
return profile;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) throw new ConflictException({ code:'ORGANIZATION_TAX_ID_EXISTS', message:'El identificador fiscal ya pertenece a otra organización' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addOrganizationMembership(parentId:string, dto:AddOrganizationMembershipDto, principal:AuthPrincipal, request:RequestWithContext) {
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async manager => {
|
||||||
|
await this.requireRole(manager,parentId,AssetTypeOperationalRole.COMPANY);
|
||||||
|
await manager.query('SELECT id FROM assets WHERE id=$1 FOR UPDATE',[parentId]);
|
||||||
|
await this.requireRole(manager,dto.memberOrganizationId,AssetTypeOperationalRole.COMPANY);
|
||||||
|
const parentProfile = await manager.getRepository(OrganizationProfile).findOne({where:{assetId:parentId}});
|
||||||
|
const memberProfile = await manager.getRepository(OrganizationProfile).findOne({where:{assetId:dto.memberOrganizationId}});
|
||||||
|
if (parentProfile?.organizationKind !== OrganizationKind.UTE) throw new BadRequestException({code:'UTE_REQUIRED',message:'La organización contenedora debe estar configurada como UTE'});
|
||||||
|
if (memberProfile?.organizationKind === OrganizationKind.UTE) throw new BadRequestException({code:'UTE_MEMBER_INVALID',message:'Una UTE no puede ser miembro directo de otra UTE'});
|
||||||
|
if (dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId);
|
||||||
|
const validFrom = dto.validFrom ?? this.today();
|
||||||
|
const [overlap] = await manager.query(`
|
||||||
|
SELECT 1 FROM organization_memberships
|
||||||
|
WHERE parent_organization_id=$1 AND member_organization_id=$2 AND role=$3
|
||||||
|
AND daterange(valid_from,COALESCE(valid_until,'infinity'::date),'[]') && daterange($4::date,'infinity'::date,'[]')
|
||||||
|
LIMIT 1
|
||||||
|
`,[parentId,dto.memberOrganizationId,dto.role,validFrom]);
|
||||||
|
if (overlap) throw new ConflictException({code:'UTE_MEMBERSHIP_OVERLAP',message:'La participación se superpone con una vigencia histórica existente'});
|
||||||
|
if (dto.participationPercent != null) {
|
||||||
|
const [sum] = await manager.query(`SELECT COALESCE(SUM(participation_percent),0)::double precision AS total FROM organization_memberships WHERE parent_organization_id=$1 AND valid_until IS NULL`,[parentId]);
|
||||||
|
if (Number(sum?.total ?? 0) + dto.participationPercent > 100.0001) throw new BadRequestException({code:'UTE_PARTICIPATION_EXCEEDS_100',message:'La participación activa de la UTE no puede superar el 100%'});
|
||||||
|
}
|
||||||
|
const repo=manager.getRepository(OrganizationMembership);
|
||||||
|
const membership=repo.create({parentOrganizationId:parentId,memberOrganizationId:dto.memberOrganizationId,role:dto.role,participationPercent:dto.participationPercent == null ? null:String(dto.participationPercent),validFrom,validUntil:null,sourceDocumentId:dto.sourceDocumentId ?? null,notes:dto.notes ?? null,endReason:null,createdBy:principal.userId,endedBy:null});
|
||||||
|
await repo.save(membership);
|
||||||
|
await this.captureAssets(manager,[parentId,dto.memberOrganizationId],principal,request);
|
||||||
|
await this.audit.record({ ...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'organization_membership',entityId:membership.id,afterData:{...membership}},manager);
|
||||||
|
return membership;
|
||||||
|
});
|
||||||
|
} catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'UTE_MEMBERSHIP_EXISTS',message:'La participación ya se encuentra activa'}); throw error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async endOrganizationMembership(id:string,dto:EndOrganizationMembershipDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||||
|
return this.dataSource.transaction(async manager=>{
|
||||||
|
const repo=manager.getRepository(OrganizationMembership);
|
||||||
|
const membership=await repo.createQueryBuilder('m').where('m.id=:id',{id}).setLock('pessimistic_write').getOne();
|
||||||
|
if(!membership) throw registryNotFound('Participación de organización');
|
||||||
|
if(membership.validUntil) throw new ConflictException({code:'UTE_MEMBERSHIP_ENDED',message:'La participación ya está finalizada'});
|
||||||
|
const end=dto.validUntil ?? this.today(); this.validateEndDate(end,membership.validFrom);
|
||||||
|
membership.validUntil=end; membership.endReason=dto.reason; membership.endedBy=principal.userId; await repo.save(membership);
|
||||||
|
await this.captureAssets(manager,[membership.parentOrganizationId,membership.memberOrganizationId],principal,request);
|
||||||
|
await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'organization_membership',entityId:id,afterData:{...membership}},manager);
|
||||||
|
return membership;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async linkDocument(assetId:string,documentId:string,dto:LinkAssetSourceDocumentDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||||
|
try { return await this.dataSource.transaction(async manager=>{
|
||||||
|
await this.requireAsset(manager,assetId); await this.requireDocument(manager,documentId);
|
||||||
|
const repo=manager.getRepository(AssetSourceDocument); const link=repo.create({assetId,documentId,relationType:dto.relationType,notes:dto.notes??null,createdBy:principal.userId}); await repo.save(link);
|
||||||
|
const versionNumber=await this.history.capture(manager,assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request);
|
||||||
|
await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_source_document',entityId:link.id,afterData:{...link},metadata:{versionNumber}},manager); return link;
|
||||||
|
}); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'ASSET_DOCUMENT_LINK_EXISTS',message:'El documento ya está vinculado al activo con ese tipo de relación'}); throw error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async addExternalIdentifier(assetId:string,dto:CreateExternalIdentifierDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||||
|
try { return await this.dataSource.transaction(async manager=>{
|
||||||
|
await this.requireAsset(manager,assetId); if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId);
|
||||||
|
const repo=manager.getRepository(AssetExternalIdentifier); const identifier=repo.create({assetId,namespace:dto.namespace,value:dto.value,validFrom:dto.validFrom ? new Date(dto.validFrom):new Date(),validUntil:null,sourceDocumentId:dto.sourceDocumentId??null,notes:dto.notes??null,endReason:null,createdBy:principal.userId,endedBy:null}); await repo.save(identifier);
|
||||||
|
const versionNumber=await this.history.capture(manager,assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_external_identifier',entityId:identifier.id,afterData:{...identifier},metadata:{versionNumber}},manager); return identifier;
|
||||||
|
}); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'EXTERNAL_IDENTIFIER_EXISTS',message:'Ese identificador externo ya está activo'}); throw error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async endExternalIdentifier(id:string,dto:EndExternalIdentifierDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||||
|
return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AssetExternalIdentifier); const item=await repo.createQueryBuilder('i').where('i.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!item) throw registryNotFound('Identificador'); if(item.validUntil) throw new ConflictException({code:'EXTERNAL_IDENTIFIER_ENDED',message:'El identificador ya está finalizado'}); item.validUntil=new Date(); item.endReason=dto.reason; item.endedBy=principal.userId; await repo.save(item); const versionNumber=await this.history.capture(manager,item.assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_external_identifier',entityId:id,afterData:{...item},metadata:{versionNumber}},manager); return item; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLegalRight(areaId:string,dto:CreateAreaLegalRightDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||||
|
return this.dataSource.transaction(async manager=>{ await this.requireRole(manager,areaId,AssetTypeOperationalRole.AREA); if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId); this.validateDateRange(dto.validFrom??null,dto.validUntil??null); const repo=manager.getRepository(AreaLegalRight); const right=repo.create({areaId,rightType:dto.rightType,name:dto.name,instrumentNumber:dto.instrumentNumber??null,validFrom:dto.validFrom??null,validUntil:dto.validUntil??null,status:dto.status,sourceDocumentId:dto.sourceDocumentId??null,notes:dto.notes??null,createdBy:principal.userId,updatedBy:principal.userId}); await repo.save(right); const versionNumber=await this.history.capture(manager,areaId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_CREATED,entityType:'area_legal_right',entityId:right.id,afterData:{...right},metadata:{versionNumber}},manager); return right; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLegalRight(id:string,dto:UpdateAreaLegalRightDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||||
|
return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AreaLegalRight); const right=await repo.createQueryBuilder('r').where('r.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!right) throw registryNotFound('Derecho hidrocarburífero'); const before={...right}; if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId); const validFrom=dto.validFrom===undefined?right.validFrom:dto.validFrom; const validUntil=dto.validUntil===undefined?right.validUntil:dto.validUntil; this.validateDateRange(validFrom,validUntil); if(dto.name!==undefined)right.name=dto.name;if(dto.instrumentNumber!==undefined)right.instrumentNumber=dto.instrumentNumber;if(dto.validFrom!==undefined)right.validFrom=dto.validFrom;if(dto.validUntil!==undefined)right.validUntil=dto.validUntil;if(dto.status!==undefined)right.status=dto.status;if(dto.sourceDocumentId!==undefined)right.sourceDocumentId=dto.sourceDocumentId;if(dto.notes!==undefined)right.notes=dto.notes;right.updatedBy=principal.userId;await repo.save(right);const versionNumber=await this.history.capture(manager,right.areaId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request);await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_UPDATED,entityType:'area_legal_right',entityId:id,beforeData:before,afterData:{...right},metadata:{reason:dto.reason,versionNumber}},manager);return right; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLegalRightOrganization(rightId:string,dto:AddAreaLegalRightOrganizationDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||||
|
try { return await this.dataSource.transaction(async manager=>{ const right=await manager.getRepository(AreaLegalRight).createQueryBuilder('r').where('r.id=:rightId',{rightId}).setLock('pessimistic_write').getOne(); if(!right) throw registryNotFound('Derecho hidrocarburífero'); await this.requireRole(manager,dto.organizationId,AssetTypeOperationalRole.COMPANY); const validFrom=dto.validFrom??this.today(); const [overlap]=await manager.query(`SELECT 1 FROM area_legal_right_organizations WHERE right_id=$1 AND organization_id=$2 AND role=$3 AND daterange(valid_from,COALESCE(valid_until,'infinity'::date),'[]') && daterange($4::date,'infinity'::date,'[]') LIMIT 1`,[rightId,dto.organizationId,dto.role,validFrom]); if(overlap) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_OVERLAP',message:'La participación se superpone con una vigencia histórica existente'}); const repo=manager.getRepository(AreaLegalRightOrganization); const item=repo.create({rightId,organizationId:dto.organizationId,role:dto.role,participationPercent:dto.participationPercent==null?null:String(dto.participationPercent),validFrom,validUntil:null,notes:dto.notes??null,endReason:null,createdBy:principal.userId,endedBy:null}); await repo.save(item); await this.captureAssets(manager,[right.areaId,dto.organizationId],principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'area_legal_right_organization',entityId:item.id,afterData:{...item}},manager); return item; }); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_EXISTS',message:'La organización ya tiene ese rol activo en el derecho'}); throw error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async endLegalRightOrganization(id:string,dto:EndAreaLegalRightOrganizationDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||||
|
return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AreaLegalRightOrganization); const item=await repo.createQueryBuilder('o').where('o.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!item) throw registryNotFound('Participación legal'); if(item.validUntil) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_ENDED',message:'La participación ya está finalizada'}); const end=dto.validUntil??this.today();this.validateEndDate(end,item.validFrom);item.validUntil=end;item.endReason=dto.reason;item.endedBy=principal.userId;await repo.save(item);const right=await manager.getRepository(AreaLegalRight).findOne({where:{id:item.rightId}});if(right)await this.captureAssets(manager,[right.areaId,item.organizationId],principal,request);await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_ORGANIZATION_ENDED,entityType:'area_legal_right_organization',entityId:id,afterData:{...item}},manager);return item; });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async captureAssets(manager:EntityManager,ids:string[],principal:AuthPrincipal,request:RequestWithContext){ for(const id of [...new Set(ids)]) await this.history.capture(manager,id,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); }
|
||||||
|
private async requireAsset(manager:EntityManager,id:string):Promise<Asset>{ const asset=await manager.getRepository(Asset).findOne({where:{id}}); if(!asset) throw registryNotFound('Activo'); return asset; }
|
||||||
|
private async requireRole(manager:EntityManager,id:string,role:AssetTypeOperationalRole):Promise<Asset>{ const [row]=await manager.query(`SELECT a.id FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=$1 AND t.operational_role=$2`,[id,role]); if(!row) throw new BadRequestException({code:'ASSET_ROLE_INVALID',message:role===AssetTypeOperationalRole.AREA?'El activo debe ser un Área':'El activo debe ser una Organización'}); return this.requireAsset(manager,id); }
|
||||||
|
private async requireDocument(manager:EntityManager,id:string){ const doc=await manager.getRepository(SourceDocument).findOne({where:{id}}); if(!doc) throw registryNotFound('Documento fuente'); return doc; }
|
||||||
|
private today(){ return new Date().toISOString().slice(0,10); }
|
||||||
|
private validateEndDate(end:string,start:string){ const today=this.today(); if(end<start) throw new BadRequestException({code:'INVALID_VALIDITY_RANGE',message:'La fecha de finalización no puede ser anterior al inicio'}); if(end>today) throw new BadRequestException({code:'FUTURE_END_DATE',message:'La fecha de finalización no puede estar en el futuro'}); }
|
||||||
|
private validateDateRange(start:string|null,end:string|null){ if(start&&end&&end<start) throw new BadRequestException({code:'INVALID_VALIDITY_RANGE',message:'La vigencia hasta no puede ser anterior a la vigencia desde'}); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { AssetTemporalService } from './asset-temporal.service';
|
||||||
|
import {
|
||||||
|
ListTemporalAssetsQueryDto,
|
||||||
|
TemporalAtQueryDto,
|
||||||
|
} from './dto/list-temporal-assets-query.dto';
|
||||||
|
|
||||||
|
@Controller('temporal-assets')
|
||||||
|
export class AssetTemporalController {
|
||||||
|
constructor(private readonly temporal: AssetTemporalService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('assets.read_temporal')
|
||||||
|
list(@Query() query: ListTemporalAssetsQueryDto) {
|
||||||
|
return this.temporal.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':assetId')
|
||||||
|
@RequirePermissions('assets.read_temporal')
|
||||||
|
get(
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
@Query() query: TemporalAtQueryDto,
|
||||||
|
) {
|
||||||
|
return this.temporal.get(assetId, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import {
|
||||||
|
AssetVersionChangeType,
|
||||||
|
AuditSource,
|
||||||
|
} from '../database/entities';
|
||||||
|
import type {
|
||||||
|
ListTemporalAssetsQueryDto,
|
||||||
|
TemporalAtQueryDto,
|
||||||
|
} from './dto/list-temporal-assets-query.dto';
|
||||||
|
|
||||||
|
export interface TemporalAssetSummary {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
assetCode: string;
|
||||||
|
assetName: string;
|
||||||
|
typeId: string;
|
||||||
|
typeName: string;
|
||||||
|
informationStatus: string;
|
||||||
|
operationalStatus: string;
|
||||||
|
versionNumber: number;
|
||||||
|
changeType: AssetVersionChangeType;
|
||||||
|
changedFields: string[];
|
||||||
|
occurredAt: Date;
|
||||||
|
effectiveUntil: Date | null;
|
||||||
|
actorUserId: string | null;
|
||||||
|
actorUsername: string | null;
|
||||||
|
source: AuditSource;
|
||||||
|
requestId: string | null;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemporalAssetDetail extends TemporalAssetSummary {
|
||||||
|
snapshot: Record<string, unknown>;
|
||||||
|
asOf: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function temporalAssetNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'ASSET_NOT_EXISTING_AT_DATE',
|
||||||
|
message: 'El activo no tenía una versión registrada en la fecha solicitada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetTemporalService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async list(query: ListTemporalAssetsQueryDto) {
|
||||||
|
const parameters: unknown[] = [new Date(query.at)];
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const add = (value: unknown): string => {
|
||||||
|
parameters.push(value);
|
||||||
|
return `$${parameters.length}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.search?.trim()) {
|
||||||
|
const search = add(`%${query.search.trim()}%`);
|
||||||
|
conditions.push(`(
|
||||||
|
version.snapshot->>'code' ILIKE ${search}
|
||||||
|
OR version.snapshot->>'name' ILIKE ${search}
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
if (query.typeId) {
|
||||||
|
conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
|
||||||
|
}
|
||||||
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
const selected = this.selectedAt('$1');
|
||||||
|
const [countRow] = (await this.dataSource.query(
|
||||||
|
`WITH selected_version AS (${selected})
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM selected_version version
|
||||||
|
${where}`,
|
||||||
|
parameters,
|
||||||
|
)) as Array<{ total: number }>;
|
||||||
|
const total = Number(countRow?.total ?? 0);
|
||||||
|
const paginated = [
|
||||||
|
...parameters,
|
||||||
|
query.pageSize,
|
||||||
|
(query.page - 1) * query.pageSize,
|
||||||
|
];
|
||||||
|
const limit = `$${parameters.length + 1}`;
|
||||||
|
const offset = `$${parameters.length + 2}`;
|
||||||
|
const data = (await this.dataSource.query(
|
||||||
|
`WITH selected_version AS (${selected})
|
||||||
|
${this.selectSummary()}
|
||||||
|
FROM selected_version version
|
||||||
|
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT candidate.occurred_at
|
||||||
|
FROM asset_versions candidate
|
||||||
|
WHERE candidate.asset_id = version.asset_id
|
||||||
|
AND candidate.version_number > version.version_number
|
||||||
|
ORDER BY candidate.version_number ASC
|
||||||
|
LIMIT 1
|
||||||
|
) next_version ON true
|
||||||
|
${where}
|
||||||
|
ORDER BY "assetName" ASC, "assetCode" ASC
|
||||||
|
LIMIT ${limit} OFFSET ${offset}`,
|
||||||
|
paginated,
|
||||||
|
)) as TemporalAssetSummary[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
asOf: new Date(query.at),
|
||||||
|
meta: {
|
||||||
|
page: query.page,
|
||||||
|
pageSize: query.pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(assetId: string, query: TemporalAtQueryDto): Promise<TemporalAssetDetail> {
|
||||||
|
const [row] = (await this.dataSource.query(
|
||||||
|
`${this.selectSummary()}, version.snapshot
|
||||||
|
FROM asset_versions version
|
||||||
|
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT candidate.occurred_at
|
||||||
|
FROM asset_versions candidate
|
||||||
|
WHERE candidate.asset_id = version.asset_id
|
||||||
|
AND candidate.version_number > version.version_number
|
||||||
|
ORDER BY candidate.version_number ASC
|
||||||
|
LIMIT 1
|
||||||
|
) next_version ON true
|
||||||
|
WHERE version.asset_id = $1
|
||||||
|
AND version.occurred_at <= $2
|
||||||
|
ORDER BY version.occurred_at DESC, version.version_number DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[assetId, new Date(query.at)],
|
||||||
|
)) as TemporalAssetDetail[];
|
||||||
|
if (!row) throw temporalAssetNotFound();
|
||||||
|
const asOf = new Date(query.at);
|
||||||
|
row.asOf = asOf;
|
||||||
|
const [context] = (await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
||||||
|
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
|
||||||
|
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany"
|
||||||
|
FROM asset_context_history history
|
||||||
|
LEFT JOIN assets parent ON parent.id=history.parent_id
|
||||||
|
LEFT JOIN assets area ON area.id=history.operational_area_id
|
||||||
|
LEFT JOIN assets company ON company.id=history.operator_company_id
|
||||||
|
WHERE history.asset_id=$1
|
||||||
|
AND history.valid_from <= $2
|
||||||
|
AND (history.valid_until IS NULL OR history.valid_until > $2)
|
||||||
|
ORDER BY history.valid_from DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, [assetId, asOf])) as Array<{ parent: Record<string, unknown> | null; operationalArea: Record<string, unknown> | null; operatorCompany: Record<string, unknown> | null }>;
|
||||||
|
if (context && row.snapshot) {
|
||||||
|
row.snapshot = {
|
||||||
|
...row.snapshot,
|
||||||
|
parent: context.parent,
|
||||||
|
operationalArea: context.operationalArea,
|
||||||
|
operatorCompany: context.operatorCompany,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectedAt(atParameter: string): string {
|
||||||
|
return `SELECT DISTINCT ON (candidate.asset_id) candidate.*
|
||||||
|
FROM asset_versions candidate
|
||||||
|
WHERE candidate.occurred_at <= ${atParameter}
|
||||||
|
ORDER BY candidate.asset_id, candidate.occurred_at DESC, candidate.version_number DESC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectSummary(): string {
|
||||||
|
return `SELECT
|
||||||
|
version.id,
|
||||||
|
version.asset_id AS "assetId",
|
||||||
|
version.snapshot->>'code' AS "assetCode",
|
||||||
|
version.snapshot->>'name' AS "assetName",
|
||||||
|
version.snapshot #>> '{type,id}' AS "typeId",
|
||||||
|
version.snapshot #>> '{type,name}' AS "typeName",
|
||||||
|
version.snapshot->>'informationStatus' AS "informationStatus",
|
||||||
|
version.snapshot->>'operationalStatus' AS "operationalStatus",
|
||||||
|
version.version_number AS "versionNumber",
|
||||||
|
version.change_type AS "changeType",
|
||||||
|
version.changed_fields AS "changedFields",
|
||||||
|
version.occurred_at AS "occurredAt",
|
||||||
|
next_version.occurred_at AS "effectiveUntil",
|
||||||
|
version.actor_user_id AS "actorUserId",
|
||||||
|
version.actor_username AS "actorUsername",
|
||||||
|
version.source,
|
||||||
|
version.request_id AS "requestId",
|
||||||
|
(version.version_number = current_asset.current_version) AS "isCurrent"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../common/http/request-context';
|
||||||
|
import { AssetTypesService } from './asset-types.service';
|
||||||
|
import { CreateAssetTypeDto } from './dto/create-asset-type.dto';
|
||||||
|
import { UpdateAssetTypeDto } from './dto/update-asset-type.dto';
|
||||||
|
import { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto';
|
||||||
|
import { UpdateAttributeDefinitionDto } from './dto/update-attribute-definition.dto';
|
||||||
|
|
||||||
|
@Controller('asset-types')
|
||||||
|
export class AssetTypesController {
|
||||||
|
constructor(private readonly assetTypes: AssetTypesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('asset_types.read')
|
||||||
|
list() {
|
||||||
|
return this.assetTypes.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('bootstrap-status')
|
||||||
|
@RequirePermissions('asset_types.read')
|
||||||
|
bootstrapStatus() {
|
||||||
|
return this.assetTypes.bootstrapStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bootstrap-defaults')
|
||||||
|
@RequirePermissions('asset_types.manage')
|
||||||
|
bootstrapDefaults(
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assetTypes.bootstrapDefaults(principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('enrichment-status')
|
||||||
|
@RequirePermissions('asset_types.read')
|
||||||
|
enrichmentStatus() {
|
||||||
|
return this.assetTypes.enrichmentStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('enrich-defaults')
|
||||||
|
@RequirePermissions('asset_types.manage')
|
||||||
|
enrichDefaults(
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assetTypes.enrichDefaults(principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('asset_types.manage')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateAssetTypeDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assetTypes.create(dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions('asset_types.read')
|
||||||
|
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.assetTypes.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions('asset_types.manage')
|
||||||
|
update(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: UpdateAssetTypeDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assetTypes.update(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/attributes')
|
||||||
|
@RequirePermissions('asset_types.manage')
|
||||||
|
createAttribute(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: CreateAttributeDefinitionDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assetTypes.createAttribute(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/attributes/:attributeId')
|
||||||
|
@RequirePermissions('asset_types.manage')
|
||||||
|
updateAttribute(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Param('attributeId', new ParseUUIDPipe({ version: '4' })) attributeId: string,
|
||||||
|
@Body() dto: UpdateAttributeDefinitionDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assetTypes.updateAttribute(
|
||||||
|
id,
|
||||||
|
attributeId,
|
||||||
|
dto,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,905 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager, In } from 'typeorm';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../common/http/request-context';
|
||||||
|
import {
|
||||||
|
AssetAttributeDataType,
|
||||||
|
AssetAttributeDefinition,
|
||||||
|
AssetType,
|
||||||
|
AssetTypeOperationalRole,
|
||||||
|
AssetTypeParentRule,
|
||||||
|
AuditAction,
|
||||||
|
} from '../database/entities';
|
||||||
|
import {
|
||||||
|
administrationAuditContext,
|
||||||
|
isUniqueViolation,
|
||||||
|
} from '../administration/common/administration-audit';
|
||||||
|
import type { CreateAssetTypeDto } from './dto/create-asset-type.dto';
|
||||||
|
import type { UpdateAssetTypeDto } from './dto/update-asset-type.dto';
|
||||||
|
import type { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto';
|
||||||
|
import type { UpdateAttributeDefinitionDto } from './dto/update-attribute-definition.dto';
|
||||||
|
import {
|
||||||
|
MASTER_BOOTSTRAP_PRESET_CODE,
|
||||||
|
MASTER_BOOTSTRAP_PRESET_NAME,
|
||||||
|
MASTER_BOOTSTRAP_TYPES,
|
||||||
|
MASTER_BOOTSTRAP_CORE_CODES,
|
||||||
|
} from './asset-master-bootstrap';
|
||||||
|
|
||||||
|
export interface AssetAttributeDefinitionView {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
dataType: AssetAttributeDataType;
|
||||||
|
isRequired: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
unit: string | null;
|
||||||
|
options: string[] | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetTypeSummary {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
isActive: boolean;
|
||||||
|
operationalRole: AssetTypeOperationalRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetTypeView extends AssetTypeSummary {
|
||||||
|
description: string;
|
||||||
|
canBeRoot: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
assetCount: number;
|
||||||
|
allowedParentTypes: AssetTypeSummary[];
|
||||||
|
attributes: AssetAttributeDefinitionView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterBootstrapStatus {
|
||||||
|
presetCode: string;
|
||||||
|
presetName: string;
|
||||||
|
typeCount: number;
|
||||||
|
canApply: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
types: Array<{
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
operationalRole: AssetTypeOperationalRole;
|
||||||
|
parentCodes: string[];
|
||||||
|
attributeCount: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterBootstrapResult {
|
||||||
|
presetCode: string;
|
||||||
|
presetName: string;
|
||||||
|
createdTypeCount: number;
|
||||||
|
createdAttributeCount: number;
|
||||||
|
createdParentRuleCount: number;
|
||||||
|
data: AssetTypeView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterEnrichmentStatus {
|
||||||
|
presetCode: string;
|
||||||
|
presetName: string;
|
||||||
|
typeCount: number;
|
||||||
|
canApply: boolean;
|
||||||
|
complete: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
missingTypeCodes: string[];
|
||||||
|
missingAttributeCount: number;
|
||||||
|
missingParentRuleCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'ASSET_TYPE_NOT_FOUND',
|
||||||
|
message: 'Tipo de activo no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attributeNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'ASSET_ATTRIBUTE_NOT_FOUND',
|
||||||
|
message: 'Atributo configurable no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetTypesService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async bootstrapStatus(): Promise<MasterBootstrapStatus> {
|
||||||
|
const [row] = (await this.dataSource.query(
|
||||||
|
'SELECT COUNT(*)::integer AS count FROM asset_types',
|
||||||
|
)) as Array<{ count: number }>;
|
||||||
|
const typeCount = Number(row?.count ?? 0);
|
||||||
|
return {
|
||||||
|
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||||
|
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||||
|
typeCount,
|
||||||
|
canApply: typeCount === 0,
|
||||||
|
reason: typeCount === 0
|
||||||
|
? null
|
||||||
|
: 'La configuración inicial sólo puede aplicarse cuando el Maestro no tiene tipos de activo.',
|
||||||
|
types: MASTER_BOOTSTRAP_TYPES.map((type) => ({
|
||||||
|
code: type.code,
|
||||||
|
name: type.name,
|
||||||
|
operationalRole: type.operationalRole,
|
||||||
|
parentCodes: [...type.allowedParentCodes],
|
||||||
|
attributeCount: type.attributes.length,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async bootstrapDefaults(
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<MasterBootstrapResult> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.query('LOCK TABLE asset_types IN SHARE ROW EXCLUSIVE MODE');
|
||||||
|
const [existing] = (await manager.query(
|
||||||
|
'SELECT COUNT(*)::integer AS count FROM asset_types',
|
||||||
|
)) as Array<{ count: number }>;
|
||||||
|
if (Number(existing?.count ?? 0) !== 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'MASTER_BOOTSTRAP_REQUIRES_EMPTY_MASTER',
|
||||||
|
message: 'La configuración inicial sólo puede aplicarse cuando el Maestro está vacío',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeRepository = manager.getRepository(AssetType);
|
||||||
|
const ruleRepository = manager.getRepository(AssetTypeParentRule);
|
||||||
|
const attributeRepository = manager.getRepository(AssetAttributeDefinition);
|
||||||
|
const idsByCode = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||||
|
const type = typeRepository.create({
|
||||||
|
code: preset.code,
|
||||||
|
name: preset.name,
|
||||||
|
description: preset.description,
|
||||||
|
canBeRoot: preset.canBeRoot,
|
||||||
|
isActive: true,
|
||||||
|
operationalRole: preset.operationalRole,
|
||||||
|
createdBy: principal.userId,
|
||||||
|
updatedBy: principal.userId,
|
||||||
|
});
|
||||||
|
const saved = await typeRepository.save(type);
|
||||||
|
idsByCode.set(preset.code, saved.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let createdParentRuleCount = 0;
|
||||||
|
let createdAttributeCount = 0;
|
||||||
|
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||||
|
const childTypeId = idsByCode.get(preset.code)!;
|
||||||
|
const rules = preset.allowedParentCodes.map((parentCode) =>
|
||||||
|
ruleRepository.create({
|
||||||
|
childTypeId,
|
||||||
|
parentTypeId: idsByCode.get(parentCode)!,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (rules.length) {
|
||||||
|
await ruleRepository.save(rules);
|
||||||
|
createdParentRuleCount += rules.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributes = preset.attributes.map((attribute) =>
|
||||||
|
attributeRepository.create({
|
||||||
|
assetTypeId: childTypeId,
|
||||||
|
code: attribute.code,
|
||||||
|
name: attribute.name,
|
||||||
|
dataType: attribute.dataType,
|
||||||
|
isRequired: attribute.isRequired,
|
||||||
|
isActive: true,
|
||||||
|
unit: attribute.unit,
|
||||||
|
options: attribute.options,
|
||||||
|
sortOrder: attribute.sortOrder,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (attributes.length) {
|
||||||
|
await attributeRepository.save(attributes);
|
||||||
|
createdAttributeCount += attributes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await manager.query(this.viewQuery(''), [])) as AssetTypeView[];
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_MASTER_BOOTSTRAPPED,
|
||||||
|
entityType: 'asset_master',
|
||||||
|
entityId: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||||
|
afterData: {
|
||||||
|
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||||
|
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||||
|
mode: 'INITIAL_BOOTSTRAP',
|
||||||
|
createdTypeCount: MASTER_BOOTSTRAP_TYPES.length,
|
||||||
|
createdAttributeCount,
|
||||||
|
createdParentRuleCount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||||
|
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||||
|
createdTypeCount: MASTER_BOOTSTRAP_TYPES.length,
|
||||||
|
createdAttributeCount,
|
||||||
|
createdParentRuleCount,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async enrichmentStatus(): Promise<MasterEnrichmentStatus> {
|
||||||
|
return this.dataSource.transaction((manager) => this.computeEnrichmentStatus(manager));
|
||||||
|
}
|
||||||
|
|
||||||
|
async enrichDefaults(
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<MasterBootstrapResult> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.query('LOCK TABLE asset_types IN SHARE ROW EXCLUSIVE MODE');
|
||||||
|
const before = await this.computeEnrichmentStatus(manager);
|
||||||
|
if (!before.canApply) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'MASTER_ENRICHMENT_INCOMPATIBLE',
|
||||||
|
message: before.reason ?? 'El Maestro actual no es compatible con el catálogo técnico',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeRepository = manager.getRepository(AssetType);
|
||||||
|
const ruleRepository = manager.getRepository(AssetTypeParentRule);
|
||||||
|
const attributeRepository = manager.getRepository(AssetAttributeDefinition);
|
||||||
|
const existingTypes = await typeRepository.find();
|
||||||
|
const typesByCode = new Map<string, AssetType>(existingTypes.map((type) => [type.code, type]));
|
||||||
|
let createdTypeCount = 0;
|
||||||
|
let createdAttributeCount = 0;
|
||||||
|
let createdParentRuleCount = 0;
|
||||||
|
|
||||||
|
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||||
|
if (typesByCode.has(preset.code)) continue;
|
||||||
|
const saved = await typeRepository.save(typeRepository.create({
|
||||||
|
code: preset.code,
|
||||||
|
name: preset.name,
|
||||||
|
description: preset.description,
|
||||||
|
canBeRoot: preset.canBeRoot,
|
||||||
|
isActive: true,
|
||||||
|
operationalRole: preset.operationalRole,
|
||||||
|
createdBy: principal.userId,
|
||||||
|
updatedBy: principal.userId,
|
||||||
|
}));
|
||||||
|
typesByCode.set(preset.code, saved);
|
||||||
|
createdTypeCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||||
|
const childType = typesByCode.get(preset.code)!;
|
||||||
|
const existingAttributes = await attributeRepository.find({
|
||||||
|
where: { assetTypeId: childType.id },
|
||||||
|
});
|
||||||
|
const attributeCodes = new Set(existingAttributes.map((attribute) => attribute.code));
|
||||||
|
for (const attribute of preset.attributes) {
|
||||||
|
if (attributeCodes.has(attribute.code)) continue;
|
||||||
|
await attributeRepository.save(attributeRepository.create({
|
||||||
|
assetTypeId: childType.id,
|
||||||
|
code: attribute.code,
|
||||||
|
name: attribute.name,
|
||||||
|
dataType: attribute.dataType,
|
||||||
|
isRequired: attribute.isRequired,
|
||||||
|
isActive: true,
|
||||||
|
unit: attribute.unit,
|
||||||
|
options: attribute.options,
|
||||||
|
sortOrder: attribute.sortOrder,
|
||||||
|
}));
|
||||||
|
createdAttributeCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const parentCode of preset.allowedParentCodes) {
|
||||||
|
const parentType = typesByCode.get(parentCode)!;
|
||||||
|
const existing = await ruleRepository.findOne({
|
||||||
|
where: { childTypeId: childType.id, parentTypeId: parentType.id },
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
await ruleRepository.save(ruleRepository.create({
|
||||||
|
childTypeId: childType.id,
|
||||||
|
parentTypeId: parentType.id,
|
||||||
|
}));
|
||||||
|
createdParentRuleCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await manager.query(this.viewQuery(''), [])) as AssetTypeView[];
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_MASTER_BOOTSTRAPPED,
|
||||||
|
entityType: 'asset_master',
|
||||||
|
entityId: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||||
|
afterData: {
|
||||||
|
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||||
|
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||||
|
mode: 'TECHNICAL_ENRICHMENT',
|
||||||
|
createdTypeCount,
|
||||||
|
createdAttributeCount,
|
||||||
|
createdParentRuleCount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||||
|
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||||
|
createdTypeCount,
|
||||||
|
createdAttributeCount,
|
||||||
|
createdParentRuleCount,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async computeEnrichmentStatus(manager: EntityManager): Promise<MasterEnrichmentStatus> {
|
||||||
|
const existingTypes = await manager.getRepository(AssetType).find();
|
||||||
|
const typesByCode = new Map<string, AssetType>(existingTypes.map((type) => [type.code, type]));
|
||||||
|
const missingCore = MASTER_BOOTSTRAP_CORE_CODES.filter((code) => !typesByCode.has(code));
|
||||||
|
const area = typesByCode.get('area');
|
||||||
|
const company = typesByCode.get('empresa');
|
||||||
|
const incompatible = [
|
||||||
|
...(missingCore.length ? [`Faltan tipos base: ${missingCore.join(', ')}`] : []),
|
||||||
|
...(area && area.operationalRole !== AssetTypeOperationalRole.AREA ? ['El tipo area no tiene rol AREA'] : []),
|
||||||
|
...(company && company.operationalRole !== AssetTypeOperationalRole.COMPANY ? ['El tipo empresa no tiene rol COMPANY'] : []),
|
||||||
|
];
|
||||||
|
const canApply = incompatible.length === 0;
|
||||||
|
const missingTypeCodes = MASTER_BOOTSTRAP_TYPES
|
||||||
|
.filter((preset) => !typesByCode.has(preset.code))
|
||||||
|
.map((preset) => preset.code);
|
||||||
|
|
||||||
|
let missingAttributeCount = 0;
|
||||||
|
let missingParentRuleCount = 0;
|
||||||
|
if (canApply) {
|
||||||
|
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||||
|
const childType = typesByCode.get(preset.code);
|
||||||
|
if (!childType) {
|
||||||
|
missingAttributeCount += preset.attributes.length;
|
||||||
|
missingParentRuleCount += preset.allowedParentCodes.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const attributeRows = (await manager.query(
|
||||||
|
'SELECT code FROM asset_attribute_definitions WHERE asset_type_id = $1',
|
||||||
|
[childType.id],
|
||||||
|
)) as Array<{ code: string }>;
|
||||||
|
const attributeCodes = new Set(attributeRows.map((row) => row.code));
|
||||||
|
missingAttributeCount += preset.attributes.filter((attribute) => !attributeCodes.has(attribute.code)).length;
|
||||||
|
for (const parentCode of preset.allowedParentCodes) {
|
||||||
|
const parentType = typesByCode.get(parentCode);
|
||||||
|
if (!parentType) {
|
||||||
|
missingParentRuleCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [rule] = (await manager.query(
|
||||||
|
'SELECT 1 FROM asset_type_parent_rules WHERE child_type_id = $1 AND parent_type_id = $2 LIMIT 1',
|
||||||
|
[childType.id, parentType.id],
|
||||||
|
)) as unknown[];
|
||||||
|
if (!rule) missingParentRuleCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const complete = canApply
|
||||||
|
&& missingTypeCodes.length === 0
|
||||||
|
&& missingAttributeCount === 0
|
||||||
|
&& missingParentRuleCount === 0;
|
||||||
|
return {
|
||||||
|
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||||
|
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||||
|
typeCount: existingTypes.length,
|
||||||
|
canApply,
|
||||||
|
complete,
|
||||||
|
reason: canApply ? null : incompatible.join('. '),
|
||||||
|
missingTypeCodes,
|
||||||
|
missingAttributeCount,
|
||||||
|
missingParentRuleCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<{ data: AssetTypeView[] }> {
|
||||||
|
const rows = (await this.dataSource.query(this.viewQuery(''), [])) as AssetTypeView[];
|
||||||
|
return { data: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<AssetTypeView> {
|
||||||
|
return this.dataSource.transaction((manager) => this.loadView(manager, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateAssetTypeDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AssetTypeView> {
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.validateParentTypes(manager, null, dto.allowedParentTypeIds);
|
||||||
|
const type = manager.getRepository(AssetType).create({
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
canBeRoot: dto.canBeRoot,
|
||||||
|
isActive: true,
|
||||||
|
operationalRole: dto.operationalRole,
|
||||||
|
createdBy: principal.userId,
|
||||||
|
updatedBy: principal.userId,
|
||||||
|
});
|
||||||
|
await manager.getRepository(AssetType).save(type);
|
||||||
|
await this.replaceParentRules(manager, type.id, dto.allowedParentTypeIds);
|
||||||
|
const created = await this.loadView(manager, type.id);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_TYPE_CREATED,
|
||||||
|
entityType: 'asset_type',
|
||||||
|
entityId: type.id,
|
||||||
|
afterData: { ...created },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ASSET_TYPE_ALREADY_EXISTS',
|
||||||
|
message: 'Ya existe un tipo de activo con ese código',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateAssetTypeDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AssetTypeView> {
|
||||||
|
if (Object.keys(dto).length === 0) {
|
||||||
|
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const type = await manager.getRepository(AssetType).findOne({ where: { id } });
|
||||||
|
if (!type) throw typeNotFound();
|
||||||
|
const before = await this.loadView(manager, id);
|
||||||
|
const nextCanBeRoot = dto.canBeRoot ?? type.canBeRoot;
|
||||||
|
const nextOperationalRole = dto.operationalRole ?? type.operationalRole;
|
||||||
|
const nextParentIds = dto.allowedParentTypeIds ?? before.allowedParentTypes.map((item) => item.id);
|
||||||
|
await this.validateParentTypes(manager, id, nextParentIds);
|
||||||
|
await this.assertExistingAssetsRemainValid(manager, id, nextCanBeRoot, nextParentIds);
|
||||||
|
await this.assertOperationalRoleChangeAllowed(manager, id, type.operationalRole, nextOperationalRole);
|
||||||
|
if (dto.isActive === false && type.isActive) {
|
||||||
|
await this.assertOperationalTypeCanBeDeactivated(manager, id, type.operationalRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.name !== undefined) type.name = dto.name;
|
||||||
|
if (dto.description !== undefined) type.description = dto.description;
|
||||||
|
if (dto.canBeRoot !== undefined) type.canBeRoot = dto.canBeRoot;
|
||||||
|
if (dto.isActive !== undefined) type.isActive = dto.isActive;
|
||||||
|
if (dto.operationalRole !== undefined) type.operationalRole = dto.operationalRole;
|
||||||
|
type.updatedBy = principal.userId;
|
||||||
|
await manager.getRepository(AssetType).save(type);
|
||||||
|
if (dto.allowedParentTypeIds !== undefined) {
|
||||||
|
await this.replaceParentRules(manager, id, nextParentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.loadView(manager, id);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_TYPE_UPDATED,
|
||||||
|
entityType: 'asset_type',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: { ...before },
|
||||||
|
afterData: { ...updated },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAttribute(
|
||||||
|
typeId: string,
|
||||||
|
dto: CreateAttributeDefinitionDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AssetTypeView> {
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.requireType(manager, typeId);
|
||||||
|
this.validateOptions(dto.dataType, dto.options);
|
||||||
|
if (dto.isRequired) {
|
||||||
|
const [{ count }] = (await manager.query(
|
||||||
|
'SELECT COUNT(*)::integer AS count FROM assets WHERE asset_type_id = $1',
|
||||||
|
[typeId],
|
||||||
|
)) as Array<{ count: number }>;
|
||||||
|
if (Number(count) > 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ATTRIBUTE_REQUIRED_VALUES_MISSING',
|
||||||
|
message: 'No se puede agregar un atributo obligatorio a activos existentes sin completar sus valores',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const normalizedOptions = dto.options?.map((item) => item.trim());
|
||||||
|
const definition = manager.getRepository(AssetAttributeDefinition).create({
|
||||||
|
assetTypeId: typeId,
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name,
|
||||||
|
dataType: dto.dataType,
|
||||||
|
isRequired: dto.isRequired,
|
||||||
|
isActive: true,
|
||||||
|
unit: dto.unit ?? null,
|
||||||
|
options: dto.dataType === AssetAttributeDataType.SELECT ? normalizedOptions! : null,
|
||||||
|
sortOrder: dto.sortOrder,
|
||||||
|
});
|
||||||
|
await manager.getRepository(AssetAttributeDefinition).save(definition);
|
||||||
|
const updated = await this.loadView(manager, typeId);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_ATTRIBUTE_CREATED,
|
||||||
|
entityType: 'asset_attribute_definition',
|
||||||
|
entityId: definition.id,
|
||||||
|
afterData: { ...definition },
|
||||||
|
metadata: { assetTypeId: typeId },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ASSET_ATTRIBUTE_ALREADY_EXISTS',
|
||||||
|
message: 'Ya existe un atributo con ese código para el tipo seleccionado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAttribute(
|
||||||
|
typeId: string,
|
||||||
|
attributeId: string,
|
||||||
|
dto: UpdateAttributeDefinitionDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<AssetTypeView> {
|
||||||
|
if (Object.keys(dto).length === 0) {
|
||||||
|
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const repository = manager.getRepository(AssetAttributeDefinition);
|
||||||
|
const definition = await repository.findOne({
|
||||||
|
where: { id: attributeId, assetTypeId: typeId },
|
||||||
|
});
|
||||||
|
if (!definition) throw attributeNotFound();
|
||||||
|
const before = { ...definition };
|
||||||
|
const nextType = dto.dataType ?? definition.dataType;
|
||||||
|
const nextOptions = dto.options === undefined
|
||||||
|
? definition.options
|
||||||
|
: dto.options?.map((item) => item.trim()) ?? null;
|
||||||
|
this.validateOptions(nextType, nextOptions ?? undefined);
|
||||||
|
|
||||||
|
if (dto.dataType !== undefined && dto.dataType !== definition.dataType) {
|
||||||
|
const [{ count }] = (await manager.query(
|
||||||
|
'SELECT COUNT(*)::integer AS count FROM asset_attribute_values WHERE definition_id = $1',
|
||||||
|
[attributeId],
|
||||||
|
)) as Array<{ count: number }>;
|
||||||
|
if (Number(count) > 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ATTRIBUTE_TYPE_IN_USE',
|
||||||
|
message: 'No se puede cambiar el tipo de un atributo que ya tiene valores',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dto.isRequired === true && !definition.isRequired) {
|
||||||
|
const [{ count }] = (await manager.query(
|
||||||
|
`SELECT COUNT(*)::integer AS count FROM assets asset
|
||||||
|
WHERE asset.asset_type_id = $1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM asset_attribute_values value
|
||||||
|
WHERE value.asset_id = asset.id AND value.definition_id = $2
|
||||||
|
)`,
|
||||||
|
[typeId, attributeId],
|
||||||
|
)) as Array<{ count: number }>;
|
||||||
|
if (Number(count) > 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ATTRIBUTE_REQUIRED_VALUES_MISSING',
|
||||||
|
message: 'Hay activos existentes sin valor para este atributo',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.name !== undefined) definition.name = dto.name;
|
||||||
|
if (dto.dataType !== undefined) definition.dataType = dto.dataType;
|
||||||
|
if (dto.isRequired !== undefined) definition.isRequired = dto.isRequired;
|
||||||
|
if (dto.isActive !== undefined) definition.isActive = dto.isActive;
|
||||||
|
if (dto.unit !== undefined) definition.unit = dto.unit;
|
||||||
|
if (dto.options !== undefined || dto.dataType !== undefined) {
|
||||||
|
definition.options = nextType === AssetAttributeDataType.SELECT ? nextOptions : null;
|
||||||
|
}
|
||||||
|
if (dto.sortOrder !== undefined) definition.sortOrder = dto.sortOrder;
|
||||||
|
await repository.save(definition);
|
||||||
|
|
||||||
|
const updated = await this.loadView(manager, typeId);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_ATTRIBUTE_UPDATED,
|
||||||
|
entityType: 'asset_attribute_definition',
|
||||||
|
entityId: attributeId,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: { ...definition },
|
||||||
|
metadata: { assetTypeId: typeId },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateOptions(
|
||||||
|
dataType: AssetAttributeDataType,
|
||||||
|
options?: string[] | null,
|
||||||
|
): void {
|
||||||
|
if (dataType === AssetAttributeDataType.SELECT) {
|
||||||
|
const cleaned = (options ?? []).map((item) => item.trim()).filter(Boolean);
|
||||||
|
if (cleaned.length < 1 || new Set(cleaned).size !== cleaned.length) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVALID_ATTRIBUTE_OPTIONS',
|
||||||
|
message: 'Los atributos de selección necesitan opciones únicas',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (options != null) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVALID_ATTRIBUTE_OPTIONS',
|
||||||
|
message: 'Sólo los atributos de selección pueden tener opciones',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireType(manager: EntityManager, id: string): Promise<AssetType> {
|
||||||
|
const type = await manager.getRepository(AssetType).findOne({ where: { id } });
|
||||||
|
if (!type) throw typeNotFound();
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateParentTypes(
|
||||||
|
manager: EntityManager,
|
||||||
|
childTypeId: string | null,
|
||||||
|
parentTypeIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (childTypeId && parentTypeIds.includes(childTypeId)) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVALID_PARENT_TYPE_RULE',
|
||||||
|
message: 'Un tipo no puede ser padre de sí mismo',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parentTypeIds.length === 0) return;
|
||||||
|
const parents = await manager.getRepository(AssetType).find({
|
||||||
|
where: { id: In(parentTypeIds) },
|
||||||
|
});
|
||||||
|
if (parents.length !== parentTypeIds.length) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'ASSET_PARENT_TYPE_NOT_FOUND',
|
||||||
|
message: 'Uno o más tipos padre no existen',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceParentRules(
|
||||||
|
manager: EntityManager,
|
||||||
|
childTypeId: string,
|
||||||
|
parentTypeIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const repository = manager.getRepository(AssetTypeParentRule);
|
||||||
|
await repository.delete({ childTypeId });
|
||||||
|
if (parentTypeIds.length) {
|
||||||
|
await repository.save(
|
||||||
|
parentTypeIds.map((parentTypeId) =>
|
||||||
|
repository.create({ childTypeId, parentTypeId }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertExistingAssetsRemainValid(
|
||||||
|
manager: EntityManager,
|
||||||
|
typeId: string,
|
||||||
|
canBeRoot: boolean,
|
||||||
|
parentTypeIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const [row] = (await manager.query(
|
||||||
|
`SELECT COUNT(*)::integer AS count
|
||||||
|
FROM assets asset
|
||||||
|
LEFT JOIN assets parent ON parent.id = asset.parent_id
|
||||||
|
WHERE asset.asset_type_id = $1
|
||||||
|
AND (
|
||||||
|
(asset.parent_id IS NULL AND $2::boolean = false)
|
||||||
|
OR (asset.parent_id IS NOT NULL AND NOT(parent.asset_type_id = ANY($3::uuid[])))
|
||||||
|
)`,
|
||||||
|
[typeId, canBeRoot, parentTypeIds],
|
||||||
|
)) as Array<{ count: number }>;
|
||||||
|
if (Number(row?.count ?? 0) > 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ASSET_TYPE_RULES_IN_USE',
|
||||||
|
message: 'Las reglas dejarían activos existentes fuera de una jerarquía válida',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async assertOperationalTypeCanBeDeactivated(
|
||||||
|
manager: EntityManager,
|
||||||
|
typeId: string,
|
||||||
|
role: AssetTypeOperationalRole,
|
||||||
|
): Promise<void> {
|
||||||
|
if (role === AssetTypeOperationalRole.GENERIC) return;
|
||||||
|
const relationColumn = role === AssetTypeOperationalRole.AREA ? 'area_id' : 'company_id';
|
||||||
|
const assignmentColumn = role === AssetTypeOperationalRole.AREA
|
||||||
|
? 'operational_area_id'
|
||||||
|
: 'operator_company_id';
|
||||||
|
const [usage] = (await manager.query(`
|
||||||
|
SELECT (
|
||||||
|
(SELECT COUNT(*) FROM area_company_relations relation
|
||||||
|
INNER JOIN assets anchor ON anchor.id = relation.${relationColumn}
|
||||||
|
WHERE anchor.asset_type_id = $1 AND relation.valid_until IS NULL)
|
||||||
|
+
|
||||||
|
(SELECT COUNT(*) FROM assets assigned
|
||||||
|
INNER JOIN assets anchor ON anchor.id = assigned.${assignmentColumn}
|
||||||
|
WHERE anchor.asset_type_id = $1)
|
||||||
|
)::integer AS count
|
||||||
|
`, [typeId])) as Array<{ count: number }>;
|
||||||
|
if (Number(usage?.count ?? 0) > 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
|
||||||
|
message: role === AssetTypeOperationalRole.AREA
|
||||||
|
? 'No se puede inactivar el tipo mientras sus áreas tengan relaciones o activos operativos asociados'
|
||||||
|
: 'No se puede inactivar el tipo mientras sus empresas tengan relaciones o activos operativos asociados',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertOperationalRoleChangeAllowed(
|
||||||
|
manager: EntityManager,
|
||||||
|
typeId: string,
|
||||||
|
currentRole: AssetTypeOperationalRole,
|
||||||
|
nextRole: AssetTypeOperationalRole,
|
||||||
|
): Promise<void> {
|
||||||
|
if (currentRole === nextRole) return;
|
||||||
|
|
||||||
|
if (nextRole !== AssetTypeOperationalRole.GENERIC) {
|
||||||
|
const [assigned] = (await manager.query(
|
||||||
|
`SELECT COUNT(*)::integer AS count
|
||||||
|
FROM assets
|
||||||
|
WHERE asset_type_id = $1
|
||||||
|
AND (operational_area_id IS NOT NULL OR operator_company_id IS NOT NULL)`,
|
||||||
|
[typeId],
|
||||||
|
)) as Array<{ count: number }>;
|
||||||
|
if (Number(assigned?.count ?? 0) > 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
|
||||||
|
message: 'No se puede convertir el tipo en Área o Empresa mientras sus activos tengan asignaciones operativas',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRole === AssetTypeOperationalRole.AREA) {
|
||||||
|
const [used] = (await manager.query(
|
||||||
|
`SELECT (
|
||||||
|
(SELECT COUNT(*) FROM area_company_relations relation
|
||||||
|
INNER JOIN assets area ON area.id = relation.area_id
|
||||||
|
WHERE area.asset_type_id = $1)
|
||||||
|
+
|
||||||
|
(SELECT COUNT(*) FROM assets asset
|
||||||
|
INNER JOIN assets area ON area.id = asset.operational_area_id
|
||||||
|
WHERE area.asset_type_id = $1)
|
||||||
|
)::integer AS count`,
|
||||||
|
[typeId],
|
||||||
|
)) as Array<{ count: number }>;
|
||||||
|
if (Number(used?.count ?? 0) > 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
|
||||||
|
message: 'El tipo todavía está utilizado como Área en relaciones o asignaciones operativas',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRole === AssetTypeOperationalRole.COMPANY) {
|
||||||
|
const [used] = (await manager.query(
|
||||||
|
`SELECT (
|
||||||
|
(SELECT COUNT(*) FROM area_company_relations relation
|
||||||
|
INNER JOIN assets company ON company.id = relation.company_id
|
||||||
|
WHERE company.asset_type_id = $1)
|
||||||
|
+
|
||||||
|
(SELECT COUNT(*) FROM assets asset
|
||||||
|
INNER JOIN assets company ON company.id = asset.operator_company_id
|
||||||
|
WHERE company.asset_type_id = $1)
|
||||||
|
)::integer AS count`,
|
||||||
|
[typeId],
|
||||||
|
)) as Array<{ count: number }>;
|
||||||
|
if (Number(used?.count ?? 0) > 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
|
||||||
|
message: 'El tipo todavía está utilizado como Empresa en relaciones o asignaciones operativas',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadView(manager: EntityManager, id: string): Promise<AssetTypeView> {
|
||||||
|
const rows = (await manager.query(this.viewQuery('WHERE asset_type.id = $1'), [id])) as AssetTypeView[];
|
||||||
|
if (!rows[0]) throw typeNotFound();
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private viewQuery(where: string): string {
|
||||||
|
return `
|
||||||
|
SELECT
|
||||||
|
asset_type.id,
|
||||||
|
asset_type.code,
|
||||||
|
asset_type.name,
|
||||||
|
asset_type.description,
|
||||||
|
asset_type.can_be_root AS "canBeRoot",
|
||||||
|
asset_type.is_active AS "isActive",
|
||||||
|
asset_type.operational_role AS "operationalRole",
|
||||||
|
asset_type.created_at AS "createdAt",
|
||||||
|
asset_type.updated_at AS "updatedAt",
|
||||||
|
(SELECT COUNT(*)::integer FROM assets WHERE asset_type_id = asset_type.id) AS "assetCount",
|
||||||
|
COALESCE((
|
||||||
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||||
|
'id', parent_type.id,
|
||||||
|
'code', parent_type.code,
|
||||||
|
'name', parent_type.name,
|
||||||
|
'isActive', parent_type.is_active,
|
||||||
|
'operationalRole', parent_type.operational_role
|
||||||
|
) ORDER BY parent_type.name)
|
||||||
|
FROM asset_type_parent_rules rule
|
||||||
|
INNER JOIN asset_types parent_type ON parent_type.id = rule.parent_type_id
|
||||||
|
WHERE rule.child_type_id = asset_type.id
|
||||||
|
), '[]'::jsonb) AS "allowedParentTypes",
|
||||||
|
COALESCE((
|
||||||
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||||
|
'id', definition.id,
|
||||||
|
'code', definition.code,
|
||||||
|
'name', definition.name,
|
||||||
|
'dataType', definition.data_type,
|
||||||
|
'isRequired', definition.is_required,
|
||||||
|
'isActive', definition.is_active,
|
||||||
|
'unit', definition.unit,
|
||||||
|
'options', definition.options,
|
||||||
|
'sortOrder', definition.sort_order
|
||||||
|
) ORDER BY definition.sort_order, definition.name)
|
||||||
|
FROM asset_attribute_definitions definition
|
||||||
|
WHERE definition.asset_type_id = asset_type.id
|
||||||
|
), '[]'::jsonb) AS attributes
|
||||||
|
FROM asset_types asset_type
|
||||||
|
${where}
|
||||||
|
ORDER BY asset_type.is_active DESC, asset_type.name ASC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
|
const TRACKED_FIELDS = [
|
||||||
|
'code',
|
||||||
|
'name',
|
||||||
|
'commonName',
|
||||||
|
'description',
|
||||||
|
'type',
|
||||||
|
'parent',
|
||||||
|
'operationalArea',
|
||||||
|
'operatorCompany',
|
||||||
|
'informationStatus',
|
||||||
|
'operationalStatus',
|
||||||
|
'organizationProfile',
|
||||||
|
'organizationMemberships',
|
||||||
|
'externalIdentifiers',
|
||||||
|
'sourceDocuments',
|
||||||
|
'legalRights',
|
||||||
|
'attributes',
|
||||||
|
'geometry',
|
||||||
|
'media',
|
||||||
|
'provenance',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function changedSnapshotFields(
|
||||||
|
previous: Record<string, unknown> | null,
|
||||||
|
current: Record<string, unknown>,
|
||||||
|
): string[] {
|
||||||
|
if (!previous) {
|
||||||
|
return TRACKED_FIELDS.filter((field) => {
|
||||||
|
const value = current[field];
|
||||||
|
return value !== null && value !== undefined && !(
|
||||||
|
Array.isArray(value) && value.length === 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return TRACKED_FIELDS.filter(
|
||||||
|
(field) => !isDeepStrictEqual(previous[field], current[field]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../common/http/request-context';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
import { ChangeAssetStatusDto } from './dto/change-asset-status.dto';
|
||||||
|
import { ChangeAssetOperationalStatusDto } from './dto/change-asset-operational-status.dto';
|
||||||
|
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||||
|
import { CreateFieldDiscoveryDto } from './dto/create-field-discovery.dto';
|
||||||
|
import { ListFieldDiscoveriesQueryDto } from './dto/list-field-discoveries-query.dto';
|
||||||
|
import { MatchFieldDiscoveryDto, RejectFieldDiscoveryDto, ReviewFieldDiscoveryDto } from './dto/review-field-discovery.dto';
|
||||||
|
import { ListAssetsQueryDto } from './dto/list-assets-query.dto';
|
||||||
|
import { ListAssetTreeQueryDto } from './dto/list-asset-tree-query.dto';
|
||||||
|
import { ListAssetTreeChildrenQueryDto } from './dto/list-asset-tree-children-query.dto';
|
||||||
|
import { ParentOptionsQueryDto } from './dto/parent-options-query.dto';
|
||||||
|
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||||
|
import { ChangeAssetContextDto } from './dto/change-asset-context.dto';
|
||||||
|
|
||||||
|
@Controller('assets')
|
||||||
|
export class AssetsController {
|
||||||
|
constructor(private readonly assets: AssetsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
list(@Query() query: ListAssetsQueryDto) {
|
||||||
|
return this.assets.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('field-discoveries')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
listFieldDiscoveries(@Query() query: ListFieldDiscoveriesQueryDto) {
|
||||||
|
return this.assets.listFieldDiscoveries(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('field-discoveries')
|
||||||
|
@RequirePermissions('assets.create')
|
||||||
|
createFieldDiscovery(
|
||||||
|
@Body() dto: CreateFieldDiscoveryDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assets.createFieldDiscovery(dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('field-discoveries/:id/approve')
|
||||||
|
@RequirePermissions('assets.change_status')
|
||||||
|
approveFieldDiscovery(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: ReviewFieldDiscoveryDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assets.approveFieldDiscovery(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('field-discoveries/:id/reject')
|
||||||
|
@RequirePermissions('assets.change_status')
|
||||||
|
rejectFieldDiscovery(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: RejectFieldDiscoveryDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assets.rejectFieldDiscovery(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('field-discoveries/:id/match')
|
||||||
|
@RequirePermissions('assets.change_status')
|
||||||
|
matchFieldDiscovery(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: MatchFieldDiscoveryDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assets.matchFieldDiscovery(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tree')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
tree(@Query() query: ListAssetTreeQueryDto) {
|
||||||
|
return this.assets.tree(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tree-children')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
treeChildren(@Query() query: ListAssetTreeChildrenQueryDto) {
|
||||||
|
return this.assets.treeChildren(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('parent-options')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
parentOptions(@Query() query: ParentOptionsQueryDto) {
|
||||||
|
return this.assets.parentOptions(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('assets.create')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateAssetDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assets.create(dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/lineage')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
lineage(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.assets.lineage(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/dossier')
|
||||||
|
@RequirePermissions(
|
||||||
|
'assets.read',
|
||||||
|
'inspections.read',
|
||||||
|
'inspection_acts.read',
|
||||||
|
'inspection_findings.read',
|
||||||
|
'inspection_evidence.read',
|
||||||
|
'inspection_communications.read',
|
||||||
|
)
|
||||||
|
dossier(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.assets.dossier(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/context-history')
|
||||||
|
@RequirePermissions('assets.read_history')
|
||||||
|
contextHistory(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.assets.contextHistory(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/context')
|
||||||
|
@RequirePermissions('assets.manage_context')
|
||||||
|
changeContext(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: ChangeAssetContextDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assets.changeContext(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.assets.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions('assets.update')
|
||||||
|
update(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: UpdateAssetDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assets.update(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/operational-status')
|
||||||
|
@RequirePermissions('assets.change_operational_status')
|
||||||
|
changeOperationalStatus(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: ChangeAssetOperationalStatusDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assets.changeOperationalStatus(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/information-status')
|
||||||
|
@RequirePermissions('assets.change_status')
|
||||||
|
changeStatus(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: ChangeAssetStatusDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.assets.changeStatus(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
import { Transform,Type } from 'class-transformer'; import { IsDateString,IsEnum,IsNumber,IsOptional,IsString,IsUUID,Max,MaxLength,Min } from 'class-validator'; import { AreaLegalRightOrganizationRole } from '../../database/entities';
|
||||||
|
export class AddAreaLegalRightOrganizationDto { @IsUUID('4') organizationId!:string; @IsEnum(AreaLegalRightOrganizationRole) role!:AreaLegalRightOrganizationRole; @IsOptional() @Type(()=>Number) @IsNumber({maxDecimalPlaces:4}) @Min(0.0001) @Max(100) participationPercent?:number|null; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { Transform,Type } from 'class-transformer'; import { IsDateString,IsEnum,IsNumber,IsOptional,IsString,IsUUID,Max,MaxLength,Min } from 'class-validator'; import { OrganizationMembershipRole } from '../../database/entities';
|
||||||
|
export class AddOrganizationMembershipDto { @IsUUID('4') memberOrganizationId!:string; @IsEnum(OrganizationMembershipRole) role:OrganizationMembershipRole=OrganizationMembershipRole.MEMBER; @IsOptional() @Type(()=>Number) @IsNumber({maxDecimalPlaces:4}) @Min(0.0001) @Max(100) participationPercent?:number|null; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @IsUUID('4') sourceDocumentId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class ChangeAssetContextDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operationalAreaId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operatorCompanyId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Date)
|
||||||
|
@IsDate()
|
||||||
|
effectiveAt?: Date;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(2000)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { IsEnum } from 'class-validator';
|
||||||
|
import { AssetOperationalStatus } from '../../database/entities';
|
||||||
|
export class ChangeAssetOperationalStatusDto { @IsEnum(AssetOperationalStatus) status!: AssetOperationalStatus; }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsEnum } from 'class-validator';
|
||||||
|
import { AssetInformationStatus } from '../../database/entities';
|
||||||
|
|
||||||
|
export class ChangeAssetStatusDto {
|
||||||
|
@IsEnum(AssetInformationStatus)
|
||||||
|
informationStatus!: AssetInformationStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min, MinLength } from 'class-validator';
|
||||||
|
import { AreaOrganizationRole } from '../../database/entities';
|
||||||
|
|
||||||
|
export class CreateAreaCompanyRelationDto {
|
||||||
|
@IsUUID('4') areaId!: string;
|
||||||
|
@IsUUID('4') companyId!: string;
|
||||||
|
@IsOptional() @IsEnum(AreaOrganizationRole) relationRole: AreaOrganizationRole = AreaOrganizationRole.OPERATOR;
|
||||||
|
@IsOptional() @Type(() => Number) @IsNumber({ maxDecimalPlaces: 4 }) @Min(0.0001) @Max(100) participationPercent?: number | null;
|
||||||
|
@IsOptional() @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @IsString() @MaxLength(240) legalInstrument?: string | null;
|
||||||
|
@IsOptional() @IsUUID('4') sourceDocumentId?: string | null;
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(3) @MaxLength(1000) reason!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { Transform } from 'class-transformer'; import { IsDateString,IsEnum,IsOptional,IsString,IsUUID,MaxLength,MinLength } from 'class-validator'; import { AreaLegalRightStatus,AreaLegalRightType } from '../../database/entities';
|
||||||
|
export class CreateAreaLegalRightDto { @IsEnum(AreaLegalRightType) rightType!:AreaLegalRightType; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(260) name!:string; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(180) instrumentNumber?:string|null; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @IsDateString() validUntil?:string|null; @IsOptional() @IsEnum(AreaLegalRightStatus) status:AreaLegalRightStatus=AreaLegalRightStatus.ACTIVE; @IsOptional() @IsUUID('4') sourceDocumentId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(4000) notes?:string|null; }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsISO8601,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { AssetMediaKind } from '../../database/entities';
|
||||||
|
|
||||||
|
export class CreateAssetMediaDto {
|
||||||
|
@IsEnum(AssetMediaKind)
|
||||||
|
kind!: AssetMediaKind;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
capturedAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 6 })
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
latitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 6 })
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
longitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 3 })
|
||||||
|
@Min(0)
|
||||||
|
@Max(100000)
|
||||||
|
accuracyM?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { AssetTypeOperationalRole } from '../../database/entities';
|
||||||
|
|
||||||
|
export class CreateAssetTypeDto {
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
|
)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(80)
|
||||||
|
@Matches(/^[a-z][a-z0-9_-]+$/)
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(160)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
canBeRoot!: boolean;
|
||||||
|
|
||||||
|
@IsEnum(AssetTypeOperationalRole)
|
||||||
|
operationalRole = AssetTypeOperationalRole.GENERIC;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
allowedParentTypeIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { AssetInformationStatus } from '../../database/entities';
|
||||||
|
|
||||||
|
export class CreateAssetDto {
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toUpperCase() : value,
|
||||||
|
)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(120)
|
||||||
|
@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(200)
|
||||||
|
commonName?: string | null;
|
||||||
|
|
||||||
|
@IsUUID('4')
|
||||||
|
typeId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operationalAreaId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operatorCompanyId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : null,
|
||||||
|
)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetInformationStatus)
|
||||||
|
informationStatus = AssetInformationStatus.DRAFT;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
attributes!: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { AssetAttributeDataType } from '../../database/entities';
|
||||||
|
|
||||||
|
export class CreateAttributeDefinitionDto {
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
|
)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(80)
|
||||||
|
@Matches(/^[a-z][a-z0-9_-]+$/)
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(160)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsEnum(AssetAttributeDataType)
|
||||||
|
dataType!: AssetAttributeDataType;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
isRequired!: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : null,
|
||||||
|
)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
unit?: string | null;
|
||||||
|
|
||||||
|
@ValidateIf((dto: CreateAttributeDefinitionDto) =>
|
||||||
|
dto.dataType === AssetAttributeDataType.SELECT,
|
||||||
|
)
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@MinLength(1, { each: true })
|
||||||
|
@MaxLength(160, { each: true })
|
||||||
|
options?: string[];
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(10_000)
|
||||||
|
sortOrder!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { Transform } from 'class-transformer'; import { IsDateString,IsOptional,IsString,IsUUID,Matches,MaxLength,MinLength } from 'class-validator';
|
||||||
|
export class CreateExternalIdentifierDto { @Transform(({value})=>typeof value==='string'?value.trim().toUpperCase():value) @IsString() @Matches(/^[A-Z0-9][A-Z0-9._/-]{1,79}$/) namespace!:string; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(1) @MaxLength(180) value!:string; @IsOptional() @IsDateString() validFrom?:string|null; @IsOptional() @IsUUID('4') sourceDocumentId?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsObject, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateFieldDiscoveryDto {
|
||||||
|
@IsUUID('4')
|
||||||
|
visitId!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(120)
|
||||||
|
@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(200)
|
||||||
|
commonName?: string | null;
|
||||||
|
|
||||||
|
@IsUUID('4')
|
||||||
|
typeId!: string;
|
||||||
|
|
||||||
|
@IsUUID('4')
|
||||||
|
parentId!: string;
|
||||||
|
|
||||||
|
@IsUUID('4')
|
||||||
|
operationalAreaId!: string;
|
||||||
|
|
||||||
|
@IsUUID('4')
|
||||||
|
operatorCompanyId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
discoveryNotes?: string | null;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
attributes!: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { Transform } from 'class-transformer'; import { IsDateString,IsEnum,IsOptional,IsString,MaxLength,MinLength } from 'class-validator'; import { SourceDocumentType } from '../../database/entities';
|
||||||
|
export class CreateSourceDocumentDto { @IsEnum(SourceDocumentType) documentType!:SourceDocumentType; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(160) documentNumber?:string|null; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(300) title!:string; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(240) issuer?:string|null; @IsOptional() @IsDateString() documentDate?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(500) externalReference?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(4000) notes?:string|null; }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class EndAreaCompanyRelationDto {
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(1000)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import { Transform } from 'class-transformer'; import { IsDateString,IsOptional,IsString,MaxLength,MinLength } from 'class-validator'; export class EndAreaLegalRightOrganizationDto { @IsOptional() @IsDateString() validUntil?:string|null; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(1000) reason!:string; }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import { Transform } from 'class-transformer'; import { IsString,MaxLength,MinLength } from 'class-validator'; export class EndExternalIdentifierDto { @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(1000) reason!:string; }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import { Transform } from 'class-transformer'; import { IsDateString,IsOptional,IsString,MaxLength,MinLength } from 'class-validator'; export class EndOrganizationMembershipDto { @IsOptional() @IsDateString() validUntil?:string|null; @Transform(({value})=>typeof value==='string'?value.trim():value) @IsString() @MinLength(3) @MaxLength(1000) reason!:string; }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { Transform } from 'class-transformer'; import { IsEnum,IsOptional,IsString,MaxLength } from 'class-validator'; import { AssetSourceDocumentRelationType } from '../../database/entities';
|
||||||
|
export class LinkAssetSourceDocumentDto { @IsEnum(AssetSourceDocumentRelationType) relationType:AssetSourceDocumentRelationType=AssetSourceDocumentRelationType.SOURCE; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim():null) @IsString() @MaxLength(2000) notes?:string|null; }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class ListAreaCompanyRelationsQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
areaId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
companyId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === true || value === 'true' || value === '1')
|
||||||
|
@IsBoolean()
|
||||||
|
includeHistory = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
|
||||||
|
import { ListAssetTreeQueryDto } from './list-asset-tree-query.dto';
|
||||||
|
|
||||||
|
export class ListAssetTreeChildrenQueryDto extends ListAssetTreeQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
|
limit = 100;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
|
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
|
||||||
|
|
||||||
|
export class ListAssetTreeQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
typeId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetInformationStatus)
|
||||||
|
status?: AssetInformationStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetOperationalStatus)
|
||||||
|
operationalStatus?: AssetOperationalStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operationalAreaId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operatorCompanyId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||||
|
@IsBoolean()
|
||||||
|
needsValidation?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||||
|
@IsBoolean()
|
||||||
|
hasGeometry?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsISO8601,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import {
|
||||||
|
AssetInformationStatus,
|
||||||
|
AssetVersionChangeType,
|
||||||
|
} from '../../database/entities';
|
||||||
|
|
||||||
|
const trim = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' ? value.trim() : value;
|
||||||
|
|
||||||
|
export class AssetVersionPageQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize = 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListAssetVersionsQueryDto extends AssetVersionPageQueryDto {
|
||||||
|
@Transform(trim)
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
typeId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetInformationStatus)
|
||||||
|
status?: AssetInformationStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetVersionChangeType)
|
||||||
|
changeType?: AssetVersionChangeType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
from?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
|
||||||
|
|
||||||
|
export class ListAssetsQueryDto {
|
||||||
|
@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()
|
||||||
|
@IsUUID('4')
|
||||||
|
typeId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetInformationStatus)
|
||||||
|
status?: AssetInformationStatus;
|
||||||
|
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetOperationalStatus)
|
||||||
|
operationalStatus?: AssetOperationalStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||||
|
@IsBoolean()
|
||||||
|
needsValidation?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||||
|
@IsBoolean()
|
||||||
|
hasGeometry?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operationalAreaId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operatorCompanyId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ListFieldDiscoveriesQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['PENDING', 'APPROVED', 'MATCHED', 'REJECTED'])
|
||||||
|
status?: 'PENDING' | 'APPROVED' | 'MATCHED' | 'REJECTED';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => Number(value ?? 1))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => Number(value ?? 25))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize = 25;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class ListOperationalAreasQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsISO8601,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { AssetInformationStatus } from '../../database/entities';
|
||||||
|
|
||||||
|
const trim = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' ? value.trim() : value;
|
||||||
|
|
||||||
|
export class TemporalAtQueryDto {
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
at!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListTemporalAssetsQueryDto extends TemporalAtQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize = 25;
|
||||||
|
|
||||||
|
@Transform(trim)
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
typeId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetInformationStatus)
|
||||||
|
status?: AssetInformationStatus;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user