From 7c1480a819ca6e2d8c49151d76e2d8dbcab021b2 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Fri, 16 Jan 2026 08:11:14 -0600 Subject: [PATCH] =?UTF-8?q?Migraci=C3=B3n=20desde=20erp-construccion/backe?= =?UTF-8?q?nd=20-=20Est=C3=A1ndar=20multi-repo=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .env.example | 71 + Dockerfile | 84 + README.md | 462 +- package-lock.json | 7817 +++++++++++++++++ package.json | 73 + scripts/sync-enums.ts | 120 + scripts/validate-constants-usage.ts | 385 + service.descriptor.yml | 169 + .../admin/controllers/audit-log.controller.ts | 229 + .../admin/controllers/backup.controller.ts | 283 + .../controllers/cost-center.controller.ts | 280 + src/modules/admin/controllers/index.ts | 9 + .../controllers/system-setting.controller.ts | 370 + .../admin/entities/audit-log.entity.ts | 256 + src/modules/admin/entities/backup.entity.ts | 301 + .../admin/entities/cost-center.entity.ts | 208 + .../entities/custom-permission.entity.ts | 161 + src/modules/admin/entities/index.ts | 10 + .../admin/entities/system-setting.entity.ts | 180 + .../admin/services/audit-log.service.ts | 309 + src/modules/admin/services/backup.service.ts | 308 + .../admin/services/cost-center.service.ts | 336 + src/modules/admin/services/index.ts | 9 + .../admin/services/system-setting.service.ts | 336 + .../auth/controllers/auth.controller.ts | 268 + src/modules/auth/controllers/index.ts | 5 + src/modules/auth/dto/auth.dto.ts | 70 + src/modules/auth/entities/index.ts | 8 + .../auth/entities/permission.entity.ts | 34 + .../auth/entities/refresh-token.entity.ts | 73 + src/modules/auth/entities/role.entity.ts | 58 + src/modules/auth/entities/user-role.entity.ts | 54 + src/modules/auth/index.ts | 14 + .../auth/middleware/auth.middleware.ts | 178 + src/modules/auth/services/auth.service.ts | 370 + src/modules/auth/services/index.ts | 5 + .../controllers/bid-analytics.controller.ts | 175 + .../controllers/bid-budget.controller.ts | 254 + .../bidding/controllers/bid.controller.ts | 370 + src/modules/bidding/controllers/index.ts | 9 + .../controllers/opportunity.controller.ts | 266 + .../bidding/entities/bid-budget.entity.ts | 256 + .../bidding/entities/bid-calendar.entity.ts | 188 + .../bidding/entities/bid-competitor.entity.ts | 203 + .../bidding/entities/bid-document.entity.ts | 170 + .../bidding/entities/bid-team.entity.ts | 176 + src/modules/bidding/entities/bid.entity.ts | 311 + src/modules/bidding/entities/index.ts | 12 + .../bidding/entities/opportunity.entity.ts | 280 + .../bidding/services/bid-analytics.service.ts | 385 + .../bidding/services/bid-budget.service.ts | 388 + src/modules/bidding/services/bid.service.ts | 384 + src/modules/bidding/services/index.ts | 9 + .../bidding/services/opportunity.service.ts | 392 + .../controllers/concepto.controller.ts | 252 + src/modules/budgets/controllers/index.ts | 7 + .../controllers/presupuesto.controller.ts | 287 + .../budgets/entities/concepto.entity.ts | 100 + src/modules/budgets/entities/index.ts | 8 + .../entities/presupuesto-partida.entity.ts | 95 + .../budgets/entities/presupuesto.entity.ts | 107 + .../budgets/services/concepto.service.ts | 160 + src/modules/budgets/services/index.ts | 6 + .../budgets/services/presupuesto.service.ts | 262 + .../controllers/etapa.controller.ts | 181 + .../controllers/fraccionamiento.controller.ts | 157 + src/modules/construction/controllers/index.ts | 11 + .../controllers/lote.controller.ts | 273 + .../controllers/manzana.controller.ts | 180 + .../controllers/prototipo.controller.ts | 181 + .../controllers/proyecto.controller.ts | 165 + .../construction/entities/etapa.entity.ts | 83 + .../entities/fraccionamiento.entity.ts | 95 + src/modules/construction/entities/index.ts | 11 + .../construction/entities/lote.entity.ts | 92 + .../construction/entities/manzana.entity.ts | 65 + .../construction/entities/prototipo.entity.ts | 85 + .../construction/entities/proyecto.entity.ts | 88 + .../construction/services/etapa.service.ts | 163 + .../services/fraccionamiento.service.ts | 117 + src/modules/construction/services/index.ts | 11 + .../construction/services/lote.service.ts | 230 + .../construction/services/manzana.service.ts | 149 + .../services/prototipo.service.ts | 173 + .../construction/services/proyecto.service.ts | 117 + .../controllers/contract.controller.ts | 415 + src/modules/contracts/controllers/index.ts | 7 + .../controllers/subcontractor.controller.ts | 257 + .../entities/contract-addendum.entity.ts | 114 + .../contracts/entities/contract.entity.ts | 192 + src/modules/contracts/entities/index.ts | 10 + .../entities/subcontractor.entity.ts | 123 + .../contracts/services/contract.service.ts | 422 + src/modules/contracts/services/index.ts | 7 + .../services/subcontractor.service.ts | 270 + src/modules/core/entities/index.ts | 6 + src/modules/core/entities/tenant.entity.ts | 50 + src/modules/core/entities/user.entity.ts | 78 + .../controllers/anticipo.controller.ts | 324 + .../controllers/estimacion.controller.ts | 408 + .../controllers/fondo-garantia.controller.ts | 299 + src/modules/estimates/controllers/index.ts | 9 + .../controllers/retencion.controller.ts | 241 + .../estimates/entities/amortizacion.entity.ts | 86 + .../estimates/entities/anticipo.entity.ts | 134 + .../entities/estimacion-concepto.entity.ts | 122 + .../entities/estimacion-workflow.entity.ts | 87 + .../estimates/entities/estimacion.entity.ts | 173 + .../entities/fondo-garantia.entity.ts | 92 + .../estimates/entities/generador.entity.ts | 125 + src/modules/estimates/entities/index.ts | 13 + .../estimates/entities/retencion.entity.ts | 99 + .../estimates/services/anticipo.service.ts | 351 + .../estimates/services/estimacion.service.ts | 424 + .../services/fondo-garantia.service.ts | 275 + src/modules/estimates/services/index.ts | 9 + .../estimates/services/retencion.service.ts | 258 + .../controllers/accounting.controller.ts | 385 + .../finance/controllers/ap.controller.ts | 264 + .../finance/controllers/ar.controller.ts | 310 + .../bank-reconciliation.controller.ts | 434 + .../controllers/cash-flow.controller.ts | 295 + src/modules/finance/controllers/index.ts | 11 + .../finance/controllers/reports.controller.ts | 410 + .../entities/account-payable.entity.ts | 233 + .../entities/account-receivable.entity.ts | 224 + .../entities/accounting-entry-line.entity.ts | 131 + .../entities/accounting-entry.entity.ts | 185 + .../finance/entities/ap-payment.entity.ts | 154 + .../finance/entities/ar-payment.entity.ts | 154 + .../finance/entities/bank-account.entity.ts | 199 + .../finance/entities/bank-movement.entity.ts | 189 + .../entities/bank-reconciliation.entity.ts | 225 + .../entities/cash-flow-projection.entity.ts | 357 + .../entities/chart-of-accounts.entity.ts | 151 + src/modules/finance/entities/index.ts | 16 + src/modules/finance/index.ts | 13 + .../finance/services/accounting.service.ts | 813 ++ src/modules/finance/services/ap.service.ts | 673 ++ src/modules/finance/services/ar.service.ts | 728 ++ .../services/bank-reconciliation.service.ts | 846 ++ .../finance/services/cash-flow.service.ts | 701 ++ .../services/erp-integration.service.ts | 699 ++ .../services/financial-reports.service.ts | 893 ++ src/modules/finance/services/index.ts | 12 + .../hr/controllers/employee.controller.ts | 342 + src/modules/hr/controllers/index.ts | 7 + .../hr/controllers/puesto.controller.ts | 193 + .../employee-fraccionamiento.entity.ts | 65 + src/modules/hr/entities/employee.entity.ts | 136 + src/modules/hr/entities/index.ts | 8 + src/modules/hr/entities/puesto.entity.ts | 68 + src/modules/hr/services/employee.service.ts | 330 + src/modules/hr/services/index.ts | 7 + src/modules/hr/services/puesto.service.ts | 149 + .../hse/controllers/ambiental.controller.ts | 598 ++ .../controllers/capacitacion.controller.ts | 223 + src/modules/hse/controllers/epp.controller.ts | 464 + .../hse/controllers/incidente.controller.ts | 398 + src/modules/hse/controllers/index.ts | 28 + .../hse/controllers/indicador.controller.ts | 354 + .../hse/controllers/inspeccion.controller.ts | 400 + .../controllers/permiso-trabajo.controller.ts | 323 + .../hse/controllers/stps.controller.ts | 794 ++ .../hse/entities/alerta-indicador.entity.ts | 76 + .../hse/entities/almacen-temporal.entity.ts | 71 + src/modules/hse/entities/auditoria.entity.ts | 82 + .../entities/capacitacion-asistente.entity.ts | 66 + .../entities/capacitacion-matriz.entity.ts | 53 + .../entities/capacitacion-sesion.entity.ts | 96 + .../hse/entities/capacitacion.entity.ts | 74 + .../hse/entities/checklist-item.entity.ts | 54 + .../entities/comision-integrante.entity.ts | 65 + .../hse/entities/comision-recorrido.entity.ts | 70 + .../hse/entities/comision-seguridad.entity.ts | 83 + .../hse/entities/constancia-dc3.entity.ts | 74 + .../hse/entities/cumplimiento-obra.entity.ts | 93 + .../hse/entities/dias-sin-accidente.entity.ts | 63 + .../hse/entities/documento-stps.entity.ts | 89 + .../hse/entities/epp-asignacion.entity.ts | 109 + src/modules/hse/entities/epp-baja.entity.ts | 64 + .../hse/entities/epp-catalogo.entity.ts | 86 + .../hse/entities/epp-inspeccion.entity.ts | 65 + .../hse/entities/epp-inventario.entity.ts | 62 + .../hse/entities/epp-matriz-puesto.entity.ts | 56 + .../hse/entities/epp-movimiento.entity.ts | 75 + .../hse/entities/hallazgo-evidencia.entity.ts | 67 + src/modules/hse/entities/hallazgo.entity.ts | 133 + .../hse/entities/horas-trabajadas.entity.ts | 70 + .../hse/entities/impacto-ambiental.entity.ts | 101 + .../hse/entities/incidente-accion.entity.ts | 71 + .../entities/incidente-evidencia.entity.ts | 62 + .../incidente-investigacion.entity.ts | 73 + .../entities/incidente-involucrado.entity.ts | 58 + src/modules/hse/entities/incidente.entity.ts | 111 + src/modules/hse/entities/index.ts | 82 + .../hse/entities/indicador-config.entity.ts | 85 + .../entities/indicador-meta-obra.entity.ts | 54 + .../hse/entities/indicador-valor.entity.ts | 86 + .../entities/inspeccion-evaluacion.entity.ts | 58 + src/modules/hse/entities/inspeccion.entity.ts | 124 + src/modules/hse/entities/instructor.entity.ts | 69 + .../hse/entities/manifiesto-detalle.entity.ts | 60 + .../entities/manifiesto-residuos.entity.ts | 95 + .../hse/entities/norma-requisito.entity.ts | 51 + src/modules/hse/entities/norma-stps.entity.ts | 61 + .../entities/permiso-autorizacion.entity.ts | 67 + .../hse/entities/permiso-checklist.entity.ts | 64 + .../hse/entities/permiso-documento.entity.ts | 52 + .../hse/entities/permiso-evento.entity.ts | 62 + .../hse/entities/permiso-monitoreo.entity.ts | 62 + .../hse/entities/permiso-personal.entity.ts | 64 + .../hse/entities/permiso-trabajo.entity.ts | 141 + .../hse/entities/programa-actividad.entity.ts | 79 + .../entities/programa-inspeccion.entity.ts | 85 + .../hse/entities/programa-seguridad.entity.ts | 85 + .../entities/proveedor-ambiental.entity.ts | 78 + .../hse/entities/queja-ambiental.entity.ts | 89 + .../hse/entities/reporte-programado.entity.ts | 77 + .../hse/entities/residuo-catalogo.entity.ts | 59 + .../hse/entities/residuo-generacion.entity.ts | 105 + .../hse/entities/tipo-inspeccion.entity.ts | 76 + .../entities/tipo-permiso-trabajo.entity.ts | 68 + src/modules/hse/services/ambiental.service.ts | 632 ++ .../hse/services/capacitacion.service.ts | 159 + src/modules/hse/services/epp.service.ts | 442 + src/modules/hse/services/incidente.service.ts | 396 + src/modules/hse/services/index.ts | 32 + src/modules/hse/services/indicador.service.ts | 282 + .../hse/services/inspeccion.service.ts | 354 + .../hse/services/permiso-trabajo.service.ts | 298 + src/modules/hse/services/stps.service.ts | 675 ++ .../controllers/asignacion.controller.ts | 240 + .../controllers/derechohabiente.controller.ts | 291 + src/modules/infonavit/controllers/index.ts | 7 + .../entities/acta-vivienda.entity.ts | 88 + src/modules/infonavit/entities/acta.entity.ts | 101 + .../entities/asignacion-vivienda.entity.ts | 116 + .../entities/derechohabiente.entity.ts | 119 + .../entities/historico-puntos.entity.ts | 75 + src/modules/infonavit/entities/index.ts | 15 + .../entities/oferta-vivienda.entity.ts | 96 + .../entities/registro-infonavit.entity.ts | 94 + .../entities/reporte-infonavit.entity.ts | 109 + .../infonavit/services/asignacion.service.ts | 290 + .../services/derechohabiente.service.ts | 328 + src/modules/infonavit/services/index.ts | 7 + .../controllers/consumo-obra.controller.ts | 189 + src/modules/inventory/controllers/index.ts | 7 + .../controllers/requisicion.controller.ts | 363 + .../entities/almacen-proyecto.entity.ts | 92 + .../inventory/entities/consumo-obra.entity.ts | 113 + src/modules/inventory/entities/index.ts | 11 + .../entities/requisicion-linea.entity.ts | 115 + .../entities/requisicion-obra.entity.ts | 117 + .../services/consumo-obra.service.ts | 200 + src/modules/inventory/services/index.ts | 7 + .../inventory/services/requisicion.service.ts | 339 + .../controllers/avance-obra.controller.ts | 303 + .../controllers/bitacora-obra.controller.ts | 245 + src/modules/progress/controllers/index.ts | 7 + .../progress/entities/avance-obra.entity.ts | 127 + .../progress/entities/bitacora-obra.entity.ts | 102 + .../progress/entities/foto-avance.entity.ts | 87 + src/modules/progress/entities/index.ts | 10 + .../entities/programa-actividad.entity.ts | 107 + .../progress/entities/programa-obra.entity.ts | 91 + .../progress/services/avance-obra.service.ts | 284 + .../services/bitacora-obra.service.ts | 209 + src/modules/progress/services/index.ts | 7 + .../controllers/comparativo.controller.ts | 308 + src/modules/purchase/controllers/index.ts | 6 + .../comparativo-cotizaciones.entity.ts | 101 + .../entities/comparativo-producto.entity.ts | 75 + .../entities/comparativo-proveedor.entity.ts | 87 + src/modules/purchase/entities/index.ts | 10 + .../purchase/services/comparativo.service.ts | 311 + src/modules/purchase/services/index.ts | 6 + src/modules/quality/controllers/index.ts | 7 + .../controllers/inspection.controller.ts | 254 + .../quality/controllers/ticket.controller.ts | 308 + .../quality/entities/checklist-item.entity.ts | 69 + .../quality/entities/checklist.entity.ts | 89 + .../entities/corrective-action.entity.ts | 100 + src/modules/quality/entities/index.ts | 15 + .../entities/inspection-result.entity.ts | 70 + .../quality/entities/inspection.entity.ts | 120 + .../quality/entities/non-conformity.entity.ts | 126 + .../entities/post-sale-ticket.entity.ts | 123 + .../entities/ticket-assignment.entity.ts | 92 + src/modules/quality/services/index.ts | 7 + .../quality/services/inspection.service.ts | 317 + .../quality/services/ticket.service.ts | 395 + .../controllers/dashboard.controller.ts | 504 ++ src/modules/reports/controllers/index.ts | 8 + .../reports/controllers/kpi.controller.ts | 349 + .../reports/controllers/report.controller.ts | 373 + .../entities/dashboard-widget.entity.ts | 222 + .../reports/entities/dashboard.entity.ts | 205 + src/modules/reports/entities/index.ts | 10 + .../reports/entities/kpi-snapshot.entity.ts | 220 + .../entities/report-execution.entity.ts | 191 + src/modules/reports/entities/report.entity.ts | 222 + .../reports/services/dashboard.service.ts | 471 + src/modules/reports/services/index.ts | 8 + src/modules/reports/services/kpi.service.ts | 425 + .../reports/services/report.service.ts | 364 + src/modules/users/controllers/index.ts | 1 + .../users/controllers/users.controller.ts | 270 + src/modules/users/index.ts | 10 + src/modules/users/services/index.ts | 1 + src/modules/users/services/users.service.ts | 254 + src/server.ts | 364 + src/shared/constants/api.constants.ts | 249 + src/shared/constants/database.constants.ts | 315 + src/shared/constants/enums.constants.ts | 494 ++ src/shared/constants/index.ts | 194 + src/shared/database/typeorm.config.ts | 62 + src/shared/interfaces/base.interface.ts | 79 + src/shared/services/base.service.ts | 217 + src/shared/services/index.ts | 5 + tsconfig.json | 36 + 322 files changed, 64119 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/sync-enums.ts create mode 100644 scripts/validate-constants-usage.ts create mode 100644 service.descriptor.yml create mode 100644 src/modules/admin/controllers/audit-log.controller.ts create mode 100644 src/modules/admin/controllers/backup.controller.ts create mode 100644 src/modules/admin/controllers/cost-center.controller.ts create mode 100644 src/modules/admin/controllers/index.ts create mode 100644 src/modules/admin/controllers/system-setting.controller.ts create mode 100644 src/modules/admin/entities/audit-log.entity.ts create mode 100644 src/modules/admin/entities/backup.entity.ts create mode 100644 src/modules/admin/entities/cost-center.entity.ts create mode 100644 src/modules/admin/entities/custom-permission.entity.ts create mode 100644 src/modules/admin/entities/index.ts create mode 100644 src/modules/admin/entities/system-setting.entity.ts create mode 100644 src/modules/admin/services/audit-log.service.ts create mode 100644 src/modules/admin/services/backup.service.ts create mode 100644 src/modules/admin/services/cost-center.service.ts create mode 100644 src/modules/admin/services/index.ts create mode 100644 src/modules/admin/services/system-setting.service.ts create mode 100644 src/modules/auth/controllers/auth.controller.ts create mode 100644 src/modules/auth/controllers/index.ts create mode 100644 src/modules/auth/dto/auth.dto.ts create mode 100644 src/modules/auth/entities/index.ts create mode 100644 src/modules/auth/entities/permission.entity.ts create mode 100644 src/modules/auth/entities/refresh-token.entity.ts create mode 100644 src/modules/auth/entities/role.entity.ts create mode 100644 src/modules/auth/entities/user-role.entity.ts create mode 100644 src/modules/auth/index.ts create mode 100644 src/modules/auth/middleware/auth.middleware.ts create mode 100644 src/modules/auth/services/auth.service.ts create mode 100644 src/modules/auth/services/index.ts create mode 100644 src/modules/bidding/controllers/bid-analytics.controller.ts create mode 100644 src/modules/bidding/controllers/bid-budget.controller.ts create mode 100644 src/modules/bidding/controllers/bid.controller.ts create mode 100644 src/modules/bidding/controllers/index.ts create mode 100644 src/modules/bidding/controllers/opportunity.controller.ts create mode 100644 src/modules/bidding/entities/bid-budget.entity.ts create mode 100644 src/modules/bidding/entities/bid-calendar.entity.ts create mode 100644 src/modules/bidding/entities/bid-competitor.entity.ts create mode 100644 src/modules/bidding/entities/bid-document.entity.ts create mode 100644 src/modules/bidding/entities/bid-team.entity.ts create mode 100644 src/modules/bidding/entities/bid.entity.ts create mode 100644 src/modules/bidding/entities/index.ts create mode 100644 src/modules/bidding/entities/opportunity.entity.ts create mode 100644 src/modules/bidding/services/bid-analytics.service.ts create mode 100644 src/modules/bidding/services/bid-budget.service.ts create mode 100644 src/modules/bidding/services/bid.service.ts create mode 100644 src/modules/bidding/services/index.ts create mode 100644 src/modules/bidding/services/opportunity.service.ts create mode 100644 src/modules/budgets/controllers/concepto.controller.ts create mode 100644 src/modules/budgets/controllers/index.ts create mode 100644 src/modules/budgets/controllers/presupuesto.controller.ts create mode 100644 src/modules/budgets/entities/concepto.entity.ts create mode 100644 src/modules/budgets/entities/index.ts create mode 100644 src/modules/budgets/entities/presupuesto-partida.entity.ts create mode 100644 src/modules/budgets/entities/presupuesto.entity.ts create mode 100644 src/modules/budgets/services/concepto.service.ts create mode 100644 src/modules/budgets/services/index.ts create mode 100644 src/modules/budgets/services/presupuesto.service.ts create mode 100644 src/modules/construction/controllers/etapa.controller.ts create mode 100644 src/modules/construction/controllers/fraccionamiento.controller.ts create mode 100644 src/modules/construction/controllers/index.ts create mode 100644 src/modules/construction/controllers/lote.controller.ts create mode 100644 src/modules/construction/controllers/manzana.controller.ts create mode 100644 src/modules/construction/controllers/prototipo.controller.ts create mode 100644 src/modules/construction/controllers/proyecto.controller.ts create mode 100644 src/modules/construction/entities/etapa.entity.ts create mode 100644 src/modules/construction/entities/fraccionamiento.entity.ts create mode 100644 src/modules/construction/entities/index.ts create mode 100644 src/modules/construction/entities/lote.entity.ts create mode 100644 src/modules/construction/entities/manzana.entity.ts create mode 100644 src/modules/construction/entities/prototipo.entity.ts create mode 100644 src/modules/construction/entities/proyecto.entity.ts create mode 100644 src/modules/construction/services/etapa.service.ts create mode 100644 src/modules/construction/services/fraccionamiento.service.ts create mode 100644 src/modules/construction/services/index.ts create mode 100644 src/modules/construction/services/lote.service.ts create mode 100644 src/modules/construction/services/manzana.service.ts create mode 100644 src/modules/construction/services/prototipo.service.ts create mode 100644 src/modules/construction/services/proyecto.service.ts create mode 100644 src/modules/contracts/controllers/contract.controller.ts create mode 100644 src/modules/contracts/controllers/index.ts create mode 100644 src/modules/contracts/controllers/subcontractor.controller.ts create mode 100644 src/modules/contracts/entities/contract-addendum.entity.ts create mode 100644 src/modules/contracts/entities/contract.entity.ts create mode 100644 src/modules/contracts/entities/index.ts create mode 100644 src/modules/contracts/entities/subcontractor.entity.ts create mode 100644 src/modules/contracts/services/contract.service.ts create mode 100644 src/modules/contracts/services/index.ts create mode 100644 src/modules/contracts/services/subcontractor.service.ts create mode 100644 src/modules/core/entities/index.ts create mode 100644 src/modules/core/entities/tenant.entity.ts create mode 100644 src/modules/core/entities/user.entity.ts create mode 100644 src/modules/estimates/controllers/anticipo.controller.ts create mode 100644 src/modules/estimates/controllers/estimacion.controller.ts create mode 100644 src/modules/estimates/controllers/fondo-garantia.controller.ts create mode 100644 src/modules/estimates/controllers/index.ts create mode 100644 src/modules/estimates/controllers/retencion.controller.ts create mode 100644 src/modules/estimates/entities/amortizacion.entity.ts create mode 100644 src/modules/estimates/entities/anticipo.entity.ts create mode 100644 src/modules/estimates/entities/estimacion-concepto.entity.ts create mode 100644 src/modules/estimates/entities/estimacion-workflow.entity.ts create mode 100644 src/modules/estimates/entities/estimacion.entity.ts create mode 100644 src/modules/estimates/entities/fondo-garantia.entity.ts create mode 100644 src/modules/estimates/entities/generador.entity.ts create mode 100644 src/modules/estimates/entities/index.ts create mode 100644 src/modules/estimates/entities/retencion.entity.ts create mode 100644 src/modules/estimates/services/anticipo.service.ts create mode 100644 src/modules/estimates/services/estimacion.service.ts create mode 100644 src/modules/estimates/services/fondo-garantia.service.ts create mode 100644 src/modules/estimates/services/index.ts create mode 100644 src/modules/estimates/services/retencion.service.ts create mode 100644 src/modules/finance/controllers/accounting.controller.ts create mode 100644 src/modules/finance/controllers/ap.controller.ts create mode 100644 src/modules/finance/controllers/ar.controller.ts create mode 100644 src/modules/finance/controllers/bank-reconciliation.controller.ts create mode 100644 src/modules/finance/controllers/cash-flow.controller.ts create mode 100644 src/modules/finance/controllers/index.ts create mode 100644 src/modules/finance/controllers/reports.controller.ts create mode 100644 src/modules/finance/entities/account-payable.entity.ts create mode 100644 src/modules/finance/entities/account-receivable.entity.ts create mode 100644 src/modules/finance/entities/accounting-entry-line.entity.ts create mode 100644 src/modules/finance/entities/accounting-entry.entity.ts create mode 100644 src/modules/finance/entities/ap-payment.entity.ts create mode 100644 src/modules/finance/entities/ar-payment.entity.ts create mode 100644 src/modules/finance/entities/bank-account.entity.ts create mode 100644 src/modules/finance/entities/bank-movement.entity.ts create mode 100644 src/modules/finance/entities/bank-reconciliation.entity.ts create mode 100644 src/modules/finance/entities/cash-flow-projection.entity.ts create mode 100644 src/modules/finance/entities/chart-of-accounts.entity.ts create mode 100644 src/modules/finance/entities/index.ts create mode 100644 src/modules/finance/index.ts create mode 100644 src/modules/finance/services/accounting.service.ts create mode 100644 src/modules/finance/services/ap.service.ts create mode 100644 src/modules/finance/services/ar.service.ts create mode 100644 src/modules/finance/services/bank-reconciliation.service.ts create mode 100644 src/modules/finance/services/cash-flow.service.ts create mode 100644 src/modules/finance/services/erp-integration.service.ts create mode 100644 src/modules/finance/services/financial-reports.service.ts create mode 100644 src/modules/finance/services/index.ts create mode 100644 src/modules/hr/controllers/employee.controller.ts create mode 100644 src/modules/hr/controllers/index.ts create mode 100644 src/modules/hr/controllers/puesto.controller.ts create mode 100644 src/modules/hr/entities/employee-fraccionamiento.entity.ts create mode 100644 src/modules/hr/entities/employee.entity.ts create mode 100644 src/modules/hr/entities/index.ts create mode 100644 src/modules/hr/entities/puesto.entity.ts create mode 100644 src/modules/hr/services/employee.service.ts create mode 100644 src/modules/hr/services/index.ts create mode 100644 src/modules/hr/services/puesto.service.ts create mode 100644 src/modules/hse/controllers/ambiental.controller.ts create mode 100644 src/modules/hse/controllers/capacitacion.controller.ts create mode 100644 src/modules/hse/controllers/epp.controller.ts create mode 100644 src/modules/hse/controllers/incidente.controller.ts create mode 100644 src/modules/hse/controllers/index.ts create mode 100644 src/modules/hse/controllers/indicador.controller.ts create mode 100644 src/modules/hse/controllers/inspeccion.controller.ts create mode 100644 src/modules/hse/controllers/permiso-trabajo.controller.ts create mode 100644 src/modules/hse/controllers/stps.controller.ts create mode 100644 src/modules/hse/entities/alerta-indicador.entity.ts create mode 100644 src/modules/hse/entities/almacen-temporal.entity.ts create mode 100644 src/modules/hse/entities/auditoria.entity.ts create mode 100644 src/modules/hse/entities/capacitacion-asistente.entity.ts create mode 100644 src/modules/hse/entities/capacitacion-matriz.entity.ts create mode 100644 src/modules/hse/entities/capacitacion-sesion.entity.ts create mode 100644 src/modules/hse/entities/capacitacion.entity.ts create mode 100644 src/modules/hse/entities/checklist-item.entity.ts create mode 100644 src/modules/hse/entities/comision-integrante.entity.ts create mode 100644 src/modules/hse/entities/comision-recorrido.entity.ts create mode 100644 src/modules/hse/entities/comision-seguridad.entity.ts create mode 100644 src/modules/hse/entities/constancia-dc3.entity.ts create mode 100644 src/modules/hse/entities/cumplimiento-obra.entity.ts create mode 100644 src/modules/hse/entities/dias-sin-accidente.entity.ts create mode 100644 src/modules/hse/entities/documento-stps.entity.ts create mode 100644 src/modules/hse/entities/epp-asignacion.entity.ts create mode 100644 src/modules/hse/entities/epp-baja.entity.ts create mode 100644 src/modules/hse/entities/epp-catalogo.entity.ts create mode 100644 src/modules/hse/entities/epp-inspeccion.entity.ts create mode 100644 src/modules/hse/entities/epp-inventario.entity.ts create mode 100644 src/modules/hse/entities/epp-matriz-puesto.entity.ts create mode 100644 src/modules/hse/entities/epp-movimiento.entity.ts create mode 100644 src/modules/hse/entities/hallazgo-evidencia.entity.ts create mode 100644 src/modules/hse/entities/hallazgo.entity.ts create mode 100644 src/modules/hse/entities/horas-trabajadas.entity.ts create mode 100644 src/modules/hse/entities/impacto-ambiental.entity.ts create mode 100644 src/modules/hse/entities/incidente-accion.entity.ts create mode 100644 src/modules/hse/entities/incidente-evidencia.entity.ts create mode 100644 src/modules/hse/entities/incidente-investigacion.entity.ts create mode 100644 src/modules/hse/entities/incidente-involucrado.entity.ts create mode 100644 src/modules/hse/entities/incidente.entity.ts create mode 100644 src/modules/hse/entities/index.ts create mode 100644 src/modules/hse/entities/indicador-config.entity.ts create mode 100644 src/modules/hse/entities/indicador-meta-obra.entity.ts create mode 100644 src/modules/hse/entities/indicador-valor.entity.ts create mode 100644 src/modules/hse/entities/inspeccion-evaluacion.entity.ts create mode 100644 src/modules/hse/entities/inspeccion.entity.ts create mode 100644 src/modules/hse/entities/instructor.entity.ts create mode 100644 src/modules/hse/entities/manifiesto-detalle.entity.ts create mode 100644 src/modules/hse/entities/manifiesto-residuos.entity.ts create mode 100644 src/modules/hse/entities/norma-requisito.entity.ts create mode 100644 src/modules/hse/entities/norma-stps.entity.ts create mode 100644 src/modules/hse/entities/permiso-autorizacion.entity.ts create mode 100644 src/modules/hse/entities/permiso-checklist.entity.ts create mode 100644 src/modules/hse/entities/permiso-documento.entity.ts create mode 100644 src/modules/hse/entities/permiso-evento.entity.ts create mode 100644 src/modules/hse/entities/permiso-monitoreo.entity.ts create mode 100644 src/modules/hse/entities/permiso-personal.entity.ts create mode 100644 src/modules/hse/entities/permiso-trabajo.entity.ts create mode 100644 src/modules/hse/entities/programa-actividad.entity.ts create mode 100644 src/modules/hse/entities/programa-inspeccion.entity.ts create mode 100644 src/modules/hse/entities/programa-seguridad.entity.ts create mode 100644 src/modules/hse/entities/proveedor-ambiental.entity.ts create mode 100644 src/modules/hse/entities/queja-ambiental.entity.ts create mode 100644 src/modules/hse/entities/reporte-programado.entity.ts create mode 100644 src/modules/hse/entities/residuo-catalogo.entity.ts create mode 100644 src/modules/hse/entities/residuo-generacion.entity.ts create mode 100644 src/modules/hse/entities/tipo-inspeccion.entity.ts create mode 100644 src/modules/hse/entities/tipo-permiso-trabajo.entity.ts create mode 100644 src/modules/hse/services/ambiental.service.ts create mode 100644 src/modules/hse/services/capacitacion.service.ts create mode 100644 src/modules/hse/services/epp.service.ts create mode 100644 src/modules/hse/services/incidente.service.ts create mode 100644 src/modules/hse/services/index.ts create mode 100644 src/modules/hse/services/indicador.service.ts create mode 100644 src/modules/hse/services/inspeccion.service.ts create mode 100644 src/modules/hse/services/permiso-trabajo.service.ts create mode 100644 src/modules/hse/services/stps.service.ts create mode 100644 src/modules/infonavit/controllers/asignacion.controller.ts create mode 100644 src/modules/infonavit/controllers/derechohabiente.controller.ts create mode 100644 src/modules/infonavit/controllers/index.ts create mode 100644 src/modules/infonavit/entities/acta-vivienda.entity.ts create mode 100644 src/modules/infonavit/entities/acta.entity.ts create mode 100644 src/modules/infonavit/entities/asignacion-vivienda.entity.ts create mode 100644 src/modules/infonavit/entities/derechohabiente.entity.ts create mode 100644 src/modules/infonavit/entities/historico-puntos.entity.ts create mode 100644 src/modules/infonavit/entities/index.ts create mode 100644 src/modules/infonavit/entities/oferta-vivienda.entity.ts create mode 100644 src/modules/infonavit/entities/registro-infonavit.entity.ts create mode 100644 src/modules/infonavit/entities/reporte-infonavit.entity.ts create mode 100644 src/modules/infonavit/services/asignacion.service.ts create mode 100644 src/modules/infonavit/services/derechohabiente.service.ts create mode 100644 src/modules/infonavit/services/index.ts create mode 100644 src/modules/inventory/controllers/consumo-obra.controller.ts create mode 100644 src/modules/inventory/controllers/index.ts create mode 100644 src/modules/inventory/controllers/requisicion.controller.ts create mode 100644 src/modules/inventory/entities/almacen-proyecto.entity.ts create mode 100644 src/modules/inventory/entities/consumo-obra.entity.ts create mode 100644 src/modules/inventory/entities/index.ts create mode 100644 src/modules/inventory/entities/requisicion-linea.entity.ts create mode 100644 src/modules/inventory/entities/requisicion-obra.entity.ts create mode 100644 src/modules/inventory/services/consumo-obra.service.ts create mode 100644 src/modules/inventory/services/index.ts create mode 100644 src/modules/inventory/services/requisicion.service.ts create mode 100644 src/modules/progress/controllers/avance-obra.controller.ts create mode 100644 src/modules/progress/controllers/bitacora-obra.controller.ts create mode 100644 src/modules/progress/controllers/index.ts create mode 100644 src/modules/progress/entities/avance-obra.entity.ts create mode 100644 src/modules/progress/entities/bitacora-obra.entity.ts create mode 100644 src/modules/progress/entities/foto-avance.entity.ts create mode 100644 src/modules/progress/entities/index.ts create mode 100644 src/modules/progress/entities/programa-actividad.entity.ts create mode 100644 src/modules/progress/entities/programa-obra.entity.ts create mode 100644 src/modules/progress/services/avance-obra.service.ts create mode 100644 src/modules/progress/services/bitacora-obra.service.ts create mode 100644 src/modules/progress/services/index.ts create mode 100644 src/modules/purchase/controllers/comparativo.controller.ts create mode 100644 src/modules/purchase/controllers/index.ts create mode 100644 src/modules/purchase/entities/comparativo-cotizaciones.entity.ts create mode 100644 src/modules/purchase/entities/comparativo-producto.entity.ts create mode 100644 src/modules/purchase/entities/comparativo-proveedor.entity.ts create mode 100644 src/modules/purchase/entities/index.ts create mode 100644 src/modules/purchase/services/comparativo.service.ts create mode 100644 src/modules/purchase/services/index.ts create mode 100644 src/modules/quality/controllers/index.ts create mode 100644 src/modules/quality/controllers/inspection.controller.ts create mode 100644 src/modules/quality/controllers/ticket.controller.ts create mode 100644 src/modules/quality/entities/checklist-item.entity.ts create mode 100644 src/modules/quality/entities/checklist.entity.ts create mode 100644 src/modules/quality/entities/corrective-action.entity.ts create mode 100644 src/modules/quality/entities/index.ts create mode 100644 src/modules/quality/entities/inspection-result.entity.ts create mode 100644 src/modules/quality/entities/inspection.entity.ts create mode 100644 src/modules/quality/entities/non-conformity.entity.ts create mode 100644 src/modules/quality/entities/post-sale-ticket.entity.ts create mode 100644 src/modules/quality/entities/ticket-assignment.entity.ts create mode 100644 src/modules/quality/services/index.ts create mode 100644 src/modules/quality/services/inspection.service.ts create mode 100644 src/modules/quality/services/ticket.service.ts create mode 100644 src/modules/reports/controllers/dashboard.controller.ts create mode 100644 src/modules/reports/controllers/index.ts create mode 100644 src/modules/reports/controllers/kpi.controller.ts create mode 100644 src/modules/reports/controllers/report.controller.ts create mode 100644 src/modules/reports/entities/dashboard-widget.entity.ts create mode 100644 src/modules/reports/entities/dashboard.entity.ts create mode 100644 src/modules/reports/entities/index.ts create mode 100644 src/modules/reports/entities/kpi-snapshot.entity.ts create mode 100644 src/modules/reports/entities/report-execution.entity.ts create mode 100644 src/modules/reports/entities/report.entity.ts create mode 100644 src/modules/reports/services/dashboard.service.ts create mode 100644 src/modules/reports/services/index.ts create mode 100644 src/modules/reports/services/kpi.service.ts create mode 100644 src/modules/reports/services/report.service.ts create mode 100644 src/modules/users/controllers/index.ts create mode 100644 src/modules/users/controllers/users.controller.ts create mode 100644 src/modules/users/index.ts create mode 100644 src/modules/users/services/index.ts create mode 100644 src/modules/users/services/users.service.ts create mode 100644 src/server.ts create mode 100644 src/shared/constants/api.constants.ts create mode 100644 src/shared/constants/database.constants.ts create mode 100644 src/shared/constants/enums.constants.ts create mode 100644 src/shared/constants/index.ts create mode 100644 src/shared/database/typeorm.config.ts create mode 100644 src/shared/interfaces/base.interface.ts create mode 100644 src/shared/services/base.service.ts create mode 100644 src/shared/services/index.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6782c1b --- /dev/null +++ b/.env.example @@ -0,0 +1,71 @@ +# ============================================================================ +# BACKEND ENVIRONMENT VARIABLES - ERP Construccion +# ============================================================================ +# Proyecto: construccion +# Rango de puertos: 3100 (ver DEVENV-PORTS.md) +# Fecha: 2025-12-06 +# ============================================================================ + +# Application +NODE_ENV=development +APP_PORT=3021 +APP_HOST=0.0.0.0 +API_VERSION=v1 +API_PREFIX=/api/v1 + +# Database (Puerto 5433 - diferenciado de erp-core:5432) +DATABASE_URL=postgresql://erp_user:erp_dev_password@localhost:5433/erp_construccion +DB_HOST=localhost +DB_PORT=5433 +DB_NAME=erp_construccion +DB_USER=erp_user +DB_PASSWORD=erp_dev_password +DB_SYNCHRONIZE=false +DB_LOGGING=true + +# Redis (Puerto 6380 - diferenciado de erp-core:6379) +REDIS_HOST=localhost +REDIS_PORT=6380 +REDIS_URL=redis://localhost:6380 + +# MinIO S3 (Puerto 9100 - diferenciado de erp-core:9000) +S3_ENDPOINT=http://localhost:9100 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=erp-construccion + +# JWT +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production +JWT_EXPIRATION=24h +JWT_REFRESH_EXPIRATION=7d + +# CORS (Frontend en puerto 5174) +CORS_ORIGIN=http://localhost:3020,http://localhost:5174 +CORS_CREDENTIALS=true + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX_REQUESTS=100 + +# Logging +LOG_LEVEL=debug +LOG_FORMAT=dev + +# File Upload +MAX_FILE_SIZE=10485760 +UPLOAD_DIR=./uploads + +# Email (opcional) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@example.com +SMTP_PASSWORD=your-email-password +SMTP_FROM=noreply@example.com + +# Security +BCRYPT_ROUNDS=10 +SESSION_SECRET=your-session-secret-change-this + +# External APIs (futuro) +INFONAVIT_API_URL=https://api.infonavit.gob.mx +INFONAVIT_API_KEY=your-api-key diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1d85539 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,84 @@ +# ============================================================================= +# Dockerfile - Backend API +# ERP Construccion - Node.js + Express + TypeScript +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1: Base +# ----------------------------------------------------------------------------- +FROM node:20-alpine AS base + +# Install dependencies for native modules +RUN apk add --no-cache \ + python3 \ + make \ + g++ \ + curl + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# ----------------------------------------------------------------------------- +# Stage 2: Development +# ----------------------------------------------------------------------------- +FROM base AS development + +# Install all dependencies (including devDependencies) +RUN npm ci + +# Copy source code +COPY . . + +# Expose port (standard: 3021 for construccion backend) +EXPOSE 3021 + +# Development command with hot reload +CMD ["npm", "run", "dev"] + +# ----------------------------------------------------------------------------- +# Stage 3: Builder +# ----------------------------------------------------------------------------- +FROM base AS builder + +# Install all dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build TypeScript +RUN npm run build + +# Prune devDependencies +RUN npm prune --production + +# ----------------------------------------------------------------------------- +# Stage 4: Production +# ----------------------------------------------------------------------------- +FROM node:20-alpine AS production + +# Security: Run as non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +WORKDIR /app + +# Copy built application +COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist +COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./ + +# Set user +USER nodejs + +# Expose port (standard: 3021 for construccion backend) +EXPOSE 3021 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:3021/health || exit 1 + +# Production command +CMD ["node", "dist/server.js"] diff --git a/README.md b/README.md index 175dfe2..0e5bb19 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,461 @@ -# erp-construccion-backend-v2 +# Backend - ERP Construccion -Backend de erp-construccion - Workspace V2 \ No newline at end of file +API REST para sistema de administracion de obra e INFONAVIT. + +| Campo | Valor | +|-------|-------| +| **Stack** | Node.js 20 + Express 4 + TypeScript 5 + TypeORM 0.3 | +| **Version** | 1.0.0 | +| **Entidades** | 30 | +| **Services** | 8 | +| **Arquitectura** | Multi-tenant con RLS | + +--- + +## Quick Start + +```bash +# Instalar dependencias +npm install + +# Configurar variables de entorno +cp ../.env.example .env + +# Desarrollo con hot-reload +npm run dev + +# El servidor estara en http://localhost:3000 +``` + +--- + +## Estructura del Proyecto + +``` +src/ +├── modules/ +│ ├── auth/ # Autenticacion JWT +│ │ ├── dto/ +│ │ │ └── auth.dto.ts # DTOs tipados +│ │ ├── middleware/ +│ │ │ └── auth.middleware.ts +│ │ ├── services/ +│ │ │ └── auth.service.ts +│ │ └── index.ts +│ │ +│ ├── budgets/ # MAI-003 Presupuestos +│ │ ├── entities/ +│ │ │ ├── concepto.entity.ts +│ │ │ ├── presupuesto.entity.ts +│ │ │ └── presupuesto-partida.entity.ts +│ │ ├── services/ +│ │ │ ├── concepto.service.ts +│ │ │ └── presupuesto.service.ts +│ │ └── index.ts +│ │ +│ ├── progress/ # MAI-005 Control de Obra +│ │ ├── entities/ +│ │ │ ├── avance-obra.entity.ts +│ │ │ ├── foto-avance.entity.ts +│ │ │ ├── bitacora-obra.entity.ts +│ │ │ ├── programa-obra.entity.ts +│ │ │ └── programa-actividad.entity.ts +│ │ ├── services/ +│ │ │ ├── avance-obra.service.ts +│ │ │ └── bitacora-obra.service.ts +│ │ └── index.ts +│ │ +│ ├── estimates/ # MAI-008 Estimaciones +│ │ ├── entities/ +│ │ │ ├── estimacion.entity.ts +│ │ │ ├── estimacion-concepto.entity.ts +│ │ │ ├── generador.entity.ts +│ │ │ ├── anticipo.entity.ts +│ │ │ ├── amortizacion.entity.ts +│ │ │ ├── retencion.entity.ts +│ │ │ ├── fondo-garantia.entity.ts +│ │ │ └── estimacion-workflow.entity.ts +│ │ ├── services/ +│ │ │ └── estimacion.service.ts +│ │ └── index.ts +│ │ +│ ├── construction/ # MAI-002 Proyectos +│ │ └── entities/ +│ │ ├── proyecto.entity.ts +│ │ └── fraccionamiento.entity.ts +│ │ +│ ├── hr/ # MAI-007 RRHH +│ │ └── entities/ +│ │ ├── employee.entity.ts +│ │ ├── puesto.entity.ts +│ │ └── employee-fraccionamiento.entity.ts +│ │ +│ ├── hse/ # MAA-017 Seguridad HSE +│ │ └── entities/ +│ │ ├── incidente.entity.ts +│ │ ├── incidente-involucrado.entity.ts +│ │ ├── incidente-accion.entity.ts +│ │ └── capacitacion.entity.ts +│ │ +│ └── core/ # Entidades base +│ └── entities/ +│ ├── user.entity.ts +│ └── tenant.entity.ts +│ +└── shared/ + ├── constants/ # SSOT + │ ├── database.constants.ts + │ ├── api.constants.ts + │ ├── enums.constants.ts + │ └── index.ts + ├── services/ + │ └── base.service.ts # CRUD multi-tenant + └── database/ + └── typeorm.config.ts +``` + +--- + +## Modulos Implementados + +### Auth Module + +Autenticacion JWT con refresh tokens y multi-tenancy. + +```typescript +// Services +AuthService + ├── login(dto) // Login con email/password + ├── register(dto) // Registro de usuarios + ├── refresh(dto) // Renovar tokens + ├── logout(token) // Revocar refresh token + └── changePassword(dto) // Cambiar password + +// Middleware +AuthMiddleware + ├── authenticate // Validar JWT (requerido) + ├── optionalAuthenticate // Validar JWT (opcional) + ├── authorize(...roles) // Autorizar por roles + ├── requireAdmin // Solo admin/super_admin + └── requireSupervisor // Solo supervisores+ +``` + +### Budgets Module (MAI-003) + +Catalogo de conceptos y presupuestos de obra. + +```typescript +// Entities +Concepto // Catalogo jerarquico (arbol) +Presupuesto // Presupuestos versionados +PresupuestoPartida // Lineas con calculo automatico + +// Services +ConceptoService + ├── createConcepto(ctx, dto) // Crear con nivel/path automatico + ├── findRootConceptos(ctx) // Conceptos raiz + ├── findChildren(ctx, parentId) // Hijos de un concepto + ├── getConceptoTree(ctx, rootId) // Arbol completo + └── search(ctx, term) // Busqueda por codigo/nombre + +PresupuestoService + ├── createPresupuesto(ctx, dto) + ├── findByFraccionamiento(ctx, id) + ├── findWithPartidas(ctx, id) + ├── addPartida(ctx, id, dto) + ├── updatePartida(ctx, id, dto) + ├── removePartida(ctx, id) + ├── recalculateTotal(ctx, id) + ├── createNewVersion(ctx, id) // Versionamiento + └── approve(ctx, id) +``` + +### Progress Module (MAI-005) + +Control de avances fisicos y bitacora de obra. + +```typescript +// Entities +AvanceObra // Avances con workflow +FotoAvance // Evidencias fotograficas con GPS +BitacoraObra // Bitacora diaria +ProgramaObra // Programa maestro +ProgramaActividad // Actividades WBS + +// Services +AvanceObraService + ├── createAvance(ctx, dto) + ├── findByLote(ctx, loteId) + ├── findByDepartamento(ctx, deptoId) + ├── findWithFilters(ctx, filters) + ├── findWithFotos(ctx, id) + ├── addFoto(ctx, id, dto) + ├── review(ctx, id) // Workflow: revisar + ├── approve(ctx, id) // Workflow: aprobar + ├── reject(ctx, id, reason) // Workflow: rechazar + └── getAccumulatedProgress(ctx) // Acumulado por concepto + +BitacoraObraService + ├── createEntry(ctx, dto) // Numero automatico + ├── findByFraccionamiento(ctx, id) + ├── findWithFilters(ctx, id, filters) + ├── findByDate(ctx, id, date) + ├── findLatest(ctx, id) + └── getStats(ctx, id) // Estadisticas +``` + +### Estimates Module (MAI-008) + +Estimaciones periodicas con workflow de aprobacion. + +```typescript +// Entities +Estimacion // Estimaciones con workflow +EstimacionConcepto // Lineas con acumulados +Generador // Numeros generadores +Anticipo // Anticipos +Amortizacion // Amortizaciones +Retencion // Retenciones +FondoGarantia // Fondo de garantia +EstimacionWorkflow // Historial de estados + +// Services +EstimacionService + ├── createEstimacion(ctx, dto) // Numero automatico + ├── findByContrato(ctx, contratoId) + ├── findWithFilters(ctx, filters) + ├── findWithDetails(ctx, id) // Con relaciones + ├── addConcepto(ctx, id, dto) + ├── addGenerador(ctx, conceptoId, dto) + ├── recalculateTotals(ctx, id) // Llama funcion PG + ├── submit(ctx, id) // Workflow + ├── review(ctx, id) // Workflow + ├── approve(ctx, id) // Workflow + ├── reject(ctx, id, reason) // Workflow + └── getContractSummary(ctx, id) // Resumen financiero +``` + +--- + +## Base Service + +Servicio base con CRUD multi-tenant. + +```typescript +// Uso +class MiService extends BaseService { + constructor(repository: Repository) { + super(repository); + } +} + +// Metodos disponibles +BaseService + ├── findAll(ctx, options?) // Paginado + ├── findById(ctx, id) + ├── findOne(ctx, where) + ├── find(ctx, options) + ├── create(ctx, data) + ├── update(ctx, id, data) + ├── softDelete(ctx, id) + ├── hardDelete(ctx, id) + ├── count(ctx, where?) + └── exists(ctx, where) + +// ServiceContext +interface ServiceContext { + tenantId: string; + userId: string; +} +``` + +--- + +## SSOT Constants + +Sistema de constantes centralizadas. + +```typescript +// database.constants.ts +import { DB_SCHEMAS, DB_TABLES, TABLE_REFS } from '@shared/constants'; + +DB_SCHEMAS.CONSTRUCTION // 'construction' +DB_TABLES.construction.CONCEPTOS // 'conceptos' +TABLE_REFS.FRACCIONAMIENTOS // 'construction.fraccionamientos' + +// api.constants.ts +import { API_ROUTES } from '@shared/constants'; + +API_ROUTES.PRESUPUESTOS.BASE // '/api/v1/presupuestos' +API_ROUTES.ESTIMACIONES.BY_ID(id) // '/api/v1/estimaciones/:id' + +// enums.constants.ts +import { ROLES, PROJECT_STATUS } from '@shared/constants'; + +ROLES.ADMIN // 'admin' +PROJECT_STATUS.IN_PROGRESS // 'in_progress' +``` + +--- + +## Scripts NPM + +```bash +# Desarrollo +npm run dev # Hot-reload con ts-node-dev +npm run build # Compilar TypeScript +npm run start # Produccion (dist/) + +# Calidad +npm run lint # ESLint +npm run lint:fix # ESLint con autofix +npm run test # Jest +npm run test:watch # Jest watch mode +npm run test:coverage # Jest con cobertura + +# Base de datos +npm run migration:generate # Generar migracion +npm run migration:run # Ejecutar migraciones +npm run migration:revert # Revertir ultima + +# SSOT +npm run validate:constants # Validar no hardcoding +npm run sync:enums # Sincronizar a frontend +npm run precommit # lint + validate +``` + +--- + +## Convenciones + +### Nomenclatura + +| Tipo | Convencion | Ejemplo | +|------|------------|---------| +| Archivos | kebab-case.tipo.ts | `concepto.entity.ts` | +| Clases | PascalCase + sufijo | `ConceptoService` | +| Variables | camelCase | `totalAmount` | +| Constantes | UPPER_SNAKE_CASE | `DB_SCHEMAS` | +| Metodos | camelCase + verbo | `findByContrato` | + +### Entity Pattern + +```typescript +@Entity({ schema: 'construction', name: 'conceptos' }) +@Index(['tenantId', 'code'], { unique: true }) +export class Concepto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // ... columnas con name: 'snake_case' + + // Soft delete + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; +} +``` + +### Service Pattern + +```typescript +export class MiService extends BaseService { + constructor( + repository: Repository, + private readonly otroRepo: Repository + ) { + super(repository); + } + + async miMetodo(ctx: ServiceContext, data: MiDto): Promise { + // ctx tiene tenantId y userId + return this.create(ctx, data); + } +} +``` + +--- + +## Seguridad + +- Helmet para HTTP security headers +- CORS configurado por dominio +- Rate limiting por IP +- JWT con refresh tokens +- Bcrypt (12 rounds) para passwords +- class-validator para inputs +- RLS para aislamiento de tenants + +--- + +## Testing + +```bash +# Ejecutar tests +npm test + +# Con cobertura +npm run test:coverage + +# Watch mode +npm run test:watch +``` + +```typescript +// Ejemplo de test +describe('ConceptoService', () => { + let service: ConceptoService; + let mockRepo: jest.Mocked>; + + beforeEach(() => { + mockRepo = createMockRepository(); + service = new ConceptoService(mockRepo); + }); + + it('should create concepto with level', async () => { + const ctx = { tenantId: 'uuid', userId: 'uuid' }; + const dto = { code: '001', name: 'Test' }; + + mockRepo.save.mockResolvedValue({ ...dto, level: 0 }); + + const result = await service.createConcepto(ctx, dto); + expect(result.level).toBe(0); + }); +}); +``` + +--- + +## Debugging + +### VS Code + +```json +{ + "type": "node", + "request": "launch", + "name": "Debug Backend", + "runtimeArgs": ["-r", "ts-node/register"], + "args": ["${workspaceFolder}/src/server.ts"], + "env": { "NODE_ENV": "development" } +} +``` + +### Logs + +```typescript +// Configurar en .env +LOG_LEVEL=debug +LOG_FORMAT=dev +``` + +--- + +**Ultima actualizacion:** 2025-12-12 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1c059c4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7817 @@ +{ + "name": "@construccion-mvp/backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@construccion-mvp/backend", + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "bcryptjs": "^2.4.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "pg": "^8.11.3", + "reflect-metadata": "^0.1.13", + "swagger-ui-express": "^5.0.0", + "typeorm": "^0.3.17", + "yamljs": "^0.3.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.5", + "@types/morgan": "^1.9.9", + "@types/node": "^20.10.5", + "@types/swagger-ui-express": "^4.1.6", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/morgan": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.26", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz", + "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-root-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", + "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz", + "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", + "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.20" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.31", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.31.tgz", + "integrity": "sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz", + "integrity": "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-static/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/sql-highlight": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", + "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.30.3", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.3.tgz", + "integrity": "sha512-giQl7/ToPxCqnUAx2wpnSnDNGZtGzw1LyUw6ZitIpTmdrvpxKFY/94v1hihm0zYNpgp1/VY0jTDk//R0BBgnRQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node-dev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", + "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.1", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.6", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^10.4.0", + "tsconfig": "^7.0.0" + }, + "bin": { + "ts-node-dev": "lib/bin.js", + "tsnd": "lib/bin.js" + }, + "engines": { + "node": ">=0.8.0" + }, + "peerDependencies": { + "node-notifier": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/ts-node-dev/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/tsconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + } + }, + "node_modules/tsconfig/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tsconfig/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typeorm": { + "version": "0.3.28", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", + "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", + "license": "MIT", + "dependencies": { + "@sqltools/formatter": "^1.2.5", + "ansis": "^4.2.0", + "app-root-path": "^3.1.0", + "buffer": "^6.0.3", + "dayjs": "^1.11.19", + "debug": "^4.4.3", + "dedent": "^1.7.0", + "dotenv": "^16.6.1", + "glob": "^10.5.0", + "reflect-metadata": "^0.2.2", + "sha.js": "^2.4.12", + "sql-highlight": "^6.1.0", + "tslib": "^2.8.1", + "uuid": "^11.1.0", + "yargs": "^17.7.2" + }, + "bin": { + "typeorm": "cli.js", + "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", + "typeorm-ts-node-esm": "cli-ts-node-esm.js" + }, + "engines": { + "node": ">=16.13.0" + }, + "funding": { + "url": "https://opencollective.com/typeorm" + }, + "peerDependencies": { + "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@sap/hana-client": "^2.14.22", + "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "ioredis": "^5.0.4", + "mongodb": "^5.8.0 || ^6.0.0", + "mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "mysql2": "^2.2.5 || ^3.0.1", + "oracledb": "^6.3.0", + "pg": "^8.5.1", + "pg-native": "^3.0.0", + "pg-query-stream": "^4.0.0", + "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", + "sql.js": "^1.4.0", + "sqlite3": "^5.0.3", + "ts-node": "^10.7.0", + "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/spanner": { + "optional": true + }, + "@sap/hana-client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "redis": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "ts-node": { + "optional": true + }, + "typeorm-aurora-data-api-driver": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validator": { + "version": "13.15.23", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", + "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yamljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", + "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "glob": "^7.0.5" + }, + "bin": { + "json2yaml": "bin/json2yaml", + "yaml2json": "bin/yaml2json" + } + }, + "node_modules/yamljs/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..894e097 --- /dev/null +++ b/package.json @@ -0,0 +1,73 @@ +{ + "name": "@construccion-mvp/backend", + "version": "1.0.0", + "description": "Backend API - MVP Sistema Administración de Obra e INFONAVIT", + "main": "dist/server.js", + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "lint": "eslint src/**/*.ts", + "lint:fix": "eslint src/**/*.ts --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "typeorm": "typeorm-ts-node-commonjs", + "migration:generate": "npm run typeorm -- migration:generate", + "migration:run": "npm run typeorm -- migration:run", + "migration:revert": "npm run typeorm -- migration:revert", + "validate:constants": "ts-node scripts/validate-constants-usage.ts", + "sync:enums": "ts-node scripts/sync-enums.ts", + "precommit": "npm run lint && npm run validate:constants" + }, + "keywords": [ + "construccion", + "erp", + "infonavit", + "nodejs", + "typescript", + "express", + "typeorm" + ], + "author": "Tu Empresa", + "license": "UNLICENSED", + "dependencies": { + "express": "^4.18.2", + "typeorm": "^0.3.17", + "pg": "^8.11.3", + "reflect-metadata": "^0.1.13", + "class-validator": "^0.14.0", + "class-transformer": "^0.5.1", + "dotenv": "^16.3.1", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "morgan": "^1.10.0", + "express-rate-limit": "^7.1.5", + "bcryptjs": "^2.4.3", + "jsonwebtoken": "^9.0.2", + "swagger-ui-express": "^5.0.0", + "yamljs": "^0.3.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.5", + "@types/cors": "^2.8.17", + "@types/morgan": "^1.9.9", + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.5", + "@types/swagger-ui-express": "^4.1.6", + "@types/jest": "^29.5.11", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } +} diff --git a/scripts/sync-enums.ts b/scripts/sync-enums.ts new file mode 100644 index 0000000..01cc26d --- /dev/null +++ b/scripts/sync-enums.ts @@ -0,0 +1,120 @@ +#!/usr/bin/env ts-node +/** + * Sync Enums - Backend to Frontend + * + * Este script sincroniza automaticamente las constantes y enums del backend + * al frontend, manteniendo el principio SSOT (Single Source of Truth). + * + * Ejecutar: npm run sync:enums + * + * @author Architecture-Analyst + * @date 2025-12-12 + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +// ============================================================================= +// CONFIGURACION +// ============================================================================= + +const BACKEND_CONSTANTS_DIR = path.resolve(__dirname, '../src/shared/constants'); +const FRONTEND_CONSTANTS_DIR = path.resolve(__dirname, '../../frontend/web/src/shared/constants'); + +// Archivos a sincronizar +const FILES_TO_SYNC = [ + 'enums.constants.ts', + 'api.constants.ts', +]; + +// Header para archivos generados +const GENERATED_HEADER = `/** + * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY + * + * Este archivo es generado automaticamente desde el backend. + * Cualquier cambio sera sobreescrito en la proxima sincronizacion. + * + * Fuente: backend/src/shared/constants/ + * Generado: ${new Date().toISOString()} + * + * Para modificar, edita el archivo fuente en el backend + * y ejecuta: npm run sync:enums + */ + +`; + +// ============================================================================= +// FUNCIONES +// ============================================================================= + +function ensureDirectoryExists(dir: string): void { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + console.log(`📁 Created directory: ${dir}`); + } +} + +function processContent(content: string): string { + // Remover imports que no aplican al frontend + let processed = content + // Remover imports de Node.js + .replace(/import\s+\*\s+as\s+\w+\s+from\s+['"]fs['"];?\n?/g, '') + .replace(/import\s+\*\s+as\s+\w+\s+from\s+['"]path['"];?\n?/g, '') + // Remover comentarios de @module backend + .replace(/@module\s+@shared\/constants\//g, '@module shared/constants/') + // Mantener 'as const' para inferencia de tipos + ; + + return GENERATED_HEADER + processed; +} + +function syncFile(filename: string): void { + const sourcePath = path.join(BACKEND_CONSTANTS_DIR, filename); + const destPath = path.join(FRONTEND_CONSTANTS_DIR, filename); + + if (!fs.existsSync(sourcePath)) { + console.log(`⚠️ Source file not found: ${sourcePath}`); + return; + } + + const content = fs.readFileSync(sourcePath, 'utf-8'); + const processedContent = processContent(content); + + fs.writeFileSync(destPath, processedContent); + console.log(`✅ Synced: ${filename}`); +} + +function generateIndexFile(): void { + const indexContent = `${GENERATED_HEADER} +// Re-export all constants +export * from './enums.constants'; +export * from './api.constants'; +`; + + const indexPath = path.join(FRONTEND_CONSTANTS_DIR, 'index.ts'); + fs.writeFileSync(indexPath, indexContent); + console.log(`✅ Generated: index.ts`); +} + +function main(): void { + console.log('🔄 Syncing constants from Backend to Frontend...\n'); + console.log(`Source: ${BACKEND_CONSTANTS_DIR}`); + console.log(`Target: ${FRONTEND_CONSTANTS_DIR}\n`); + + // Asegurar que el directorio destino existe + ensureDirectoryExists(FRONTEND_CONSTANTS_DIR); + + // Sincronizar cada archivo + for (const file of FILES_TO_SYNC) { + syncFile(file); + } + + // Generar archivo index + generateIndexFile(); + + console.log('\n✅ Sync completed successfully!'); + console.log('\nRecuerda importar las constantes desde:'); + console.log(' import { ROLES, PROJECT_STATUS, API_ROUTES } from "@/shared/constants";'); +} + +main(); diff --git a/scripts/validate-constants-usage.ts b/scripts/validate-constants-usage.ts new file mode 100644 index 0000000..cabf451 --- /dev/null +++ b/scripts/validate-constants-usage.ts @@ -0,0 +1,385 @@ +#!/usr/bin/env ts-node +/** + * Validate Constants Usage - SSOT Enforcement + * + * Este script detecta hardcoding de schemas, tablas, rutas API y enums + * que deberian estar usando las constantes centralizadas del SSOT. + * + * Ejecutar: npm run validate:constants + * + * @author Architecture-Analyst + * @date 2025-12-12 + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +// ============================================================================= +// CONFIGURACION +// ============================================================================= + +interface ValidationPattern { + pattern: RegExp; + message: string; + severity: 'P0' | 'P1' | 'P2'; + suggestion: string; + exclude?: RegExp[]; +} + +const PATTERNS: ValidationPattern[] = [ + // Database Schemas + { + pattern: /['"`]auth['"`](?!\s*:)/g, + message: 'Hardcoded schema "auth"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.AUTH', + exclude: [/from\s+['"`]\.\/database\.constants['"`]/], + }, + { + pattern: /['"`]construction['"`](?!\s*:)/g, + message: 'Hardcoded schema "construction"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.CONSTRUCTION', + }, + { + pattern: /['"`]hr['"`](?!\s*:)(?!\.entity)/g, + message: 'Hardcoded schema "hr"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.HR', + }, + { + pattern: /['"`]hse['"`](?!\s*:)(?!\/)/g, + message: 'Hardcoded schema "hse"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.HSE', + }, + { + pattern: /['"`]estimates['"`](?!\s*:)/g, + message: 'Hardcoded schema "estimates"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.ESTIMATES', + }, + { + pattern: /['"`]infonavit['"`](?!\s*:)/g, + message: 'Hardcoded schema "infonavit"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.INFONAVIT', + }, + { + pattern: /['"`]inventory['"`](?!\s*:)/g, + message: 'Hardcoded schema "inventory"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.INVENTORY', + }, + { + pattern: /['"`]purchase['"`](?!\s*:)/g, + message: 'Hardcoded schema "purchase"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.PURCHASE', + }, + + // API Routes + { + pattern: /['"`]\/api\/v1\/proyectos['"`]/g, + message: 'Hardcoded API route "/api/v1/proyectos"', + severity: 'P0', + suggestion: 'Usa API_ROUTES.PROYECTOS.BASE', + }, + { + pattern: /['"`]\/api\/v1\/fraccionamientos['"`]/g, + message: 'Hardcoded API route "/api/v1/fraccionamientos"', + severity: 'P0', + suggestion: 'Usa API_ROUTES.FRACCIONAMIENTOS.BASE', + }, + { + pattern: /['"`]\/api\/v1\/employees['"`]/g, + message: 'Hardcoded API route "/api/v1/employees"', + severity: 'P0', + suggestion: 'Usa API_ROUTES.EMPLOYEES.BASE', + }, + { + pattern: /['"`]\/api\/v1\/incidentes['"`]/g, + message: 'Hardcoded API route "/api/v1/incidentes"', + severity: 'P0', + suggestion: 'Usa API_ROUTES.INCIDENTES.BASE', + }, + + // Common Table Names + { + pattern: /FROM\s+proyectos(?!\s+AS|\s+WHERE)/gi, + message: 'Hardcoded table name "proyectos"', + severity: 'P1', + suggestion: 'Usa DB_TABLES.CONSTRUCTION.PROYECTOS', + }, + { + pattern: /FROM\s+fraccionamientos(?!\s+AS|\s+WHERE)/gi, + message: 'Hardcoded table name "fraccionamientos"', + severity: 'P1', + suggestion: 'Usa DB_TABLES.CONSTRUCTION.FRACCIONAMIENTOS', + }, + { + pattern: /FROM\s+employees(?!\s+AS|\s+WHERE)/gi, + message: 'Hardcoded table name "employees"', + severity: 'P1', + suggestion: 'Usa DB_TABLES.HR.EMPLOYEES', + }, + { + pattern: /FROM\s+incidentes(?!\s+AS|\s+WHERE)/gi, + message: 'Hardcoded table name "incidentes"', + severity: 'P1', + suggestion: 'Usa DB_TABLES.HSE.INCIDENTES', + }, + + // Status Values + { + pattern: /status\s*===?\s*['"`]active['"`]/gi, + message: 'Hardcoded status "active"', + severity: 'P1', + suggestion: 'Usa PROJECT_STATUS.ACTIVE o USER_STATUS.ACTIVE', + }, + { + pattern: /status\s*===?\s*['"`]borrador['"`]/gi, + message: 'Hardcoded status "borrador"', + severity: 'P1', + suggestion: 'Usa BUDGET_STATUS.DRAFT o ESTIMATION_STATUS.DRAFT', + }, + { + pattern: /status\s*===?\s*['"`]aprobado['"`]/gi, + message: 'Hardcoded status "aprobado"', + severity: 'P1', + suggestion: 'Usa BUDGET_STATUS.APPROVED o ESTIMATION_STATUS.APPROVED', + }, + + // Role Names + { + pattern: /role\s*===?\s*['"`]admin['"`]/gi, + message: 'Hardcoded role "admin"', + severity: 'P0', + suggestion: 'Usa ROLES.ADMIN', + }, + { + pattern: /role\s*===?\s*['"`]supervisor['"`]/gi, + message: 'Hardcoded role "supervisor"', + severity: 'P1', + suggestion: 'Usa ROLES.SUPERVISOR_OBRA o ROLES.SUPERVISOR_HSE', + }, +]; + +// Archivos a excluir +const EXCLUDED_PATHS = [ + 'node_modules', + 'dist', + '.git', + 'coverage', + 'database.constants.ts', + 'api.constants.ts', + 'enums.constants.ts', + 'index.ts', + '.sql', + '.md', + '.json', + '.yml', + '.yaml', +]; + +// Extensiones a validar +const VALID_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']; + +// ============================================================================= +// TIPOS +// ============================================================================= + +interface Violation { + file: string; + line: number; + column: number; + pattern: string; + message: string; + severity: 'P0' | 'P1' | 'P2'; + suggestion: string; + context: string; +} + +// ============================================================================= +// FUNCIONES +// ============================================================================= + +function shouldExclude(filePath: string): boolean { + return EXCLUDED_PATHS.some(excluded => filePath.includes(excluded)); +} + +function hasValidExtension(filePath: string): boolean { + return VALID_EXTENSIONS.some(ext => filePath.endsWith(ext)); +} + +function getFiles(dir: string): string[] { + const files: string[] = []; + + if (!fs.existsSync(dir)) { + return files; + } + + const items = fs.readdirSync(dir); + + for (const item of items) { + const fullPath = path.join(dir, item); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + if (!shouldExclude(fullPath)) { + files.push(...getFiles(fullPath)); + } + } else if (stat.isFile() && hasValidExtension(fullPath) && !shouldExclude(fullPath)) { + files.push(fullPath); + } + } + + return files; +} + +function findViolations(filePath: string, content: string, patterns: ValidationPattern[]): Violation[] { + const violations: Violation[] = []; + const lines = content.split('\n'); + + for (const patternConfig of patterns) { + let match: RegExpExecArray | null; + const regex = new RegExp(patternConfig.pattern.source, patternConfig.pattern.flags); + + while ((match = regex.exec(content)) !== null) { + // Check exclusions + if (patternConfig.exclude) { + const shouldSkip = patternConfig.exclude.some(excludePattern => + excludePattern.test(content) + ); + if (shouldSkip) continue; + } + + // Find line number + const beforeMatch = content.substring(0, match.index); + const lineNumber = beforeMatch.split('\n').length; + const lineStart = beforeMatch.lastIndexOf('\n') + 1; + const column = match.index - lineStart + 1; + + violations.push({ + file: filePath, + line: lineNumber, + column, + pattern: match[0], + message: patternConfig.message, + severity: patternConfig.severity, + suggestion: patternConfig.suggestion, + context: lines[lineNumber - 1]?.trim() || '', + }); + } + } + + return violations; +} + +function formatViolation(v: Violation): string { + const severityColor = { + P0: '\x1b[31m', // Red + P1: '\x1b[33m', // Yellow + P2: '\x1b[36m', // Cyan + }; + const reset = '\x1b[0m'; + + return ` +${severityColor[v.severity]}[${v.severity}]${reset} ${v.message} + File: ${v.file}:${v.line}:${v.column} + Found: "${v.pattern}" + Context: ${v.context} + Suggestion: ${v.suggestion} +`; +} + +function generateReport(violations: Violation[]): void { + const p0 = violations.filter(v => v.severity === 'P0'); + const p1 = violations.filter(v => v.severity === 'P1'); + const p2 = violations.filter(v => v.severity === 'P2'); + + console.log('\n========================================'); + console.log('SSOT VALIDATION REPORT'); + console.log('========================================\n'); + + console.log(`Total Violations: ${violations.length}`); + console.log(` P0 (Critical): ${p0.length}`); + console.log(` P1 (High): ${p1.length}`); + console.log(` P2 (Medium): ${p2.length}`); + + if (violations.length > 0) { + console.log('\n----------------------------------------'); + console.log('VIOLATIONS FOUND:'); + console.log('----------------------------------------'); + + // Group by file + const byFile = violations.reduce((acc, v) => { + if (!acc[v.file]) acc[v.file] = []; + acc[v.file].push(v); + return acc; + }, {} as Record); + + for (const [file, fileViolations] of Object.entries(byFile)) { + console.log(`\n📁 ${file}`); + for (const v of fileViolations) { + console.log(formatViolation(v)); + } + } + } + + console.log('\n========================================'); + + if (p0.length > 0) { + console.log('\n❌ FAILED: P0 violations found. Fix before merging.\n'); + process.exit(1); + } else if (violations.length > 0) { + console.log('\n⚠️ WARNING: Non-critical violations found. Consider fixing.\n'); + process.exit(0); + } else { + console.log('\n✅ PASSED: No SSOT violations found!\n'); + process.exit(0); + } +} + +// ============================================================================= +// MAIN +// ============================================================================= + +function main(): void { + const backendDir = path.resolve(__dirname, '../src'); + const frontendDir = path.resolve(__dirname, '../../frontend/web/src'); + + console.log('🔍 Validating SSOT constants usage...\n'); + console.log(`Backend: ${backendDir}`); + console.log(`Frontend: ${frontendDir}`); + + const allViolations: Violation[] = []; + + // Scan backend + if (fs.existsSync(backendDir)) { + const backendFiles = getFiles(backendDir); + console.log(`\nScanning ${backendFiles.length} backend files...`); + + for (const file of backendFiles) { + const content = fs.readFileSync(file, 'utf-8'); + const violations = findViolations(file, content, PATTERNS); + allViolations.push(...violations); + } + } + + // Scan frontend + if (fs.existsSync(frontendDir)) { + const frontendFiles = getFiles(frontendDir); + console.log(`Scanning ${frontendFiles.length} frontend files...`); + + for (const file of frontendFiles) { + const content = fs.readFileSync(file, 'utf-8'); + const violations = findViolations(file, content, PATTERNS); + allViolations.push(...violations); + } + } + + generateReport(allViolations); +} + +main(); diff --git a/service.descriptor.yml b/service.descriptor.yml new file mode 100644 index 0000000..f5820d8 --- /dev/null +++ b/service.descriptor.yml @@ -0,0 +1,169 @@ +# ============================================================================== +# SERVICE DESCRIPTOR - ERP CONSTRUCCION API +# ============================================================================== +# API especializada para construccion residencial +# Mantenido por: Backend-Agent +# Actualizado: 2025-12-18 +# ============================================================================== + +version: "1.0.0" + +# ------------------------------------------------------------------------------ +# IDENTIFICACION DEL SERVICIO +# ------------------------------------------------------------------------------ +service: + name: "erp-construccion-api" + display_name: "ERP Construccion API" + description: "API especializada para gestion de construccion residencial" + type: "backend" + runtime: "node" + framework: "express" + owner_agent: "NEXUS-BACKEND" + +# ------------------------------------------------------------------------------ +# CONFIGURACION DE PUERTOS +# ------------------------------------------------------------------------------ +ports: + internal: 3020 + registry_ref: "projects.erp_suite.verticales.construccion.api" + protocol: "http" + +# ------------------------------------------------------------------------------ +# CONFIGURACION DE BASE DE DATOS +# ------------------------------------------------------------------------------ +database: + registry_ref: "erp_construccion" + schemas: + - "construction" + - "budgets" + - "estimates" + - "progress" + - "hr" + - "hse" + - "inventory" + - "purchase" + role: "runtime" + +# ------------------------------------------------------------------------------ +# DEPENDENCIAS +# ------------------------------------------------------------------------------ +dependencies: + services: + - name: "erp-core-api" + type: "backend" + required: true + description: "Core ERP para auth y catalogos" + - name: "postgres" + type: "database" + required: true + +# ------------------------------------------------------------------------------ +# MODULOS ESPECIALIZADOS +# ------------------------------------------------------------------------------ +modules: + construction: + description: "Gestion de construccion" + entities: + - fraccionamiento + - etapa + - manzana + - lote + - prototipo + endpoints: + - { path: "/fraccionamientos", methods: ["GET", "POST"] } + - { path: "/etapas", methods: ["GET", "POST"] } + - { path: "/manzanas", methods: ["GET", "POST"] } + - { path: "/lotes", methods: ["GET", "POST"] } + + budgets: + description: "Presupuestos de obra" + entities: + - presupuesto + - partida + - concepto + endpoints: + - { path: "/presupuestos", methods: ["GET", "POST"] } + + estimates: + description: "Estimaciones" + entities: + - estimacion + - detalle_estimacion + endpoints: + - { path: "/estimaciones", methods: ["GET", "POST"] } + + progress: + description: "Avance de obra" + entities: + - avance_obra + - bitacora + endpoints: + - { path: "/avance-obra", methods: ["GET", "POST"] } + + hr: + description: "Recursos humanos" + entities: + - empleado + - nomina + - asistencia + + hse: + description: "Seguridad e higiene" + entities: + - incidente + - capacitacion + + inventory: + description: "Inventario de materiales" + entities: + - material + - movimiento_inventario + + purchase: + description: "Compras" + entities: + - orden_compra + - proveedor + +# ------------------------------------------------------------------------------ +# DOCKER +# ------------------------------------------------------------------------------ +docker: + image: "erp-construccion-api" + dockerfile: "Dockerfile" + networks: + - "erp_construccion_${ENV:-local}" + - "erp_core_${ENV:-local}" + - "infra_shared" + labels: + traefik: + enable: true + router: "erp-construccion-api" + rule: "Host(`api.construccion.erp.localhost`)" + +# ------------------------------------------------------------------------------ +# HEALTH CHECK +# ------------------------------------------------------------------------------ +healthcheck: + endpoint: "/health" + interval: "30s" + timeout: "5s" + retries: 3 + +# ------------------------------------------------------------------------------ +# ESTADO +# ------------------------------------------------------------------------------ +status: + phase: "development" + version: "0.1.0" + completeness: 40 + +# ------------------------------------------------------------------------------ +# METADATA +# ------------------------------------------------------------------------------ +metadata: + created_at: "2025-12-18" + created_by: "Backend-Agent" + project: "erp-suite" + vertical: "construccion" + team: "construccion-team" diff --git a/src/modules/admin/controllers/audit-log.controller.ts b/src/modules/admin/controllers/audit-log.controller.ts new file mode 100644 index 0000000..13a4b3a --- /dev/null +++ b/src/modules/admin/controllers/audit-log.controller.ts @@ -0,0 +1,229 @@ +/** + * AuditLogController - Controller de Logs de Auditoría + * + * Endpoints REST para consulta de logs de auditoría. + * + * @module Admin + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AuditLogService, AuditLogFilters } from '../services/audit-log.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { AuditLog } from '../entities/audit-log.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createAuditLogController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const auditLogRepository = dataSource.getRepository(AuditLog); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const auditLogService = new AuditLogService(auditLogRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /audit-logs + */ + router.get('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + + const filters: AuditLogFilters = {}; + if (req.query.userId) filters.userId = req.query.userId as string; + if (req.query.category) filters.category = req.query.category as any; + if (req.query.action) filters.action = req.query.action as any; + if (req.query.severity) filters.severity = req.query.severity as any; + if (req.query.entityType) filters.entityType = req.query.entityType as string; + if (req.query.entityId) filters.entityId = req.query.entityId as string; + if (req.query.module) filters.module = req.query.module as string; + if (req.query.isSuccess !== undefined) filters.isSuccess = req.query.isSuccess === 'true'; + if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string); + if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string); + if (req.query.ipAddress) filters.ipAddress = req.query.ipAddress as string; + if (req.query.search) filters.search = req.query.search as string; + + const result = await auditLogService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /audit-logs/stats + */ + router.get('/stats', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const days = parseInt(req.query.days as string) || 30; + const stats = await auditLogService.getStats(getContext(req), days); + + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /audit-logs/critical + */ + router.get('/critical', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const days = parseInt(req.query.days as string) || 7; + const limit = Math.min(parseInt(req.query.limit as string) || 100, 500); + + const logs = await auditLogService.getCriticalLogs(getContext(req), days, limit); + res.status(200).json({ success: true, data: logs }); + } catch (error) { + next(error); + } + }); + + /** + * GET /audit-logs/failed + */ + router.get('/failed', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const hours = parseInt(req.query.hours as string) || 24; + const limit = Math.min(parseInt(req.query.limit as string) || 100, 500); + + const logs = await auditLogService.getFailedLogs(getContext(req), hours, limit); + res.status(200).json({ success: true, data: logs }); + } catch (error) { + next(error); + } + }); + + /** + * GET /audit-logs/entity/:type/:id + */ + router.get('/entity/:type/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await auditLogService.findByEntity( + getContext(req), + req.params.type, + req.params.id, + page, + limit + ); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /audit-logs/user/:userId + */ + router.get('/user/:userId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'auditor'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + + const result = await auditLogService.findByUser( + getContext(req), + req.params.userId, + page, + limit + ); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /audit-logs/cleanup + * Cleanup expired logs + */ + router.post('/cleanup', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await auditLogService.cleanupExpiredLogs(getContext(req)); + res.status(200).json({ + success: true, + message: `Cleaned up ${deleted} expired audit logs`, + data: { deleted }, + }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createAuditLogController; diff --git a/src/modules/admin/controllers/backup.controller.ts b/src/modules/admin/controllers/backup.controller.ts new file mode 100644 index 0000000..826253c --- /dev/null +++ b/src/modules/admin/controllers/backup.controller.ts @@ -0,0 +1,283 @@ +/** + * BackupController - Controller de Backups + * + * Endpoints REST para gestión de backups del sistema. + * + * @module Admin + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { BackupService, CreateBackupDto, BackupFilters } from '../services/backup.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Backup } from '../entities/backup.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createBackupController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const backupRepository = dataSource.getRepository(Backup); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const backupService = new BackupService(backupRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /backups + */ + router.get('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: BackupFilters = {}; + if (req.query.backupType) filters.backupType = req.query.backupType as any; + if (req.query.status) filters.status = req.query.status as any; + if (req.query.storageLocation) filters.storageLocation = req.query.storageLocation as any; + if (req.query.isScheduled !== undefined) filters.isScheduled = req.query.isScheduled === 'true'; + if (req.query.isVerified !== undefined) filters.isVerified = req.query.isVerified === 'true'; + if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string); + if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string); + + const result = await backupService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /backups/stats + */ + router.get('/stats', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await backupService.getStats(getContext(req)); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /backups/last + */ + router.get('/last', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const backupType = req.query.backupType as any; + const backup = await backupService.getLastSuccessful(getContext(req), backupType); + + if (!backup) { + res.status(404).json({ error: 'Not Found', message: 'No successful backup found' }); + return; + } + + res.status(200).json({ success: true, data: backup }); + } catch (error) { + next(error); + } + }); + + /** + * GET /backups/pending-verification + */ + router.get('/pending-verification', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const backups = await backupService.getPendingVerification(getContext(req)); + res.status(200).json({ success: true, data: backups }); + } catch (error) { + next(error); + } + }); + + /** + * GET /backups/:id + */ + router.get('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const backup = await backupService.findById(getContext(req), req.params.id); + if (!backup) { + res.status(404).json({ error: 'Not Found', message: 'Backup not found' }); + return; + } + + res.status(200).json({ success: true, data: backup }); + } catch (error) { + next(error); + } + }); + + /** + * POST /backups + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateBackupDto = req.body; + if (!dto.backupType || !dto.name) { + res.status(400).json({ + error: 'Bad Request', + message: 'backupType and name are required', + }); + return; + } + + const backup = await backupService.initiateBackup(getContext(req), dto); + res.status(201).json({ success: true, data: backup }); + } catch (error) { + next(error); + } + }); + + /** + * POST /backups/:id/verify + */ + router.post('/:id/verify', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const backup = await backupService.markVerified(getContext(req), req.params.id); + if (!backup) { + res.status(404).json({ error: 'Not Found', message: 'Backup not found' }); + return; + } + + res.status(200).json({ success: true, data: backup }); + } catch (error) { + if (error instanceof Error && error.message.includes('Only completed')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /backups/:id/cancel + */ + router.post('/:id/cancel', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const backup = await backupService.cancelBackup(getContext(req), req.params.id); + if (!backup) { + res.status(404).json({ error: 'Not Found', message: 'Backup not found' }); + return; + } + + res.status(200).json({ success: true, data: backup }); + } catch (error) { + if (error instanceof Error && error.message.includes('Only pending')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /backups/cleanup + */ + router.post('/cleanup', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const expired = await backupService.cleanupExpired(getContext(req)); + res.status(200).json({ + success: true, + message: `Marked ${expired} backups as expired`, + data: { expired }, + }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /backups/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await backupService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Backup not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Backup deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createBackupController; diff --git a/src/modules/admin/controllers/cost-center.controller.ts b/src/modules/admin/controllers/cost-center.controller.ts new file mode 100644 index 0000000..c72f94d --- /dev/null +++ b/src/modules/admin/controllers/cost-center.controller.ts @@ -0,0 +1,280 @@ +/** + * CostCenterController - Controller de Centros de Costo + * + * Endpoints REST para gestión de centros de costo jerárquicos. + * + * @module Admin + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { CostCenterService, CreateCostCenterDto, UpdateCostCenterDto, CostCenterFilters } from '../services/cost-center.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { CostCenter } from '../entities/cost-center.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createCostCenterController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const costCenterRepository = dataSource.getRepository(CostCenter); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const costCenterService = new CostCenterService(costCenterRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /cost-centers + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: CostCenterFilters = {}; + if (req.query.costCenterType) filters.costCenterType = req.query.costCenterType as any; + if (req.query.level) filters.level = req.query.level as any; + if (req.query.fraccionamientoId) filters.fraccionamientoId = req.query.fraccionamientoId as string; + if (req.query.parentId) filters.parentId = req.query.parentId as string; + if (req.query.responsibleId) filters.responsibleId = req.query.responsibleId as string; + if (req.query.isActive !== undefined) filters.isActive = req.query.isActive === 'true'; + if (req.query.search) filters.search = req.query.search as string; + + const result = await costCenterService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /cost-centers/tree + */ + router.get('/tree', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string | undefined; + const tree = await costCenterService.getTree(getContext(req), fraccionamientoId); + + res.status(200).json({ success: true, data: tree }); + } catch (error) { + next(error); + } + }); + + /** + * GET /cost-centers/stats + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await costCenterService.getStats(getContext(req)); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /cost-centers/budget-summary + */ + router.get('/budget-summary', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string | undefined; + const summary = await costCenterService.getBudgetSummary(getContext(req), fraccionamientoId); + + res.status(200).json({ success: true, data: summary }); + } catch (error) { + next(error); + } + }); + + /** + * GET /cost-centers/code/:code + */ + router.get('/code/:code', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const cc = await costCenterService.findByCode(getContext(req), req.params.code); + if (!cc) { + res.status(404).json({ error: 'Not Found', message: 'Cost center not found' }); + return; + } + + res.status(200).json({ success: true, data: cc }); + } catch (error) { + next(error); + } + }); + + /** + * GET /cost-centers/:id + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const cc = await costCenterService.findById(getContext(req), req.params.id); + if (!cc) { + res.status(404).json({ error: 'Not Found', message: 'Cost center not found' }); + return; + } + + res.status(200).json({ success: true, data: cc }); + } catch (error) { + next(error); + } + }); + + /** + * GET /cost-centers/:id/children + */ + router.get('/:id/children', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const children = await costCenterService.getChildren(getContext(req), req.params.id); + res.status(200).json({ success: true, data: children }); + } catch (error) { + next(error); + } + }); + + /** + * POST /cost-centers + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'finance'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateCostCenterDto = req.body; + if (!dto.code || !dto.name) { + res.status(400).json({ + error: 'Bad Request', + message: 'code and name are required', + }); + return; + } + + const cc = await costCenterService.createCostCenter(getContext(req), dto); + res.status(201).json({ success: true, data: cc }); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + if (error.message.includes('not found')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * PUT /cost-centers/:id + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'finance'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateCostCenterDto = req.body; + const cc = await costCenterService.update(getContext(req), req.params.id, dto); + + if (!cc) { + res.status(404).json({ error: 'Not Found', message: 'Cost center not found' }); + return; + } + + res.status(200).json({ success: true, data: cc }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /cost-centers/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await costCenterService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Cost center not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Cost center deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createCostCenterController; diff --git a/src/modules/admin/controllers/index.ts b/src/modules/admin/controllers/index.ts new file mode 100644 index 0000000..bbd4d41 --- /dev/null +++ b/src/modules/admin/controllers/index.ts @@ -0,0 +1,9 @@ +/** + * Admin Controllers Index + * @module Admin + */ + +export { createCostCenterController } from './cost-center.controller'; +export { createAuditLogController } from './audit-log.controller'; +export { createSystemSettingController } from './system-setting.controller'; +export { createBackupController } from './backup.controller'; diff --git a/src/modules/admin/controllers/system-setting.controller.ts b/src/modules/admin/controllers/system-setting.controller.ts new file mode 100644 index 0000000..1638d68 --- /dev/null +++ b/src/modules/admin/controllers/system-setting.controller.ts @@ -0,0 +1,370 @@ +/** + * SystemSettingController - Controller de Configuración del Sistema + * + * Endpoints REST para gestión de configuraciones. + * + * @module Admin + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { SystemSettingService, CreateSettingDto, UpdateSettingDto, SettingFilters } from '../services/system-setting.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { SystemSetting } from '../entities/system-setting.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createSystemSettingController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const settingRepository = dataSource.getRepository(SystemSetting); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const settingService = new SystemSettingService(settingRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /settings + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + + const filters: SettingFilters = {}; + if (req.query.category) filters.category = req.query.category as any; + if (req.query.isPublic !== undefined) filters.isPublic = req.query.isPublic === 'true'; + if (req.query.search) filters.search = req.query.search as string; + + const result = await settingService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/public + */ + router.get('/public', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const settings = await settingService.getPublicSettings(getContext(req)); + res.status(200).json({ success: true, data: settings }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/stats + */ + router.get('/stats', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await settingService.getStats(getContext(req)); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/category/:category + */ + router.get('/category/:category', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const settings = await settingService.findByCategory(getContext(req), req.params.category as any); + res.status(200).json({ success: true, data: settings }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/key/:key + */ + router.get('/key/:key', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const setting = await settingService.findByKey(getContext(req), req.params.key); + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'Setting not found' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/key/:key/value + */ + router.get('/key/:key/value', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const value = await settingService.getValue(getContext(req), req.params.key); + if (value === null) { + res.status(404).json({ error: 'Not Found', message: 'Setting not found' }); + return; + } + + res.status(200).json({ success: true, data: { key: req.params.key, value } }); + } catch (error) { + next(error); + } + }); + + /** + * GET /settings/:id + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const setting = await settingService.findById(getContext(req), req.params.id); + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'Setting not found' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + next(error); + } + }); + + /** + * POST /settings + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateSettingDto = req.body; + if (!dto.key || !dto.name || dto.value === undefined) { + res.status(400).json({ + error: 'Bad Request', + message: 'key, name, and value are required', + }); + return; + } + + const setting = await settingService.createSetting(getContext(req), dto); + res.status(201).json({ success: true, data: setting }); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + if (error.message.includes('must be') || error.message.includes('pattern')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * PUT /settings/:id + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateSettingDto = req.body; + const setting = await settingService.update(getContext(req), req.params.id, dto); + + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'Setting not found' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /settings/key/:key/value + */ + router.put('/key/:key/value', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { value } = req.body; + if (value === undefined) { + res.status(400).json({ error: 'Bad Request', message: 'value is required' }); + return; + } + + const setting = await settingService.updateValue(getContext(req), req.params.key, value); + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'Setting not found' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + if (error instanceof Error && (error.message.includes('must be') || error.message.includes('pattern'))) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /settings/key/:key/reset + */ + router.post('/key/:key/reset', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const setting = await settingService.resetToDefault(getContext(req), req.params.key); + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'Setting not found or no default value' }); + return; + } + + res.status(200).json({ success: true, data: setting }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /settings/bulk + */ + router.put('/bulk', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { settings } = req.body; + if (!Array.isArray(settings)) { + res.status(400).json({ error: 'Bad Request', message: 'settings array is required' }); + return; + } + + const result = await settingService.updateMultiple(getContext(req), settings); + res.status(200).json({ + success: true, + data: result, + message: `Updated ${result.updated} settings`, + }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /settings/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + // Check if system setting + const setting = await settingService.findById(getContext(req), req.params.id); + if (!setting) { + res.status(404).json({ error: 'Not Found', message: 'Setting not found' }); + return; + } + + if (setting.isSystem) { + res.status(403).json({ error: 'Forbidden', message: 'Cannot delete system settings' }); + return; + } + + const deleted = await settingService.hardDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Setting not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Setting deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createSystemSettingController; diff --git a/src/modules/admin/entities/audit-log.entity.ts b/src/modules/admin/entities/audit-log.entity.ts new file mode 100644 index 0000000..6bbfc65 --- /dev/null +++ b/src/modules/admin/entities/audit-log.entity.ts @@ -0,0 +1,256 @@ +/** + * AuditLog Entity + * Registro de auditoría para trazabilidad de operaciones + * + * @module Admin + * @table admin.audit_logs + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +export type AuditCategory = + | 'authentication' + | 'user_management' + | 'critical_operation' + | 'administration' + | 'data_access' + | 'system'; + +export type AuditAction = + | 'login' + | 'logout' + | 'login_failed' + | 'password_change' + | 'password_reset' + | 'create' + | 'read' + | 'update' + | 'delete' + | 'approve' + | 'reject' + | 'export' + | 'import' + | 'configure' + | 'backup' + | 'restore'; + +export type AuditSeverity = 'low' | 'medium' | 'high' | 'critical'; + +@Entity({ schema: 'admin', name: 'audit_logs' }) +@Index(['tenantId']) +@Index(['userId']) +@Index(['category']) +@Index(['action']) +@Index(['entityType', 'entityId']) +@Index(['createdAt']) +@Index(['severity']) +@Index(['ipAddress']) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string | null; + + @Column({ + type: 'varchar', + length: 30, + }) + category: AuditCategory; + + @Column({ + type: 'varchar', + length: 30, + }) + action: AuditAction; + + @Column({ + type: 'varchar', + length: 20, + default: 'medium', + }) + severity: AuditSeverity; + + @Column({ + name: 'entity_type', + type: 'varchar', + length: 100, + nullable: true, + comment: 'Type of entity affected (e.g., User, Estimacion)', + }) + entityType: string | null; + + @Column({ + name: 'entity_id', + type: 'uuid', + nullable: true, + comment: 'ID of the affected entity', + }) + entityId: string | null; + + @Column({ + name: 'entity_name', + type: 'varchar', + length: 200, + nullable: true, + comment: 'Human-readable name of the entity', + }) + entityName: string | null; + + @Column({ + type: 'text', + comment: 'Human-readable description of the action', + }) + description: string; + + @Column({ + name: 'old_values', + type: 'jsonb', + nullable: true, + comment: 'Previous values before the change', + }) + oldValues: Record | null; + + @Column({ + name: 'new_values', + type: 'jsonb', + nullable: true, + comment: 'New values after the change', + }) + newValues: Record | null; + + @Column({ + name: 'changed_fields', + type: 'varchar', + array: true, + nullable: true, + comment: 'List of fields that were changed', + }) + changedFields: string[] | null; + + @Column({ + name: 'ip_address', + type: 'varchar', + length: 45, + nullable: true, + }) + ipAddress: string | null; + + @Column({ + name: 'user_agent', + type: 'varchar', + length: 500, + nullable: true, + }) + userAgent: string | null; + + @Column({ + name: 'request_id', + type: 'varchar', + length: 100, + nullable: true, + comment: 'Request/correlation ID for tracing', + }) + requestId: string | null; + + @Column({ + name: 'session_id', + type: 'varchar', + length: 100, + nullable: true, + }) + sessionId: string | null; + + @Column({ + type: 'varchar', + length: 100, + nullable: true, + comment: 'Module where action occurred', + }) + module: string | null; + + @Column({ + type: 'varchar', + length: 200, + nullable: true, + comment: 'API endpoint or route', + }) + endpoint: string | null; + + @Column({ + name: 'http_method', + type: 'varchar', + length: 10, + nullable: true, + }) + httpMethod: string | null; + + @Column({ + name: 'response_status', + type: 'integer', + nullable: true, + }) + responseStatus: number | null; + + @Column({ + name: 'duration_ms', + type: 'integer', + nullable: true, + comment: 'Request duration in milliseconds', + }) + durationMs: number | null; + + @Column({ + name: 'is_success', + type: 'boolean', + default: true, + }) + isSuccess: boolean; + + @Column({ + name: 'error_message', + type: 'text', + nullable: true, + }) + errorMessage: string | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Additional context data', + }) + metadata: Record | null; + + @Column({ + name: 'retention_days', + type: 'integer', + default: 90, + comment: 'Days to retain this log (90 for operational, 1825 for critical)', + }) + retentionDays: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user: User | null; +} diff --git a/src/modules/admin/entities/backup.entity.ts b/src/modules/admin/entities/backup.entity.ts new file mode 100644 index 0000000..343721a --- /dev/null +++ b/src/modules/admin/entities/backup.entity.ts @@ -0,0 +1,301 @@ +/** + * Backup Entity + * Registro de backups del sistema + * + * @module Admin + * @table admin.backups + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +export type BackupType = 'full' | 'incremental' | 'differential' | 'files' | 'snapshot'; + +export type BackupStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'expired'; + +export type BackupStorage = 'local' | 's3' | 'gcs' | 'azure' | 'offsite'; + +@Entity({ schema: 'admin', name: 'backups' }) +@Index(['tenantId']) +@Index(['backupType']) +@Index(['status']) +@Index(['createdAt']) +@Index(['expiresAt']) +export class Backup { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ + name: 'backup_type', + type: 'varchar', + length: 20, + }) + backupType: BackupType; + + @Column({ + type: 'varchar', + length: 20, + default: 'pending', + }) + status: BackupStatus; + + @Column({ + type: 'varchar', + length: 200, + comment: 'Descriptive name for the backup', + }) + name: string; + + @Column({ + type: 'text', + nullable: true, + }) + description: string | null; + + @Column({ + name: 'file_path', + type: 'varchar', + length: 500, + nullable: true, + }) + filePath: string | null; + + @Column({ + name: 'file_name', + type: 'varchar', + length: 200, + nullable: true, + }) + fileName: string | null; + + @Column({ + name: 'file_size', + type: 'bigint', + nullable: true, + comment: 'Size in bytes', + }) + fileSize: number | null; + + @Column({ + name: 'storage_location', + type: 'varchar', + length: 20, + default: 'local', + }) + storageLocation: BackupStorage; + + @Column({ + name: 'storage_url', + type: 'varchar', + length: 1000, + nullable: true, + comment: 'Full URL or path to backup file', + }) + storageUrl: string | null; + + @Column({ + type: 'varchar', + length: 64, + nullable: true, + comment: 'SHA-256 checksum for integrity verification', + }) + checksum: string | null; + + @Column({ + name: 'is_encrypted', + type: 'boolean', + default: true, + }) + isEncrypted: boolean; + + @Column({ + name: 'encryption_key_id', + type: 'varchar', + length: 100, + nullable: true, + comment: 'Reference to encryption key used', + }) + encryptionKeyId: string | null; + + @Column({ + name: 'is_compressed', + type: 'boolean', + default: true, + }) + isCompressed: boolean; + + @Column({ + name: 'compression_type', + type: 'varchar', + length: 20, + nullable: true, + comment: 'gzip, lz4, zstd, etc.', + }) + compressionType: string | null; + + @Column({ + name: 'database_version', + type: 'varchar', + length: 50, + nullable: true, + comment: 'PostgreSQL version at backup time', + }) + databaseVersion: string | null; + + @Column({ + name: 'app_version', + type: 'varchar', + length: 50, + nullable: true, + comment: 'Application version at backup time', + }) + appVersion: string | null; + + @Column({ + name: 'tables_included', + type: 'varchar', + array: true, + nullable: true, + comment: 'List of tables included in backup', + }) + tablesIncluded: string[] | null; + + @Column({ + name: 'tables_excluded', + type: 'varchar', + array: true, + nullable: true, + comment: 'List of tables excluded from backup', + }) + tablesExcluded: string[] | null; + + @Column({ + name: 'row_count', + type: 'integer', + nullable: true, + comment: 'Total rows backed up', + }) + rowCount: number | null; + + @Column({ + name: 'started_at', + type: 'timestamptz', + nullable: true, + }) + startedAt: Date | null; + + @Column({ + name: 'completed_at', + type: 'timestamptz', + nullable: true, + }) + completedAt: Date | null; + + @Column({ + name: 'duration_seconds', + type: 'integer', + nullable: true, + }) + durationSeconds: number | null; + + @Column({ + name: 'expires_at', + type: 'timestamptz', + nullable: true, + comment: 'When this backup will be automatically deleted', + }) + expiresAt: Date | null; + + @Column({ + name: 'retention_policy', + type: 'varchar', + length: 50, + nullable: true, + comment: 'daily, weekly, monthly, yearly, permanent', + }) + retentionPolicy: string | null; + + @Column({ + name: 'is_scheduled', + type: 'boolean', + default: false, + comment: 'Was this a scheduled backup?', + }) + isScheduled: boolean; + + @Column({ + name: 'schedule_id', + type: 'varchar', + length: 100, + nullable: true, + comment: 'Reference to schedule that triggered this backup', + }) + scheduleId: string | null; + + @Column({ + name: 'is_verified', + type: 'boolean', + default: false, + comment: 'Has restore been tested?', + }) + isVerified: boolean; + + @Column({ + name: 'verified_at', + type: 'timestamptz', + nullable: true, + }) + verifiedAt: Date | null; + + @Column({ + name: 'verified_by', + type: 'uuid', + nullable: true, + }) + verifiedById: string | null; + + @Column({ + name: 'error_message', + type: 'text', + nullable: true, + }) + errorMessage: string | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Additional backup metadata', + }) + metadata: Record | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'verified_by' }) + verifiedBy: User | null; +} diff --git a/src/modules/admin/entities/cost-center.entity.ts b/src/modules/admin/entities/cost-center.entity.ts new file mode 100644 index 0000000..eed81c0 --- /dev/null +++ b/src/modules/admin/entities/cost-center.entity.ts @@ -0,0 +1,208 @@ +/** + * CostCenter Entity + * Centros de costo jerárquicos para imputación de gastos + * + * @module Admin + * @table admin.cost_centers + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, + Tree, + TreeChildren, + TreeParent, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +export type CostCenterType = 'direct' | 'indirect' | 'shared_services' | 'overhead'; + +export type CostCenterLevel = 'company' | 'project' | 'phase' | 'front' | 'activity'; + +@Entity({ schema: 'admin', name: 'cost_centers' }) +@Tree('closure-table') +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId']) +@Index(['parentId']) +@Index(['fraccionamientoId']) +@Index(['costCenterType']) +@Index(['level']) +@Index(['isActive']) +export class CostCenter { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + name: 'cost_center_type', + type: 'varchar', + length: 30, + default: 'direct', + }) + costCenterType: CostCenterType; + + @Column({ + type: 'varchar', + length: 20, + default: 'activity', + }) + level: CostCenterLevel; + + @Column({ + name: 'parent_id', + type: 'uuid', + nullable: true, + }) + parentId: string | null; + + @Column({ + name: 'fraccionamiento_id', + type: 'uuid', + nullable: true, + comment: 'Project this cost center belongs to (null for company level)', + }) + fraccionamientoId: string | null; + + @Column({ + name: 'responsible_id', + type: 'uuid', + nullable: true, + comment: 'User responsible for this cost center', + }) + responsibleId: string | null; + + @Column({ + type: 'decimal', + precision: 16, + scale: 2, + default: 0, + comment: 'Annual budget for this cost center', + }) + budget: number; + + @Column({ + name: 'budget_consumed', + type: 'decimal', + precision: 16, + scale: 2, + default: 0, + comment: 'Amount consumed from budget', + }) + budgetConsumed: number; + + @Column({ + name: 'distribution_percentage', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + comment: 'Percentage for indirect cost distribution', + }) + distributionPercentage: number | null; + + @Column({ + name: 'distribution_base', + type: 'varchar', + length: 50, + nullable: true, + comment: 'Base for distribution: direct_cost, headcount, area, etc.', + }) + distributionBase: string | null; + + @Column({ + name: 'accounting_code', + type: 'varchar', + length: 50, + nullable: true, + comment: 'Code for accounting system integration', + }) + accountingCode: string | null; + + @Column({ + name: 'is_billable', + type: 'boolean', + default: false, + comment: 'Can be billed to client', + }) + isBillable: boolean; + + @Column({ + name: 'is_active', + type: 'boolean', + default: true, + }) + isActive: boolean; + + @Column({ + name: 'sort_order', + type: 'integer', + default: 0, + }) + sortOrder: number; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Additional metadata', + }) + metadata: Record | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @TreeParent() + @ManyToOne(() => CostCenter, (cc) => cc.children) + @JoinColumn({ name: 'parent_id' }) + parent: CostCenter | null; + + @TreeChildren() + @OneToMany(() => CostCenter, (cc) => cc.parent) + children: CostCenter[]; + + @ManyToOne(() => User) + @JoinColumn({ name: 'responsible_id' }) + responsible: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/src/modules/admin/entities/custom-permission.entity.ts b/src/modules/admin/entities/custom-permission.entity.ts new file mode 100644 index 0000000..72a6b5a --- /dev/null +++ b/src/modules/admin/entities/custom-permission.entity.ts @@ -0,0 +1,161 @@ +/** + * CustomPermission Entity + * Permisos personalizados y temporales para usuarios + * + * @module Admin + * @table admin.custom_permissions + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +export type PermissionAction = 'create' | 'read' | 'update' | 'delete' | 'approve' | 'export' | 'import'; + +@Entity({ schema: 'admin', name: 'custom_permissions' }) +@Index(['tenantId']) +@Index(['userId']) +@Index(['module']) +@Index(['isActive']) +@Index(['validUntil']) +@Index(['tenantId', 'userId', 'module']) +export class CustomPermission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ + type: 'varchar', + length: 100, + comment: 'Module this permission applies to', + }) + module: string; + + @Column({ + type: 'varchar', + array: true, + comment: 'Actions allowed', + }) + actions: PermissionAction[]; + + @Column({ + name: 'resource_type', + type: 'varchar', + length: 100, + nullable: true, + comment: 'Specific resource type (e.g., Estimacion, Proyecto)', + }) + resourceType: string | null; + + @Column({ + name: 'resource_id', + type: 'uuid', + nullable: true, + comment: 'Specific resource ID (null = all resources)', + }) + resourceId: string | null; + + @Column({ + name: 'fraccionamiento_id', + type: 'uuid', + nullable: true, + comment: 'Project scope (null = all projects)', + }) + fraccionamientoId: string | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Additional conditions for permission', + }) + conditions: Record | null; + + @Column({ + type: 'text', + nullable: true, + comment: 'Reason for granting this permission', + }) + reason: string | null; + + @Column({ + name: 'valid_from', + type: 'timestamptz', + default: () => 'CURRENT_TIMESTAMP', + }) + validFrom: Date; + + @Column({ + name: 'valid_until', + type: 'timestamptz', + nullable: true, + comment: 'Expiration date (null = permanent)', + }) + validUntil: Date | null; + + @Column({ + name: 'is_active', + type: 'boolean', + default: true, + }) + isActive: boolean; + + @Column({ + name: 'granted_by', + type: 'uuid', + }) + grantedById: string; + + @Column({ + name: 'revoked_at', + type: 'timestamptz', + nullable: true, + }) + revokedAt: Date | null; + + @Column({ + name: 'revoked_by', + type: 'uuid', + nullable: true, + }) + revokedById: string | null; + + @Column({ + name: 'revoke_reason', + type: 'text', + nullable: true, + }) + revokeReason: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'granted_by' }) + grantedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'revoked_by' }) + revokedBy: User | null; +} diff --git a/src/modules/admin/entities/index.ts b/src/modules/admin/entities/index.ts new file mode 100644 index 0000000..9276d74 --- /dev/null +++ b/src/modules/admin/entities/index.ts @@ -0,0 +1,10 @@ +/** + * Admin Module - Entity Exports + * MAI-013: Administración & Seguridad + */ + +export * from './cost-center.entity'; +export * from './audit-log.entity'; +export * from './system-setting.entity'; +export * from './backup.entity'; +export * from './custom-permission.entity'; diff --git a/src/modules/admin/entities/system-setting.entity.ts b/src/modules/admin/entities/system-setting.entity.ts new file mode 100644 index 0000000..1a8d160 --- /dev/null +++ b/src/modules/admin/entities/system-setting.entity.ts @@ -0,0 +1,180 @@ +/** + * SystemSetting Entity + * Configuración del sistema por tenant + * + * @module Admin + * @table admin.system_settings + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +export type SettingCategory = + | 'general' + | 'security' + | 'notifications' + | 'integrations' + | 'workflow' + | 'reports' + | 'backups' + | 'appearance'; + +export type SettingDataType = 'string' | 'number' | 'boolean' | 'json' | 'array'; + +@Entity({ schema: 'admin', name: 'system_settings' }) +@Index(['tenantId', 'key'], { unique: true }) +@Index(['tenantId']) +@Index(['category']) +@Index(['isPublic']) +export class SystemSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ + type: 'varchar', + length: 100, + comment: 'Unique key for the setting', + }) + key: string; + + @Column({ + type: 'varchar', + length: 200, + }) + name: string; + + @Column({ + type: 'text', + nullable: true, + }) + description: string | null; + + @Column({ + type: 'varchar', + length: 30, + default: 'general', + }) + category: SettingCategory; + + @Column({ + name: 'data_type', + type: 'varchar', + length: 20, + default: 'string', + }) + dataType: SettingDataType; + + @Column({ + type: 'text', + comment: 'Current value (stored as string)', + }) + value: string; + + @Column({ + name: 'default_value', + type: 'text', + nullable: true, + comment: 'Default value for reset', + }) + defaultValue: string | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Validation rules (min, max, pattern, options, etc.)', + }) + validation: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Allowed options for select/enum types', + }) + options: Record[] | null; + + @Column({ + name: 'is_public', + type: 'boolean', + default: false, + comment: 'Can be read without authentication', + }) + isPublic: boolean; + + @Column({ + name: 'is_encrypted', + type: 'boolean', + default: false, + comment: 'Value is encrypted (for sensitive data)', + }) + isEncrypted: boolean; + + @Column({ + name: 'is_system', + type: 'boolean', + default: false, + comment: 'System setting cannot be deleted', + }) + isSystem: boolean; + + @Column({ + name: 'requires_restart', + type: 'boolean', + default: false, + comment: 'Requires app restart to take effect', + }) + requiresRestart: boolean; + + @Column({ + name: 'allowed_roles', + type: 'varchar', + array: true, + nullable: true, + comment: 'Roles that can modify this setting', + }) + allowedRoles: string[] | null; + + @Column({ + name: 'sort_order', + type: 'integer', + default: 0, + }) + sortOrder: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User | null; +} diff --git a/src/modules/admin/services/audit-log.service.ts b/src/modules/admin/services/audit-log.service.ts new file mode 100644 index 0000000..2a1706c --- /dev/null +++ b/src/modules/admin/services/audit-log.service.ts @@ -0,0 +1,309 @@ +/** + * AuditLogService - Gestión de Logs de Auditoría + * + * Registra y consulta eventos de auditoría para trazabilidad. + * + * @module Admin + */ + +import { Repository } from 'typeorm'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { AuditLog, AuditCategory, AuditAction, AuditSeverity } from '../entities/audit-log.entity'; + +export interface CreateAuditLogDto { + userId?: string; + category: AuditCategory; + action: AuditAction; + severity?: AuditSeverity; + entityType?: string; + entityId?: string; + entityName?: string; + description: string; + oldValues?: Record; + newValues?: Record; + changedFields?: string[]; + ipAddress?: string; + userAgent?: string; + requestId?: string; + sessionId?: string; + module?: string; + endpoint?: string; + httpMethod?: string; + responseStatus?: number; + durationMs?: number; + isSuccess?: boolean; + errorMessage?: string; + metadata?: Record; +} + +export interface AuditLogFilters { + userId?: string; + category?: AuditCategory; + action?: AuditAction; + severity?: AuditSeverity; + entityType?: string; + entityId?: string; + module?: string; + isSuccess?: boolean; + dateFrom?: Date; + dateTo?: Date; + ipAddress?: string; + search?: string; +} + +export class AuditLogService { + constructor(private readonly repository: Repository) {} + + /** + * Crear registro de auditoría + */ + async log(ctx: ServiceContext, data: CreateAuditLogDto): Promise { + // Determinar retención basada en severidad + let retentionDays = 90; // Default for operational logs + if (data.severity === 'critical' || data.category === 'critical_operation') { + retentionDays = 1825; // 5 years for critical + } else if (data.severity === 'high') { + retentionDays = 365; // 1 year for high severity + } + + const log = this.repository.create({ + tenantId: ctx.tenantId, + ...data, + severity: data.severity || 'medium', + isSuccess: data.isSuccess ?? true, + retentionDays, + }); + + return this.repository.save(log); + } + + /** + * Buscar logs con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: AuditLogFilters, + page = 1, + limit = 50 + ): Promise> { + const qb = this.repository + .createQueryBuilder('al') + .leftJoinAndSelect('al.user', 'u') + .where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.userId) { + qb.andWhere('al.user_id = :userId', { userId: filters.userId }); + } + if (filters.category) { + qb.andWhere('al.category = :category', { category: filters.category }); + } + if (filters.action) { + qb.andWhere('al.action = :action', { action: filters.action }); + } + if (filters.severity) { + qb.andWhere('al.severity = :severity', { severity: filters.severity }); + } + if (filters.entityType) { + qb.andWhere('al.entity_type = :entityType', { entityType: filters.entityType }); + } + if (filters.entityId) { + qb.andWhere('al.entity_id = :entityId', { entityId: filters.entityId }); + } + if (filters.module) { + qb.andWhere('al.module = :module', { module: filters.module }); + } + if (filters.isSuccess !== undefined) { + qb.andWhere('al.is_success = :isSuccess', { isSuccess: filters.isSuccess }); + } + if (filters.dateFrom) { + qb.andWhere('al.created_at >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('al.created_at <= :dateTo', { dateTo: filters.dateTo }); + } + if (filters.ipAddress) { + qb.andWhere('al.ip_address = :ipAddress', { ipAddress: filters.ipAddress }); + } + if (filters.search) { + qb.andWhere( + '(al.description ILIKE :search OR al.entity_name ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + const skip = (page - 1) * limit; + qb.orderBy('al.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Obtener logs por entidad + */ + async findByEntity( + ctx: ServiceContext, + entityType: string, + entityId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findWithFilters(ctx, { entityType, entityId }, page, limit); + } + + /** + * Obtener logs por usuario + */ + async findByUser( + ctx: ServiceContext, + userId: string, + page = 1, + limit = 50 + ): Promise> { + return this.findWithFilters(ctx, { userId }, page, limit); + } + + /** + * Obtener logs críticos recientes + */ + async getCriticalLogs( + ctx: ServiceContext, + days = 7, + limit = 100 + ): Promise { + const dateFrom = new Date(); + dateFrom.setDate(dateFrom.getDate() - days); + + return this.repository + .createQueryBuilder('al') + .leftJoinAndSelect('al.user', 'u') + .where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('al.severity IN (:...severities)', { severities: ['high', 'critical'] }) + .andWhere('al.created_at >= :dateFrom', { dateFrom }) + .orderBy('al.created_at', 'DESC') + .take(limit) + .getMany(); + } + + /** + * Obtener logs fallidos + */ + async getFailedLogs( + ctx: ServiceContext, + hours = 24, + limit = 100 + ): Promise { + const dateFrom = new Date(); + dateFrom.setHours(dateFrom.getHours() - hours); + + return this.repository + .createQueryBuilder('al') + .leftJoinAndSelect('al.user', 'u') + .where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('al.is_success = false') + .andWhere('al.created_at >= :dateFrom', { dateFrom }) + .orderBy('al.created_at', 'DESC') + .take(limit) + .getMany(); + } + + /** + * Limpiar logs expirados + */ + async cleanupExpiredLogs(ctx: ServiceContext): Promise { + const result = await this.repository + .createQueryBuilder() + .delete() + .from(AuditLog) + .where('tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere("created_at < NOW() - (retention_days || ' days')::interval") + .execute(); + + return result.affected || 0; + } + + /** + * Obtener estadísticas + */ + async getStats(ctx: ServiceContext, days = 30): Promise { + const dateFrom = new Date(); + dateFrom.setDate(dateFrom.getDate() - days); + + const qb = this.repository + .createQueryBuilder('al') + .where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('al.created_at >= :dateFrom', { dateFrom }); + + // Total count + const total = await qb.getCount(); + + // By category + const byCategory = await this.repository + .createQueryBuilder('al') + .select('al.category', 'category') + .addSelect('COUNT(*)', 'count') + .where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('al.created_at >= :dateFrom', { dateFrom }) + .groupBy('al.category') + .getRawMany(); + + // By action + const byAction = await this.repository + .createQueryBuilder('al') + .select('al.action', 'action') + .addSelect('COUNT(*)', 'count') + .where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('al.created_at >= :dateFrom', { dateFrom }) + .groupBy('al.action') + .getRawMany(); + + // Success/failure + const successCount = await this.repository + .createQueryBuilder('al') + .where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('al.created_at >= :dateFrom', { dateFrom }) + .andWhere('al.is_success = true') + .getCount(); + + // By severity + const bySeverity = await this.repository + .createQueryBuilder('al') + .select('al.severity', 'severity') + .addSelect('COUNT(*)', 'count') + .where('al.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('al.created_at >= :dateFrom', { dateFrom }) + .groupBy('al.severity') + .getRawMany(); + + return { + period: { days, from: dateFrom, to: new Date() }, + total, + successCount, + failureCount: total - successCount, + successRate: total > 0 ? (successCount / total) * 100 : 0, + byCategory: byCategory.map((r) => ({ category: r.category, count: parseInt(r.count) })), + byAction: byAction.map((r) => ({ action: r.action, count: parseInt(r.count) })), + bySeverity: bySeverity.map((r) => ({ severity: r.severity, count: parseInt(r.count) })), + }; + } +} + +export interface AuditLogStats { + period: { days: number; from: Date; to: Date }; + total: number; + successCount: number; + failureCount: number; + successRate: number; + byCategory: { category: AuditCategory; count: number }[]; + byAction: { action: AuditAction; count: number }[]; + bySeverity: { severity: AuditSeverity; count: number }[]; +} diff --git a/src/modules/admin/services/backup.service.ts b/src/modules/admin/services/backup.service.ts new file mode 100644 index 0000000..745399e --- /dev/null +++ b/src/modules/admin/services/backup.service.ts @@ -0,0 +1,308 @@ +/** + * BackupService - Gestión de Backups + * + * Administra creación, verificación y restauración de backups. + * + * @module Admin + */ + +import { Repository } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Backup, BackupType, BackupStatus, BackupStorage } from '../entities/backup.entity'; + +export interface CreateBackupDto { + backupType: BackupType; + name: string; + description?: string; + storageLocation?: BackupStorage; + tablesIncluded?: string[]; + tablesExcluded?: string[]; + retentionPolicy?: string; + isScheduled?: boolean; + scheduleId?: string; + metadata?: Record; +} + +export interface BackupFilters { + backupType?: BackupType; + status?: BackupStatus; + storageLocation?: BackupStorage; + isScheduled?: boolean; + isVerified?: boolean; + dateFrom?: Date; + dateTo?: Date; +} + +export class BackupService extends BaseService { + constructor(repository: Repository) { + super(repository); + } + + /** + * Buscar backups con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: BackupFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('b') + .leftJoinAndSelect('b.createdBy', 'cb') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.backupType) { + qb.andWhere('b.backup_type = :backupType', { backupType: filters.backupType }); + } + if (filters.status) { + qb.andWhere('b.status = :status', { status: filters.status }); + } + if (filters.storageLocation) { + qb.andWhere('b.storage_location = :storageLocation', { storageLocation: filters.storageLocation }); + } + if (filters.isScheduled !== undefined) { + qb.andWhere('b.is_scheduled = :isScheduled', { isScheduled: filters.isScheduled }); + } + if (filters.isVerified !== undefined) { + qb.andWhere('b.is_verified = :isVerified', { isVerified: filters.isVerified }); + } + if (filters.dateFrom) { + qb.andWhere('b.created_at >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('b.created_at <= :dateTo', { dateTo: filters.dateTo }); + } + + const skip = (page - 1) * limit; + qb.orderBy('b.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Iniciar backup + */ + async initiateBackup(ctx: ServiceContext, data: CreateBackupDto): Promise { + // Calculate expiration based on retention policy + let expiresAt: Date | null = null; + if (data.retentionPolicy) { + expiresAt = this.calculateExpiration(data.retentionPolicy); + } + + const backup = await this.create(ctx, { + ...data, + status: 'pending' as BackupStatus, + isEncrypted: true, + isCompressed: true, + compressionType: 'gzip', + expiresAt, + }); + + // In production, this would trigger an async job + // For now, we simulate starting the backup + await this.startBackupProcess(ctx, backup.id); + + return this.findById(ctx, backup.id) as Promise; + } + + /** + * Proceso de backup (simulado) + */ + private async startBackupProcess(ctx: ServiceContext, backupId: string): Promise { + const startTime = Date.now(); + + await this.update(ctx, backupId, { + status: 'running' as BackupStatus, + startedAt: new Date(), + }); + + // Simulate backup process + // In production, this would be handled by a job queue + const duration = Date.now() - startTime; + + await this.update(ctx, backupId, { + status: 'completed' as BackupStatus, + completedAt: new Date(), + durationSeconds: Math.ceil(duration / 1000), + // These would be real values from the backup process + fileSize: 0, + rowCount: 0, + }); + } + + /** + * Calcular fecha de expiración + */ + private calculateExpiration(policy: string): Date | null { + const now = new Date(); + switch (policy) { + case 'daily': + now.setDate(now.getDate() + 7); // Keep 7 days + return now; + case 'weekly': + now.setDate(now.getDate() + 30); // Keep 30 days + return now; + case 'monthly': + now.setMonth(now.getMonth() + 12); // Keep 12 months + return now; + case 'yearly': + now.setFullYear(now.getFullYear() + 7); // Keep 7 years + return now; + case 'permanent': + return null; // Never expires + default: + now.setDate(now.getDate() + 30); // Default 30 days + return now; + } + } + + /** + * Marcar backup como verificado + */ + async markVerified(ctx: ServiceContext, id: string): Promise { + const backup = await this.findById(ctx, id); + if (!backup) { + return null; + } + + if (backup.status !== 'completed') { + throw new Error('Only completed backups can be verified'); + } + + return this.update(ctx, id, { + isVerified: true, + verifiedAt: new Date(), + verifiedById: ctx.userId, + }); + } + + /** + * Cancelar backup en progreso + */ + async cancelBackup(ctx: ServiceContext, id: string): Promise { + const backup = await this.findById(ctx, id); + if (!backup) { + return null; + } + + if (!['pending', 'running'].includes(backup.status)) { + throw new Error('Only pending or running backups can be cancelled'); + } + + return this.update(ctx, id, { + status: 'cancelled' as BackupStatus, + completedAt: new Date(), + }); + } + + /** + * Obtener último backup exitoso + */ + async getLastSuccessful(ctx: ServiceContext, backupType?: BackupType): Promise { + const qb = this.repository + .createQueryBuilder('b') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.status = :status', { status: 'completed' }); + + if (backupType) { + qb.andWhere('b.backup_type = :backupType', { backupType }); + } + + return qb.orderBy('b.completed_at', 'DESC').getOne(); + } + + /** + * Obtener backups pendientes de verificación + */ + async getPendingVerification(ctx: ServiceContext): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + status: 'completed' as BackupStatus, + isVerified: false, + } as any, + order: { completedAt: 'DESC' }, + }); + } + + /** + * Limpiar backups expirados + */ + async cleanupExpired(ctx: ServiceContext): Promise { + const result = await this.repository + .createQueryBuilder() + .update(Backup) + .set({ status: 'expired' as BackupStatus }) + .where('tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('expires_at < NOW()') + .andWhere('status = :status', { status: 'completed' }) + .execute(); + + return result.affected || 0; + } + + /** + * Obtener estadísticas + */ + async getStats(ctx: ServiceContext): Promise { + const all = await this.repository.find({ + where: { tenantId: ctx.tenantId } as any, + }); + + const byType = new Map(); + const byStatus = new Map(); + let totalSize = 0; + let verifiedCount = 0; + + for (const backup of all) { + byType.set(backup.backupType, (byType.get(backup.backupType) || 0) + 1); + byStatus.set(backup.status, (byStatus.get(backup.status) || 0) + 1); + totalSize += Number(backup.fileSize) || 0; + if (backup.isVerified) verifiedCount++; + } + + const lastBackup = await this.getLastSuccessful(ctx); + + return { + total: all.length, + totalSizeBytes: totalSize, + totalSizeHuman: this.formatBytes(totalSize), + verifiedCount, + byType: Array.from(byType.entries()).map(([type, count]) => ({ type, count })), + byStatus: Array.from(byStatus.entries()).map(([status, count]) => ({ status, count })), + lastBackupAt: lastBackup?.completedAt || null, + }; + } + + /** + * Formatear bytes a formato legible + */ + private formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} + +export interface BackupStats { + total: number; + totalSizeBytes: number; + totalSizeHuman: string; + verifiedCount: number; + byType: { type: BackupType; count: number }[]; + byStatus: { status: BackupStatus; count: number }[]; + lastBackupAt: Date | null; +} diff --git a/src/modules/admin/services/cost-center.service.ts b/src/modules/admin/services/cost-center.service.ts new file mode 100644 index 0000000..ede0ae9 --- /dev/null +++ b/src/modules/admin/services/cost-center.service.ts @@ -0,0 +1,336 @@ +/** + * CostCenterService - Gestión de Centros de Costo + * + * Administra centros de costo jerárquicos para imputación de gastos. + * + * @module Admin + */ + +import { Repository } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { CostCenter, CostCenterType, CostCenterLevel } from '../entities/cost-center.entity'; + +export interface CreateCostCenterDto { + code: string; + name: string; + description?: string; + costCenterType?: CostCenterType; + level?: CostCenterLevel; + parentId?: string; + fraccionamientoId?: string; + responsibleId?: string; + budget?: number; + distributionPercentage?: number; + distributionBase?: string; + accountingCode?: string; + isBillable?: boolean; + metadata?: Record; +} + +export interface UpdateCostCenterDto extends Partial { + isActive?: boolean; + sortOrder?: number; +} + +export interface CostCenterFilters { + costCenterType?: CostCenterType; + level?: CostCenterLevel; + fraccionamientoId?: string; + parentId?: string; + responsibleId?: string; + isActive?: boolean; + search?: string; +} + +export class CostCenterService extends BaseService { + constructor(repository: Repository) { + super(repository); + } + + /** + * Buscar centros de costo con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: CostCenterFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('cc') + .leftJoinAndSelect('cc.responsible', 'r') + .where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('cc.deleted_at IS NULL'); + + if (filters.costCenterType) { + qb.andWhere('cc.cost_center_type = :costCenterType', { costCenterType: filters.costCenterType }); + } + if (filters.level) { + qb.andWhere('cc.level = :level', { level: filters.level }); + } + if (filters.fraccionamientoId) { + qb.andWhere('cc.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId: filters.fraccionamientoId }); + } + if (filters.parentId !== undefined) { + if (filters.parentId === null) { + qb.andWhere('cc.parent_id IS NULL'); + } else { + qb.andWhere('cc.parent_id = :parentId', { parentId: filters.parentId }); + } + } + if (filters.responsibleId) { + qb.andWhere('cc.responsible_id = :responsibleId', { responsibleId: filters.responsibleId }); + } + if (filters.isActive !== undefined) { + qb.andWhere('cc.is_active = :isActive', { isActive: filters.isActive }); + } + if (filters.search) { + qb.andWhere('(cc.name ILIKE :search OR cc.code ILIKE :search OR cc.description ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const skip = (page - 1) * limit; + qb.orderBy('cc.sort_order', 'ASC') + .addOrderBy('cc.code', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Buscar por código + */ + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.repository.findOne({ + where: { + tenantId: ctx.tenantId, + code, + deletedAt: null, + } as any, + }); + } + + /** + * Obtener árbol de centros de costo + */ + async getTree(ctx: ServiceContext, fraccionamientoId?: string): Promise { + const qb = this.repository + .createQueryBuilder('cc') + .where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('cc.deleted_at IS NULL') + .andWhere('cc.parent_id IS NULL'); + + if (fraccionamientoId) { + qb.andWhere('(cc.fraccionamiento_id = :fraccionamientoId OR cc.fraccionamiento_id IS NULL)', { + fraccionamientoId, + }); + } + + const roots = await qb.orderBy('cc.sort_order', 'ASC').getMany(); + + // Load children recursively + for (const root of roots) { + await this.loadChildren(ctx, root, fraccionamientoId); + } + + return roots; + } + + /** + * Cargar hijos recursivamente + */ + private async loadChildren( + ctx: ServiceContext, + parent: CostCenter, + fraccionamientoId?: string + ): Promise { + const qb = this.repository + .createQueryBuilder('cc') + .where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('cc.deleted_at IS NULL') + .andWhere('cc.parent_id = :parentId', { parentId: parent.id }); + + if (fraccionamientoId) { + qb.andWhere('(cc.fraccionamiento_id = :fraccionamientoId OR cc.fraccionamiento_id IS NULL)', { + fraccionamientoId, + }); + } + + parent.children = await qb.orderBy('cc.sort_order', 'ASC').getMany(); + + for (const child of parent.children) { + await this.loadChildren(ctx, child, fraccionamientoId); + } + } + + /** + * Obtener hijos directos + */ + async getChildren(ctx: ServiceContext, parentId: string): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + parentId, + deletedAt: null, + } as any, + order: { sortOrder: 'ASC', code: 'ASC' }, + }); + } + + /** + * Crear centro de costo + */ + async createCostCenter(ctx: ServiceContext, data: CreateCostCenterDto): Promise { + const existing = await this.findByCode(ctx, data.code); + if (existing) { + throw new Error(`Cost center with code ${data.code} already exists`); + } + + // Validate parent if provided + if (data.parentId) { + const parent = await this.findById(ctx, data.parentId); + if (!parent) { + throw new Error('Parent cost center not found'); + } + } + + return this.create(ctx, { + ...data, + isActive: true, + budgetConsumed: 0, + }); + } + + /** + * Actualizar presupuesto consumido + */ + async updateBudgetConsumed( + ctx: ServiceContext, + id: string, + amount: number + ): Promise { + const cc = await this.findById(ctx, id); + if (!cc) { + return null; + } + + return this.update(ctx, id, { + budgetConsumed: Number(cc.budgetConsumed) + amount, + }); + } + + /** + * Obtener resumen de presupuesto + */ + async getBudgetSummary( + ctx: ServiceContext, + fraccionamientoId?: string + ): Promise { + const qb = this.repository + .createQueryBuilder('cc') + .select([ + 'cc.cost_center_type as type', + 'SUM(cc.budget) as total_budget', + 'SUM(cc.budget_consumed) as total_consumed', + 'COUNT(*) as count', + ]) + .where('cc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('cc.deleted_at IS NULL') + .andWhere('cc.is_active = true') + .groupBy('cc.cost_center_type'); + + if (fraccionamientoId) { + qb.andWhere('cc.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + const results = await qb.getRawMany(); + + let totalBudget = 0; + let totalConsumed = 0; + const byType: { type: CostCenterType; budget: number; consumed: number; count: number }[] = []; + + for (const r of results) { + const budget = parseFloat(r.total_budget || '0'); + const consumed = parseFloat(r.total_consumed || '0'); + totalBudget += budget; + totalConsumed += consumed; + byType.push({ + type: r.type, + budget, + consumed, + count: parseInt(r.count), + }); + } + + return { + totalBudget, + totalConsumed, + totalAvailable: totalBudget - totalConsumed, + utilizationPercentage: totalBudget > 0 ? (totalConsumed / totalBudget) * 100 : 0, + byType, + }; + } + + /** + * Obtener estadísticas + */ + async getStats(ctx: ServiceContext): Promise { + const all = await this.repository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + }); + + const byType = new Map(); + const byLevel = new Map(); + let activeCount = 0; + + for (const cc of all) { + byType.set(cc.costCenterType, (byType.get(cc.costCenterType) || 0) + 1); + byLevel.set(cc.level, (byLevel.get(cc.level) || 0) + 1); + if (cc.isActive) activeCount++; + } + + return { + total: all.length, + active: activeCount, + inactive: all.length - activeCount, + byType: Array.from(byType.entries()).map(([type, count]) => ({ type, count })), + byLevel: Array.from(byLevel.entries()).map(([level, count]) => ({ level, count })), + }; + } +} + +export interface CostCenterBudgetSummary { + totalBudget: number; + totalConsumed: number; + totalAvailable: number; + utilizationPercentage: number; + byType: { + type: CostCenterType; + budget: number; + consumed: number; + count: number; + }[]; +} + +export interface CostCenterStats { + total: number; + active: number; + inactive: number; + byType: { type: CostCenterType; count: number }[]; + byLevel: { level: CostCenterLevel; count: number }[]; +} diff --git a/src/modules/admin/services/index.ts b/src/modules/admin/services/index.ts new file mode 100644 index 0000000..ade2981 --- /dev/null +++ b/src/modules/admin/services/index.ts @@ -0,0 +1,9 @@ +/** + * Admin Module - Service Exports + * MAI-013: Administración & Seguridad + */ + +export * from './cost-center.service'; +export * from './audit-log.service'; +export * from './system-setting.service'; +export * from './backup.service'; diff --git a/src/modules/admin/services/system-setting.service.ts b/src/modules/admin/services/system-setting.service.ts new file mode 100644 index 0000000..3daf8f6 --- /dev/null +++ b/src/modules/admin/services/system-setting.service.ts @@ -0,0 +1,336 @@ +/** + * SystemSettingService - Gestión de Configuración del Sistema + * + * Administra configuraciones del sistema por tenant. + * + * @module Admin + */ + +import { Repository } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { SystemSetting, SettingCategory, SettingDataType } from '../entities/system-setting.entity'; + +export interface CreateSettingDto { + key: string; + name: string; + description?: string; + category?: SettingCategory; + dataType?: SettingDataType; + value: string; + defaultValue?: string; + validation?: Record; + options?: Record[]; + isPublic?: boolean; + isEncrypted?: boolean; + requiresRestart?: boolean; + allowedRoles?: string[]; +} + +export interface UpdateSettingDto { + name?: string; + description?: string; + value?: string; + validation?: Record; + options?: Record[]; + isPublic?: boolean; + allowedRoles?: string[]; + sortOrder?: number; +} + +export interface SettingFilters { + category?: SettingCategory; + isPublic?: boolean; + search?: string; +} + +export class SystemSettingService extends BaseService { + constructor(repository: Repository) { + super(repository); + } + + /** + * Obtener valor de configuración + */ + async getValue(ctx: ServiceContext, key: string): Promise { + const setting = await this.findByKey(ctx, key); + if (!setting) { + return null; + } + + return this.parseValue(setting.value, setting.dataType); + } + + /** + * Obtener valor con default + */ + async getValueOrDefault(ctx: ServiceContext, key: string, defaultValue: any): Promise { + const value = await this.getValue(ctx, key); + return value !== null ? value : defaultValue; + } + + /** + * Buscar por key + */ + async findByKey(ctx: ServiceContext, key: string): Promise { + return this.repository.findOne({ + where: { + tenantId: ctx.tenantId, + key, + } as any, + }); + } + + /** + * Buscar configuraciones con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: SettingFilters, + page = 1, + limit = 50 + ): Promise> { + const qb = this.repository + .createQueryBuilder('ss') + .where('ss.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.category) { + qb.andWhere('ss.category = :category', { category: filters.category }); + } + if (filters.isPublic !== undefined) { + qb.andWhere('ss.is_public = :isPublic', { isPublic: filters.isPublic }); + } + if (filters.search) { + qb.andWhere( + '(ss.key ILIKE :search OR ss.name ILIKE :search OR ss.description ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + const skip = (page - 1) * limit; + qb.orderBy('ss.category', 'ASC') + .addOrderBy('ss.sort_order', 'ASC') + .addOrderBy('ss.key', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Obtener por categoría + */ + async findByCategory(ctx: ServiceContext, category: SettingCategory): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + category, + } as any, + order: { sortOrder: 'ASC', key: 'ASC' }, + }); + } + + /** + * Obtener configuraciones públicas + */ + async getPublicSettings(ctx: ServiceContext): Promise> { + const settings = await this.repository.find({ + where: { + tenantId: ctx.tenantId, + isPublic: true, + } as any, + }); + + const result: Record = {}; + for (const setting of settings) { + result[setting.key] = this.parseValue(setting.value, setting.dataType); + } + + return result; + } + + /** + * Crear configuración + */ + async createSetting(ctx: ServiceContext, data: CreateSettingDto): Promise { + const existing = await this.findByKey(ctx, data.key); + if (existing) { + throw new Error(`Setting with key ${data.key} already exists`); + } + + // Validate value against type and validation rules + this.validateValue(data.value, data.dataType || 'string', data.validation); + + return this.create(ctx, { + ...data, + isSystem: false, + }); + } + + /** + * Actualizar valor + */ + async updateValue(ctx: ServiceContext, key: string, value: string): Promise { + const setting = await this.findByKey(ctx, key); + if (!setting) { + return null; + } + + // Validate value against type and validation rules + this.validateValue(value, setting.dataType, setting.validation); + + return this.update(ctx, setting.id, { value }); + } + + /** + * Restablecer a valor por defecto + */ + async resetToDefault(ctx: ServiceContext, key: string): Promise { + const setting = await this.findByKey(ctx, key); + if (!setting || !setting.defaultValue) { + return null; + } + + return this.update(ctx, setting.id, { value: setting.defaultValue }); + } + + /** + * Actualizar múltiples configuraciones + */ + async updateMultiple( + ctx: ServiceContext, + settings: { key: string; value: string }[] + ): Promise<{ updated: number; errors: string[] }> { + let updated = 0; + const errors: string[] = []; + + for (const { key, value } of settings) { + try { + const result = await this.updateValue(ctx, key, value); + if (result) { + updated++; + } else { + errors.push(`Setting ${key} not found`); + } + } catch (error) { + errors.push(`${key}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + return { updated, errors }; + } + + /** + * Parsear valor según tipo + */ + private parseValue(value: string, dataType: SettingDataType): any { + switch (dataType) { + case 'number': + return parseFloat(value); + case 'boolean': + return value === 'true' || value === '1'; + case 'json': + case 'array': + try { + return JSON.parse(value); + } catch { + return null; + } + default: + return value; + } + } + + /** + * Validar valor + */ + private validateValue( + value: string, + dataType: SettingDataType, + validation?: Record | null + ): void { + // Basic type validation + switch (dataType) { + case 'number': + if (isNaN(parseFloat(value))) { + throw new Error('Value must be a valid number'); + } + break; + case 'boolean': + if (!['true', 'false', '1', '0'].includes(value)) { + throw new Error('Value must be true/false or 1/0'); + } + break; + case 'json': + case 'array': + try { + JSON.parse(value); + } catch { + throw new Error('Value must be valid JSON'); + } + break; + } + + // Custom validation rules + if (validation) { + if (validation.min !== undefined && parseFloat(value) < validation.min) { + throw new Error(`Value must be at least ${validation.min}`); + } + if (validation.max !== undefined && parseFloat(value) > validation.max) { + throw new Error(`Value must be at most ${validation.max}`); + } + if (validation.pattern && !new RegExp(validation.pattern).test(value)) { + throw new Error(`Value does not match required pattern`); + } + if (validation.options && !validation.options.includes(value)) { + throw new Error(`Value must be one of: ${validation.options.join(', ')}`); + } + } + } + + /** + * Obtener estadísticas + */ + async getStats(ctx: ServiceContext): Promise { + const all = await this.repository.find({ + where: { tenantId: ctx.tenantId } as any, + }); + + const byCategory = new Map(); + let publicCount = 0; + let systemCount = 0; + let encryptedCount = 0; + + for (const setting of all) { + byCategory.set(setting.category, (byCategory.get(setting.category) || 0) + 1); + if (setting.isPublic) publicCount++; + if (setting.isSystem) systemCount++; + if (setting.isEncrypted) encryptedCount++; + } + + return { + total: all.length, + publicCount, + systemCount, + encryptedCount, + byCategory: Array.from(byCategory.entries()).map(([category, count]) => ({ category, count })), + }; + } +} + +export interface SettingStats { + total: number; + publicCount: number; + systemCount: number; + encryptedCount: number; + byCategory: { category: SettingCategory; count: number }[]; +} diff --git a/src/modules/auth/controllers/auth.controller.ts b/src/modules/auth/controllers/auth.controller.ts new file mode 100644 index 0000000..461cf02 --- /dev/null +++ b/src/modules/auth/controllers/auth.controller.ts @@ -0,0 +1,268 @@ +/** + * AuthController - Controlador de Autenticación + * + * Endpoints REST para login, register, refresh y logout. + * Implementa validación de datos y manejo de errores. + * + * @module Auth + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AuthService } from '../services/auth.service'; +import { AuthMiddleware } from '../middleware/auth.middleware'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../entities/refresh-token.entity'; +import { + LoginDto, + RegisterDto, + RefreshTokenDto, + ChangePasswordDto, +} from '../dto/auth.dto'; + +/** + * Crear router de autenticación + */ +export function createAuthController(dataSource: DataSource): Router { + const router = Router(); + + // Inicializar repositorios + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Inicializar servicio + const authService = new AuthService( + userRepository, + tenantRepository, + refreshTokenRepository as any + ); + + // Inicializar middleware + const authMiddleware = new AuthMiddleware(authService, dataSource); + + /** + * POST /auth/login + * Login de usuario + */ + router.post('/login', async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: LoginDto = req.body; + + if (!dto.email || !dto.password) { + res.status(400).json({ + error: 'Bad Request', + message: 'Email and password are required', + }); + return; + } + + const result = await authService.login(dto); + res.status(200).json({ success: true, data: result }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Invalid credentials') { + res.status(401).json({ error: 'Unauthorized', message: 'Invalid email or password' }); + return; + } + if (error.message === 'User is not active') { + res.status(403).json({ error: 'Forbidden', message: 'User account is disabled' }); + return; + } + if (error.message === 'No tenant specified' || error.message === 'Tenant not found or inactive') { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * POST /auth/register + * Registro de nuevo usuario + */ + router.post('/register', async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: RegisterDto = req.body; + + if (!dto.email || !dto.password || !dto.firstName || !dto.lastName || !dto.tenantId) { + res.status(400).json({ + error: 'Bad Request', + message: 'Email, password, firstName, lastName and tenantId are required', + }); + return; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(dto.email)) { + res.status(400).json({ error: 'Bad Request', message: 'Invalid email format' }); + return; + } + + if (dto.password.length < 8) { + res.status(400).json({ error: 'Bad Request', message: 'Password must be at least 8 characters' }); + return; + } + + const result = await authService.register(dto); + res.status(201).json({ success: true, data: result }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Email already registered') { + res.status(409).json({ error: 'Conflict', message: 'Email is already registered' }); + return; + } + if (error.message === 'Tenant not found') { + res.status(400).json({ error: 'Bad Request', message: 'Invalid tenant ID' }); + return; + } + } + next(error); + } + }); + + /** + * POST /auth/refresh + * Renovar access token usando refresh token + */ + router.post('/refresh', async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: RefreshTokenDto = req.body; + + if (!dto.refreshToken) { + res.status(400).json({ error: 'Bad Request', message: 'Refresh token is required' }); + return; + } + + const result = await authService.refresh(dto); + res.status(200).json({ success: true, data: result }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Invalid refresh token' || error.message === 'Refresh token expired or revoked') { + res.status(401).json({ error: 'Unauthorized', message: error.message }); + return; + } + if (error.message === 'User not found or inactive') { + res.status(401).json({ error: 'Unauthorized', message: 'User account is disabled or deleted' }); + return; + } + } + next(error); + } + }); + + /** + * POST /auth/logout + * Cerrar sesión (revocar refresh token) + */ + router.post('/logout', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { refreshToken } = req.body; + if (refreshToken) { + await authService.logout(refreshToken); + } + res.status(200).json({ success: true, message: 'Logged out successfully' }); + } catch (error) { + next(error); + } + }); + + /** + * POST /auth/change-password + * Cambiar contraseña (requiere autenticación) + */ + router.post('/change-password', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: ChangePasswordDto = req.body; + const userId = req.user?.sub; + + if (!userId) { + res.status(401).json({ error: 'Unauthorized', message: 'User not authenticated' }); + return; + } + + if (!dto.currentPassword || !dto.newPassword) { + res.status(400).json({ error: 'Bad Request', message: 'Current password and new password are required' }); + return; + } + + if (dto.newPassword.length < 8) { + res.status(400).json({ error: 'Bad Request', message: 'New password must be at least 8 characters' }); + return; + } + + await authService.changePassword(userId, dto); + res.status(200).json({ success: true, message: 'Password changed successfully' }); + } catch (error) { + if (error instanceof Error && error.message === 'Current password is incorrect') { + res.status(400).json({ error: 'Bad Request', message: 'Current password is incorrect' }); + return; + } + next(error); + } + }); + + /** + * GET /auth/me + * Obtener información del usuario autenticado + */ + router.get('/me', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userId = req.user?.sub; + + if (!userId) { + res.status(401).json({ error: 'Unauthorized', message: 'User not authenticated' }); + return; + } + + const user = await userRepository.findOne({ + where: { id: userId } as any, + select: ['id', 'email', 'firstName', 'lastName', 'isActive', 'createdAt'], + }); + + if (!user) { + res.status(404).json({ error: 'Not Found', message: 'User not found' }); + return; + } + + res.status(200).json({ + success: true, + data: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + roles: req.user?.roles || [], + tenantId: req.tenantId, + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /auth/verify + * Verificar si el token es válido + */ + router.get('/verify', authMiddleware.authenticate, (req: Request, res: Response): void => { + res.status(200).json({ + success: true, + data: { + valid: true, + user: { + id: req.user?.sub, + email: req.user?.email, + roles: req.user?.roles, + }, + tenantId: req.tenantId, + }, + }); + }); + + return router; +} + +export default createAuthController; diff --git a/src/modules/auth/controllers/index.ts b/src/modules/auth/controllers/index.ts new file mode 100644 index 0000000..8884f4f --- /dev/null +++ b/src/modules/auth/controllers/index.ts @@ -0,0 +1,5 @@ +/** + * Auth Controllers - Export + */ + +export * from './auth.controller'; diff --git a/src/modules/auth/dto/auth.dto.ts b/src/modules/auth/dto/auth.dto.ts new file mode 100644 index 0000000..9fd85eb --- /dev/null +++ b/src/modules/auth/dto/auth.dto.ts @@ -0,0 +1,70 @@ +/** + * Auth DTOs - Data Transfer Objects para autenticación + * + * @module Auth + */ + +export interface LoginDto { + email: string; + password: string; + tenantId?: string; +} + +export interface RegisterDto { + email: string; + password: string; + firstName: string; + lastName: string; + tenantId: string; +} + +export interface RefreshTokenDto { + refreshToken: string; +} + +export interface ChangePasswordDto { + currentPassword: string; + newPassword: string; +} + +export interface ResetPasswordRequestDto { + email: string; +} + +export interface ResetPasswordDto { + token: string; + newPassword: string; +} + +export interface TokenPayload { + sub: string; // userId + email: string; + tenantId: string; + roles: string[]; + type: 'access' | 'refresh'; + iat?: number; + exp?: number; +} + +export interface AuthResponse { + accessToken: string; + refreshToken: string; + expiresIn: number; + user: { + id: string; + email: string; + firstName: string; + lastName: string; + roles: string[]; + }; + tenant: { + id: string; + name: string; + }; +} + +export interface TokenValidationResult { + valid: boolean; + payload?: TokenPayload; + error?: string; +} diff --git a/src/modules/auth/entities/index.ts b/src/modules/auth/entities/index.ts new file mode 100644 index 0000000..a028313 --- /dev/null +++ b/src/modules/auth/entities/index.ts @@ -0,0 +1,8 @@ +/** + * Auth Entities - Export + */ + +export { RefreshToken } from './refresh-token.entity'; +export { Role } from './role.entity'; +export { Permission } from './permission.entity'; +export { UserRole } from './user-role.entity'; diff --git a/src/modules/auth/entities/permission.entity.ts b/src/modules/auth/entities/permission.entity.ts new file mode 100644 index 0000000..8599af9 --- /dev/null +++ b/src/modules/auth/entities/permission.entity.ts @@ -0,0 +1,34 @@ +/** + * Permission Entity + * Permisos granulares del sistema + * + * @module Auth + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +@Entity({ schema: 'auth', name: 'permissions' }) +export class Permission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'varchar', length: 50 }) + module: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/auth/entities/refresh-token.entity.ts b/src/modules/auth/entities/refresh-token.entity.ts new file mode 100644 index 0000000..1091227 --- /dev/null +++ b/src/modules/auth/entities/refresh-token.entity.ts @@ -0,0 +1,73 @@ +/** + * RefreshToken Entity + * + * Almacena refresh tokens para autenticación JWT. + * Permite revocar tokens y gestionar sesiones. + * + * @module Auth + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; + +@Entity({ name: 'refresh_tokens', schema: 'auth' }) +@Index(['userId', 'revokedAt']) +@Index(['token']) +export class RefreshToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ type: 'text' }) + token: string; + + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'user_agent', type: 'varchar', length: 500, nullable: true }) + userAgent: string | null; + + @Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true }) + ipAddress: string | null; + + /** + * Verificar si el token está expirado + */ + isExpired(): boolean { + return this.expiresAt < new Date(); + } + + /** + * Verificar si el token está revocado + */ + isRevoked(): boolean { + return this.revokedAt !== null; + } + + /** + * Verificar si el token es válido + */ + isValid(): boolean { + return !this.isExpired() && !this.isRevoked(); + } +} diff --git a/src/modules/auth/entities/role.entity.ts b/src/modules/auth/entities/role.entity.ts new file mode 100644 index 0000000..a69a83a --- /dev/null +++ b/src/modules/auth/entities/role.entity.ts @@ -0,0 +1,58 @@ +/** + * Role Entity + * Roles del sistema para RBAC + * + * @module Auth + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + ManyToMany, + JoinTable, +} from 'typeorm'; +import { Permission } from './permission.entity'; +import { UserRole } from './user-role.entity'; + +@Entity({ schema: 'auth', name: 'roles' }) +export class Role { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToMany(() => Permission) + @JoinTable({ + name: 'role_permissions', + joinColumn: { name: 'role_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' }, + }) + permissions: Permission[]; + + @OneToMany(() => UserRole, (userRole) => userRole.role) + userRoles: UserRole[]; +} diff --git a/src/modules/auth/entities/user-role.entity.ts b/src/modules/auth/entities/user-role.entity.ts new file mode 100644 index 0000000..9a6ea4b --- /dev/null +++ b/src/modules/auth/entities/user-role.entity.ts @@ -0,0 +1,54 @@ +/** + * UserRole Entity + * Relación usuarios-roles con soporte multi-tenant + * + * @module Auth + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; +import { Role } from './role.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; + +@Entity({ schema: 'auth', name: 'user_roles' }) +@Index(['userId', 'roleId', 'tenantId'], { unique: true }) +export class UserRole { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'role_id', type: 'uuid' }) + roleId: string; + + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Column({ name: 'assigned_by', type: 'uuid', nullable: true }) + assignedBy: string; + + @CreateDateColumn({ name: 'assigned_at', type: 'timestamptz' }) + assignedAt: Date; + + // Relations + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => Role, (role) => role.userRoles, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'role_id' }) + role: Role; + + @ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; +} diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts new file mode 100644 index 0000000..50b4d61 --- /dev/null +++ b/src/modules/auth/index.ts @@ -0,0 +1,14 @@ +/** + * Auth Module - Main Exports + * + * Módulo de autenticación con JWT y refresh tokens. + * Implementa multi-tenancy con RLS. + * + * @module Auth + */ + +export * from './dto/auth.dto'; +export { RefreshToken } from './entities/refresh-token.entity'; +export { AuthService } from './services/auth.service'; +export { AuthMiddleware, createAuthMiddleware } from './middleware/auth.middleware'; +export { createAuthController } from './controllers/auth.controller'; diff --git a/src/modules/auth/middleware/auth.middleware.ts b/src/modules/auth/middleware/auth.middleware.ts new file mode 100644 index 0000000..fd75f17 --- /dev/null +++ b/src/modules/auth/middleware/auth.middleware.ts @@ -0,0 +1,178 @@ +/** + * Auth Middleware - Middleware de Autenticación + * + * Middleware para Express que valida JWT y extrae información del usuario. + * Configura el tenant_id para RLS en PostgreSQL. + * + * @module Auth + */ + +import { Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AuthService } from '../services/auth.service'; +import { TokenPayload } from '../dto/auth.dto'; + +// Extender Request de Express con información de autenticación +declare global { + namespace Express { + interface Request { + user?: TokenPayload; + tenantId?: string; + } + } +} + +export class AuthMiddleware { + constructor( + private readonly authService: AuthService, + private readonly dataSource: DataSource + ) {} + + /** + * Middleware de autenticación requerida + */ + authenticate = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const token = this.extractToken(req); + + if (!token) { + res.status(401).json({ + error: 'Unauthorized', + message: 'No token provided', + }); + return; + } + + const validation = this.authService.validateAccessToken(token); + + if (!validation.valid || !validation.payload) { + res.status(401).json({ + error: 'Unauthorized', + message: validation.error || 'Invalid token', + }); + return; + } + + // Establecer información en el request + req.user = validation.payload; + req.tenantId = validation.payload.tenantId; + + // Configurar tenant_id para RLS en PostgreSQL + await this.setTenantContext(validation.payload.tenantId); + + next(); + } catch (error) { + res.status(401).json({ + error: 'Unauthorized', + message: 'Authentication failed', + }); + } + }; + + /** + * Middleware de autenticación opcional + */ + optionalAuthenticate = async (req: Request, _res: Response, next: NextFunction): Promise => { + try { + const token = this.extractToken(req); + + if (token) { + const validation = this.authService.validateAccessToken(token); + + if (validation.valid && validation.payload) { + req.user = validation.payload; + req.tenantId = validation.payload.tenantId; + await this.setTenantContext(validation.payload.tenantId); + } + } + + next(); + } catch { + // Si hay error, continuar sin autenticación + next(); + } + }; + + /** + * Middleware de autorización por roles + */ + authorize = (...allowedRoles: string[]) => { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.user) { + res.status(401).json({ + error: 'Unauthorized', + message: 'Authentication required', + }); + return; + } + + const hasRole = req.user.roles.some((role) => allowedRoles.includes(role)); + + if (!hasRole) { + res.status(403).json({ + error: 'Forbidden', + message: 'Insufficient permissions', + }); + return; + } + + next(); + }; + }; + + /** + * Middleware que requiere rol de admin + */ + requireAdmin = (req: Request, res: Response, next: NextFunction): void => { + return this.authorize('admin', 'super_admin')(req, res, next); + }; + + /** + * Middleware que requiere ser supervisor + */ + requireSupervisor = (req: Request, res: Response, next: NextFunction): void => { + return this.authorize('admin', 'super_admin', 'supervisor_obra', 'supervisor_hse')(req, res, next); + }; + + /** + * Extraer token del header Authorization + */ + private extractToken(req: Request): string | null { + const authHeader = req.headers.authorization; + + if (!authHeader) { + return null; + } + + // Bearer token + const [type, token] = authHeader.split(' '); + + if (type !== 'Bearer' || !token) { + return null; + } + + return token; + } + + /** + * Configurar contexto de tenant para RLS + */ + private async setTenantContext(tenantId: string): Promise { + try { + await this.dataSource.query(`SET app.current_tenant_id = '${tenantId}'`); + } catch (error) { + console.error('Error setting tenant context:', error); + throw new Error('Failed to set tenant context'); + } + } +} + +/** + * Factory para crear middleware de autenticación + */ +export function createAuthMiddleware( + authService: AuthService, + dataSource: DataSource +): AuthMiddleware { + return new AuthMiddleware(authService, dataSource); +} diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts new file mode 100644 index 0000000..5803739 --- /dev/null +++ b/src/modules/auth/services/auth.service.ts @@ -0,0 +1,370 @@ +/** + * AuthService - Servicio de Autenticación + * + * Gestiona login, logout, refresh tokens y validación de JWT. + * Implementa patrón multi-tenant con verificación de tenant_id. + * + * @module Auth + */ + +import * as jwt from 'jsonwebtoken'; +import * as bcrypt from 'bcryptjs'; +import { Repository } from 'typeorm'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { + LoginDto, + RegisterDto, + RefreshTokenDto, + ChangePasswordDto, + TokenPayload, + AuthResponse, + TokenValidationResult, +} from '../dto/auth.dto'; + +export interface RefreshToken { + id: string; + userId: string; + token: string; + expiresAt: Date; + revokedAt?: Date; +} + +export class AuthService { + private readonly jwtSecret: string; + private readonly jwtExpiresIn: string; + private readonly jwtRefreshExpiresIn: string; + + constructor( + private readonly userRepository: Repository, + private readonly tenantRepository: Repository, + private readonly refreshTokenRepository: Repository + ) { + this.jwtSecret = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production-minimum-32-chars'; + this.jwtExpiresIn = process.env.JWT_EXPIRES_IN || '1d'; + this.jwtRefreshExpiresIn = process.env.JWT_REFRESH_EXPIRES_IN || '7d'; + } + + /** + * Login de usuario + */ + async login(dto: LoginDto): Promise { + // Buscar usuario por email + const user = await this.userRepository.findOne({ + where: { email: dto.email, deletedAt: null } as any, + relations: ['userRoles', 'userRoles.role'], + }); + + if (!user) { + throw new Error('Invalid credentials'); + } + + // Verificar password + const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash); + if (!isPasswordValid) { + throw new Error('Invalid credentials'); + } + + // Verificar que el usuario esté activo + if (!user.isActive) { + throw new Error('User is not active'); + } + + // Obtener tenant + const tenantId = dto.tenantId || user.defaultTenantId; + if (!tenantId) { + throw new Error('No tenant specified'); + } + + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId, isActive: true, deletedAt: null } as any, + }); + + if (!tenant) { + throw new Error('Tenant not found or inactive'); + } + + // Obtener roles del usuario + const roles = user.userRoles?.map((ur) => ur.role.code) || []; + + // Generar tokens + const accessToken = this.generateAccessToken(user, tenantId, roles); + const refreshToken = await this.generateRefreshToken(user.id); + + // Actualizar último login + await this.userRepository.update(user.id, { lastLoginAt: new Date() }); + + return { + accessToken, + refreshToken, + expiresIn: this.getExpiresInSeconds(this.jwtExpiresIn), + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + roles, + }, + tenant: { + id: tenant.id, + name: tenant.name, + }, + }; + } + + /** + * Registro de usuario + */ + async register(dto: RegisterDto): Promise { + // Verificar si el email ya existe + const existingUser = await this.userRepository.findOne({ + where: { email: dto.email } as any, + }); + + if (existingUser) { + throw new Error('Email already registered'); + } + + // Verificar que el tenant existe + const tenant = await this.tenantRepository.findOne({ + where: { id: dto.tenantId, isActive: true } as any, + }); + + if (!tenant) { + throw new Error('Tenant not found'); + } + + // Hash del password + const passwordHash = await bcrypt.hash(dto.password, 12); + + // Crear usuario + const user = await this.userRepository.save( + this.userRepository.create({ + email: dto.email, + passwordHash, + firstName: dto.firstName, + lastName: dto.lastName, + defaultTenantId: dto.tenantId, + isActive: true, + }) + ); + + // Generar tokens (rol default: user) + const roles = ['user']; + const accessToken = this.generateAccessToken(user, dto.tenantId, roles); + const refreshToken = await this.generateRefreshToken(user.id); + + return { + accessToken, + refreshToken, + expiresIn: this.getExpiresInSeconds(this.jwtExpiresIn), + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + roles, + }, + tenant: { + id: tenant.id, + name: tenant.name, + }, + }; + } + + /** + * Refresh de token + */ + async refresh(dto: RefreshTokenDto): Promise { + // Validar refresh token + const validation = this.validateToken(dto.refreshToken, 'refresh'); + if (!validation.valid || !validation.payload) { + throw new Error('Invalid refresh token'); + } + + // Verificar que el token no está revocado + const storedToken = await this.refreshTokenRepository.findOne({ + where: { token: dto.refreshToken, revokedAt: null } as any, + }); + + if (!storedToken || storedToken.expiresAt < new Date()) { + throw new Error('Refresh token expired or revoked'); + } + + // Obtener usuario + const user = await this.userRepository.findOne({ + where: { id: validation.payload.sub, deletedAt: null } as any, + relations: ['userRoles', 'userRoles.role'], + }); + + if (!user || !user.isActive) { + throw new Error('User not found or inactive'); + } + + // Obtener tenant + const tenant = await this.tenantRepository.findOne({ + where: { id: validation.payload.tenantId, isActive: true } as any, + }); + + if (!tenant) { + throw new Error('Tenant not found or inactive'); + } + + const roles = user.userRoles?.map((ur) => ur.role.code) || []; + + // Revocar token anterior + await this.refreshTokenRepository.update(storedToken.id, { revokedAt: new Date() }); + + // Generar nuevos tokens + const accessToken = this.generateAccessToken(user, tenant.id, roles); + const refreshToken = await this.generateRefreshToken(user.id); + + return { + accessToken, + refreshToken, + expiresIn: this.getExpiresInSeconds(this.jwtExpiresIn), + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + roles, + }, + tenant: { + id: tenant.id, + name: tenant.name, + }, + }; + } + + /** + * Logout - Revocar refresh token + */ + async logout(refreshToken: string): Promise { + await this.refreshTokenRepository.update( + { token: refreshToken } as any, + { revokedAt: new Date() } + ); + } + + /** + * Cambiar password + */ + async changePassword(userId: string, dto: ChangePasswordDto): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId } as any, + }); + + if (!user) { + throw new Error('User not found'); + } + + const isCurrentValid = await bcrypt.compare(dto.currentPassword, user.passwordHash); + if (!isCurrentValid) { + throw new Error('Current password is incorrect'); + } + + const newPasswordHash = await bcrypt.hash(dto.newPassword, 12); + await this.userRepository.update(userId, { passwordHash: newPasswordHash }); + + // Revocar todos los refresh tokens del usuario + await this.refreshTokenRepository.update( + { userId } as any, + { revokedAt: new Date() } + ); + } + + /** + * Validar access token + */ + validateAccessToken(token: string): TokenValidationResult { + return this.validateToken(token, 'access'); + } + + /** + * Validar token + */ + private validateToken(token: string, expectedType: 'access' | 'refresh'): TokenValidationResult { + try { + const payload = jwt.verify(token, this.jwtSecret) as TokenPayload; + + if (payload.type !== expectedType) { + return { valid: false, error: 'Invalid token type' }; + } + + return { valid: true, payload }; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return { valid: false, error: 'Token expired' }; + } + if (error instanceof jwt.JsonWebTokenError) { + return { valid: false, error: 'Invalid token' }; + } + return { valid: false, error: 'Token validation failed' }; + } + } + + /** + * Generar access token + */ + private generateAccessToken(user: User, tenantId: string, roles: string[]): string { + const payload: TokenPayload = { + sub: user.id, + email: user.email, + tenantId, + roles, + type: 'access', + }; + + return jwt.sign(payload, this.jwtSecret, { + expiresIn: this.jwtExpiresIn as jwt.SignOptions['expiresIn'], + }); + } + + /** + * Generar refresh token + */ + private async generateRefreshToken(userId: string): Promise { + const payload: Partial = { + sub: userId, + type: 'refresh', + }; + + const token = jwt.sign(payload, this.jwtSecret, { + expiresIn: this.jwtRefreshExpiresIn as jwt.SignOptions['expiresIn'], + }); + + // Almacenar en DB + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); // 7 días + + await this.refreshTokenRepository.save( + this.refreshTokenRepository.create({ + userId, + token, + expiresAt, + }) + ); + + return token; + } + + /** + * Convertir expiresIn a segundos + */ + private getExpiresInSeconds(expiresIn: string): number { + const match = expiresIn.match(/^(\d+)([dhms])$/); + if (!match) return 86400; // default 1 día + + const value = parseInt(match[1]); + const unit = match[2]; + + switch (unit) { + case 'd': return value * 86400; + case 'h': return value * 3600; + case 'm': return value * 60; + case 's': return value; + default: return 86400; + } + } +} diff --git a/src/modules/auth/services/index.ts b/src/modules/auth/services/index.ts new file mode 100644 index 0000000..7255de3 --- /dev/null +++ b/src/modules/auth/services/index.ts @@ -0,0 +1,5 @@ +/** + * Auth Module - Service Exports + */ + +export * from './auth.service'; diff --git a/src/modules/bidding/controllers/bid-analytics.controller.ts b/src/modules/bidding/controllers/bid-analytics.controller.ts new file mode 100644 index 0000000..05b6c45 --- /dev/null +++ b/src/modules/bidding/controllers/bid-analytics.controller.ts @@ -0,0 +1,175 @@ +/** + * BidAnalyticsController - Controller de Análisis de Licitaciones + * + * Endpoints REST para dashboards y análisis de preconstrucción. + * + * @module Bidding + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { BidAnalyticsService } from '../services/bid-analytics.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Bid } from '../entities/bid.entity'; +import { Opportunity } from '../entities/opportunity.entity'; +import { BidCompetitor } from '../entities/bid-competitor.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createBidAnalyticsController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const bidRepository = dataSource.getRepository(Bid); + const opportunityRepository = dataSource.getRepository(Opportunity); + const competitorRepository = dataSource.getRepository(BidCompetitor); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const analyticsService = new BidAnalyticsService(bidRepository, opportunityRepository, competitorRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /bid-analytics/dashboard + */ + router.get('/dashboard', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dashboard = await analyticsService.getDashboard(getContext(req)); + res.status(200).json({ success: true, data: dashboard }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-analytics/pipeline-by-source + */ + router.get('/pipeline-by-source', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const data = await analyticsService.getPipelineBySource(getContext(req)); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-analytics/win-rate-by-type + */ + router.get('/win-rate-by-type', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const months = parseInt(req.query.months as string) || 12; + const data = await analyticsService.getWinRateByType(getContext(req), months); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-analytics/monthly-trend + */ + router.get('/monthly-trend', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const months = parseInt(req.query.months as string) || 12; + const data = await analyticsService.getMonthlyTrend(getContext(req), months); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-analytics/competitors + */ + router.get('/competitors', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const data = await analyticsService.getCompetitorAnalysis(getContext(req)); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-analytics/funnel + */ + router.get('/funnel', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const months = parseInt(req.query.months as string) || 12; + const data = await analyticsService.getFunnelAnalysis(getContext(req), months); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-analytics/cycle-time + */ + router.get('/cycle-time', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const months = parseInt(req.query.months as string) || 12; + const data = await analyticsService.getCycleTimeAnalysis(getContext(req), months); + res.status(200).json({ success: true, data }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createBidAnalyticsController; diff --git a/src/modules/bidding/controllers/bid-budget.controller.ts b/src/modules/bidding/controllers/bid-budget.controller.ts new file mode 100644 index 0000000..b1227ff --- /dev/null +++ b/src/modules/bidding/controllers/bid-budget.controller.ts @@ -0,0 +1,254 @@ +/** + * BidBudgetController - Controller de Presupuestos de Licitación + * + * Endpoints REST para gestión de propuestas económicas. + * + * @module Bidding + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { BidBudgetService, CreateBudgetItemDto, UpdateBudgetItemDto, BudgetFilters } from '../services/bid-budget.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { BidBudget } from '../entities/bid-budget.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createBidBudgetController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const budgetRepository = dataSource.getRepository(BidBudget); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const budgetService = new BidBudgetService(budgetRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /bid-budgets + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const bidId = req.query.bidId as string; + if (!bidId) { + res.status(400).json({ error: 'Bad Request', message: 'bidId is required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 100, 500); + + const filters: BudgetFilters = { bidId }; + if (req.query.itemType) filters.itemType = req.query.itemType as any; + if (req.query.status) filters.status = req.query.status as any; + if (req.query.parentId !== undefined) { + filters.parentId = req.query.parentId === 'null' ? null : req.query.parentId as string; + } + if (req.query.isSummary !== undefined) filters.isSummary = req.query.isSummary === 'true'; + + const result = await budgetService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-budgets/tree + */ + router.get('/tree', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const bidId = req.query.bidId as string; + if (!bidId) { + res.status(400).json({ error: 'Bad Request', message: 'bidId is required' }); + return; + } + + const tree = await budgetService.getTree(getContext(req), bidId); + res.status(200).json({ success: true, data: tree }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-budgets/summary + */ + router.get('/summary', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const bidId = req.query.bidId as string; + if (!bidId) { + res.status(400).json({ error: 'Bad Request', message: 'bidId is required' }); + return; + } + + const summary = await budgetService.getSummary(getContext(req), bidId); + res.status(200).json({ success: true, data: summary }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bid-budgets/:id + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const item = await budgetService.findById(getContext(req), req.params.id); + if (!item) { + res.status(404).json({ error: 'Not Found', message: 'Budget item not found' }); + return; + } + + res.status(200).json({ success: true, data: item }); + } catch (error) { + next(error); + } + }); + + /** + * POST /bid-budgets + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'costos'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateBudgetItemDto = req.body; + if (!dto.bidId || !dto.code || !dto.name || !dto.itemType) { + res.status(400).json({ + error: 'Bad Request', + message: 'bidId, code, name, and itemType are required', + }); + return; + } + + const item = await budgetService.create(getContext(req), dto); + res.status(201).json({ success: true, data: item }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /bid-budgets/:id + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'costos'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateBudgetItemDto = req.body; + const item = await budgetService.update(getContext(req), req.params.id, dto); + + if (!item) { + res.status(404).json({ error: 'Not Found', message: 'Budget item not found' }); + return; + } + + res.status(200).json({ success: true, data: item }); + } catch (error) { + next(error); + } + }); + + /** + * POST /bid-budgets/status + */ + router.post('/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { bidId, status } = req.body; + if (!bidId || !status) { + res.status(400).json({ error: 'Bad Request', message: 'bidId and status are required' }); + return; + } + + const updated = await budgetService.changeStatus(getContext(req), bidId, status); + res.status(200).json({ + success: true, + message: `Updated ${updated} budget items`, + data: { updated }, + }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /bid-budgets/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await budgetService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Budget item not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Budget item deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createBidBudgetController; diff --git a/src/modules/bidding/controllers/bid.controller.ts b/src/modules/bidding/controllers/bid.controller.ts new file mode 100644 index 0000000..6ab1122 --- /dev/null +++ b/src/modules/bidding/controllers/bid.controller.ts @@ -0,0 +1,370 @@ +/** + * BidController - Controller de Licitaciones + * + * Endpoints REST para gestión de licitaciones/propuestas. + * + * @module Bidding + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { BidService, CreateBidDto, UpdateBidDto, BidFilters } from '../services/bid.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Bid, BidStatus } from '../entities/bid.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createBidController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const bidRepository = dataSource.getRepository(Bid); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const bidService = new BidService(bidRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /bids + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: BidFilters = {}; + if (req.query.status) { + const statuses = (req.query.status as string).split(',') as BidStatus[]; + filters.status = statuses.length === 1 ? statuses[0] : statuses; + } + if (req.query.bidType) filters.bidType = req.query.bidType as any; + if (req.query.stage) filters.stage = req.query.stage as any; + if (req.query.opportunityId) filters.opportunityId = req.query.opportunityId as string; + if (req.query.bidManagerId) filters.bidManagerId = req.query.bidManagerId as string; + if (req.query.contractingEntity) filters.contractingEntity = req.query.contractingEntity as string; + if (req.query.deadlineFrom) filters.deadlineFrom = new Date(req.query.deadlineFrom as string); + if (req.query.deadlineTo) filters.deadlineTo = new Date(req.query.deadlineTo as string); + if (req.query.minBudget) filters.minBudget = parseFloat(req.query.minBudget as string); + if (req.query.maxBudget) filters.maxBudget = parseFloat(req.query.maxBudget as string); + if (req.query.search) filters.search = req.query.search as string; + + const result = await bidService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bids/upcoming-deadlines + */ + router.get('/upcoming-deadlines', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const days = parseInt(req.query.days as string) || 7; + const bids = await bidService.getUpcomingDeadlines(getContext(req), days); + res.status(200).json({ success: true, data: bids }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bids/stats + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const year = req.query.year ? parseInt(req.query.year as string) : undefined; + const stats = await bidService.getStats(getContext(req), year); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bids/:id + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const bid = await bidService.findById(getContext(req), req.params.id); + if (!bid) { + res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); + return; + } + + res.status(200).json({ success: true, data: bid }); + } catch (error) { + next(error); + } + }); + + /** + * POST /bids + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateBidDto = req.body; + if (!dto.opportunityId || !dto.code || !dto.name || !dto.bidType || !dto.submissionDeadline) { + res.status(400).json({ + error: 'Bad Request', + message: 'opportunityId, code, name, bidType, and submissionDeadline are required', + }); + return; + } + + const bid = await bidService.create(getContext(req), dto); + res.status(201).json({ success: true, data: bid }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /bids/:id + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateBidDto = req.body; + const bid = await bidService.update(getContext(req), req.params.id, dto); + + if (!bid) { + res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); + return; + } + + res.status(200).json({ success: true, data: bid }); + } catch (error) { + next(error); + } + }); + + /** + * POST /bids/:id/status + */ + router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { status } = req.body; + if (!status) { + res.status(400).json({ error: 'Bad Request', message: 'status is required' }); + return; + } + + const bid = await bidService.changeStatus(getContext(req), req.params.id, status); + + if (!bid) { + res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); + return; + } + + res.status(200).json({ success: true, data: bid }); + } catch (error) { + next(error); + } + }); + + /** + * POST /bids/:id/stage + */ + router.post('/:id/stage', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { stage } = req.body; + if (!stage) { + res.status(400).json({ error: 'Bad Request', message: 'stage is required' }); + return; + } + + const bid = await bidService.changeStage(getContext(req), req.params.id, stage); + + if (!bid) { + res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); + return; + } + + res.status(200).json({ success: true, data: bid }); + } catch (error) { + next(error); + } + }); + + /** + * POST /bids/:id/submit + */ + router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { proposalAmount } = req.body; + if (!proposalAmount) { + res.status(400).json({ error: 'Bad Request', message: 'proposalAmount is required' }); + return; + } + + const bid = await bidService.submit(getContext(req), req.params.id, proposalAmount); + + if (!bid) { + res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); + return; + } + + res.status(200).json({ success: true, data: bid }); + } catch (error) { + next(error); + } + }); + + /** + * POST /bids/:id/result + */ + router.post('/:id/result', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { won, winnerName, winningAmount, rankingPosition, rejectionReason, lessonsLearned } = req.body; + if (won === undefined) { + res.status(400).json({ error: 'Bad Request', message: 'won is required' }); + return; + } + + const bid = await bidService.recordResult(getContext(req), req.params.id, won, { + winnerName, + winningAmount, + rankingPosition, + rejectionReason, + lessonsLearned, + }); + + if (!bid) { + res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); + return; + } + + res.status(200).json({ success: true, data: bid }); + } catch (error) { + next(error); + } + }); + + /** + * POST /bids/:id/convert + */ + router.post('/:id/convert', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { projectId } = req.body; + if (!projectId) { + res.status(400).json({ error: 'Bad Request', message: 'projectId is required' }); + return; + } + + const bid = await bidService.convertToProject(getContext(req), req.params.id, projectId); + + if (!bid) { + res.status(404).json({ error: 'Not Found', message: 'Bid not found or not awarded' }); + return; + } + + res.status(200).json({ success: true, data: bid }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /bids/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await bidService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Bid not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Bid deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createBidController; diff --git a/src/modules/bidding/controllers/index.ts b/src/modules/bidding/controllers/index.ts new file mode 100644 index 0000000..f3eb096 --- /dev/null +++ b/src/modules/bidding/controllers/index.ts @@ -0,0 +1,9 @@ +/** + * Bidding Controllers Index + * @module Bidding + */ + +export { createOpportunityController } from './opportunity.controller'; +export { createBidController } from './bid.controller'; +export { createBidBudgetController } from './bid-budget.controller'; +export { createBidAnalyticsController } from './bid-analytics.controller'; diff --git a/src/modules/bidding/controllers/opportunity.controller.ts b/src/modules/bidding/controllers/opportunity.controller.ts new file mode 100644 index 0000000..ce35ed5 --- /dev/null +++ b/src/modules/bidding/controllers/opportunity.controller.ts @@ -0,0 +1,266 @@ +/** + * OpportunityController - Controller de Oportunidades + * + * Endpoints REST para gestión del pipeline de oportunidades. + * + * @module Bidding + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from '../services/opportunity.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Opportunity, OpportunityStatus } from '../entities/opportunity.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createOpportunityController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const opportunityRepository = dataSource.getRepository(Opportunity); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const opportunityService = new OpportunityService(opportunityRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /opportunities + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: OpportunityFilters = {}; + if (req.query.status) { + const statuses = (req.query.status as string).split(',') as OpportunityStatus[]; + filters.status = statuses.length === 1 ? statuses[0] : statuses; + } + if (req.query.source) filters.source = req.query.source as any; + if (req.query.projectType) filters.projectType = req.query.projectType as any; + if (req.query.priority) filters.priority = req.query.priority as any; + if (req.query.assignedToId) filters.assignedToId = req.query.assignedToId as string; + if (req.query.clientName) filters.clientName = req.query.clientName as string; + if (req.query.state) filters.state = req.query.state as string; + if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string); + if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string); + if (req.query.minValue) filters.minValue = parseFloat(req.query.minValue as string); + if (req.query.maxValue) filters.maxValue = parseFloat(req.query.maxValue as string); + if (req.query.search) filters.search = req.query.search as string; + + const result = await opportunityService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /opportunities/pipeline + */ + router.get('/pipeline', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const pipeline = await opportunityService.getPipeline(getContext(req)); + res.status(200).json({ success: true, data: pipeline }); + } catch (error) { + next(error); + } + }); + + /** + * GET /opportunities/upcoming-deadlines + */ + router.get('/upcoming-deadlines', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const days = parseInt(req.query.days as string) || 7; + const opportunities = await opportunityService.getUpcomingDeadlines(getContext(req), days); + res.status(200).json({ success: true, data: opportunities }); + } catch (error) { + next(error); + } + }); + + /** + * GET /opportunities/stats + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const year = req.query.year ? parseInt(req.query.year as string) : undefined; + const stats = await opportunityService.getStats(getContext(req), year); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /opportunities/:id + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const opportunity = await opportunityService.findById(getContext(req), req.params.id); + if (!opportunity) { + res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' }); + return; + } + + res.status(200).json({ success: true, data: opportunity }); + } catch (error) { + next(error); + } + }); + + /** + * POST /opportunities + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateOpportunityDto = req.body; + if (!dto.code || !dto.name || !dto.source || !dto.projectType || !dto.clientName || !dto.identificationDate) { + res.status(400).json({ + error: 'Bad Request', + message: 'code, name, source, projectType, clientName, and identificationDate are required', + }); + return; + } + + const opportunity = await opportunityService.create(getContext(req), dto); + res.status(201).json({ success: true, data: opportunity }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /opportunities/:id + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateOpportunityDto = req.body; + const opportunity = await opportunityService.update(getContext(req), req.params.id, dto); + + if (!opportunity) { + res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' }); + return; + } + + res.status(200).json({ success: true, data: opportunity }); + } catch (error) { + next(error); + } + }); + + /** + * POST /opportunities/:id/status + */ + router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { status, reason } = req.body; + if (!status) { + res.status(400).json({ error: 'Bad Request', message: 'status is required' }); + return; + } + + const opportunity = await opportunityService.changeStatus(getContext(req), req.params.id, status, reason); + + if (!opportunity) { + res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' }); + return; + } + + res.status(200).json({ success: true, data: opportunity }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /opportunities/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await opportunityService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Opportunity deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createOpportunityController; diff --git a/src/modules/bidding/entities/bid-budget.entity.ts b/src/modules/bidding/entities/bid-budget.entity.ts new file mode 100644 index 0000000..86e2675 --- /dev/null +++ b/src/modules/bidding/entities/bid-budget.entity.ts @@ -0,0 +1,256 @@ +/** + * BidBudget Entity - Presupuesto de Licitación + * + * Desglose del presupuesto para la propuesta económica. + * + * @module Bidding + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Bid } from './bid.entity'; + +export type BudgetItemType = + | 'direct_cost' + | 'indirect_cost' + | 'labor' + | 'materials' + | 'equipment' + | 'subcontract' + | 'overhead' + | 'profit' + | 'contingency' + | 'financing' + | 'taxes' + | 'bonds' + | 'other'; + +export type BudgetStatus = 'draft' | 'calculated' | 'reviewed' | 'approved' | 'locked'; + +@Entity('bid_budget', { schema: 'bidding' }) +@Index(['tenantId', 'bidId']) +@Index(['tenantId', 'itemType']) +export class BidBudget { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Referencia a licitación + @Column({ name: 'bid_id', type: 'uuid' }) + bidId!: string; + + @ManyToOne(() => Bid, (bid) => bid.budgetItems) + @JoinColumn({ name: 'bid_id' }) + bid?: Bid; + + // Jerarquía + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId?: string; + + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder!: number; + + @Column({ type: 'int', default: 0 }) + level!: number; + + @Column({ length: 50 }) + code!: string; + + // Información del item + @Column({ length: 255 }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ + name: 'item_type', + type: 'enum', + enum: ['direct_cost', 'indirect_cost', 'labor', 'materials', 'equipment', 'subcontract', 'overhead', 'profit', 'contingency', 'financing', 'taxes', 'bonds', 'other'], + enumName: 'bid_budget_item_type', + }) + itemType!: BudgetItemType; + + @Column({ + type: 'enum', + enum: ['draft', 'calculated', 'reviewed', 'approved', 'locked'], + enumName: 'bid_budget_status', + default: 'draft', + }) + status!: BudgetStatus; + + // Unidad y cantidad + @Column({ length: 20, nullable: true }) + unit?: string; + + @Column({ + type: 'decimal', + precision: 18, + scale: 4, + default: 0, + }) + quantity!: number; + + // Precios + @Column({ + name: 'unit_price', + type: 'decimal', + precision: 18, + scale: 4, + default: 0, + }) + unitPrice!: number; + + @Column({ + name: 'total_amount', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + totalAmount!: number; + + // Desglose de costos directos + @Column({ + name: 'materials_cost', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + materialsCost?: number; + + @Column({ + name: 'labor_cost', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + laborCost?: number; + + @Column({ + name: 'equipment_cost', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + equipmentCost?: number; + + @Column({ + name: 'subcontract_cost', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + subcontractCost?: number; + + // Porcentajes + @Column({ + name: 'indirect_percentage', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + indirectPercentage?: number; + + @Column({ + name: 'profit_percentage', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + profitPercentage?: number; + + @Column({ + name: 'financing_percentage', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + financingPercentage?: number; + + // Comparación con base de licitación + @Column({ + name: 'base_amount', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + baseAmount?: number; + + @Column({ + name: 'variance_amount', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + varianceAmount?: number; + + @Column({ + name: 'variance_percentage', + type: 'decimal', + precision: 8, + scale: 2, + nullable: true, + }) + variancePercentage?: number; + + // Flags + @Column({ name: 'is_summary', type: 'boolean', default: false }) + isSummary!: boolean; + + @Column({ name: 'is_calculated', type: 'boolean', default: false }) + isCalculated!: boolean; + + @Column({ name: 'is_adjusted', type: 'boolean', default: false }) + isAdjusted!: boolean; + + @Column({ name: 'adjustment_reason', type: 'text', nullable: true }) + adjustmentReason?: string; + + // Referencia a concepto de catálogo + @Column({ name: 'catalog_concept_id', type: 'uuid', nullable: true }) + catalogConceptId?: string; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/bidding/entities/bid-calendar.entity.ts b/src/modules/bidding/entities/bid-calendar.entity.ts new file mode 100644 index 0000000..0b0a025 --- /dev/null +++ b/src/modules/bidding/entities/bid-calendar.entity.ts @@ -0,0 +1,188 @@ +/** + * BidCalendar Entity - Calendario de Licitación + * + * Eventos y fechas importantes del proceso de licitación. + * + * @module Bidding + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; +import { Bid } from './bid.entity'; + +export type CalendarEventType = + | 'publication' + | 'site_visit' + | 'clarification_meeting' + | 'clarification_deadline' + | 'submission_deadline' + | 'opening' + | 'technical_evaluation' + | 'economic_evaluation' + | 'award_notification' + | 'contract_signing' + | 'kick_off' + | 'milestone' + | 'internal_review' + | 'team_meeting' + | 'reminder' + | 'other'; + +export type EventPriority = 'low' | 'medium' | 'high' | 'critical'; + +export type EventStatus = 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'postponed'; + +@Entity('bid_calendar', { schema: 'bidding' }) +@Index(['tenantId', 'bidId']) +@Index(['tenantId', 'eventDate']) +@Index(['tenantId', 'eventType']) +export class BidCalendar { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Referencia a licitación + @Column({ name: 'bid_id', type: 'uuid' }) + bidId!: string; + + @ManyToOne(() => Bid, (bid) => bid.calendarEvents) + @JoinColumn({ name: 'bid_id' }) + bid?: Bid; + + // Información del evento + @Column({ length: 255 }) + title!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ + name: 'event_type', + type: 'enum', + enum: ['publication', 'site_visit', 'clarification_meeting', 'clarification_deadline', 'submission_deadline', 'opening', 'technical_evaluation', 'economic_evaluation', 'award_notification', 'contract_signing', 'kick_off', 'milestone', 'internal_review', 'team_meeting', 'reminder', 'other'], + enumName: 'calendar_event_type', + }) + eventType!: CalendarEventType; + + @Column({ + type: 'enum', + enum: ['low', 'medium', 'high', 'critical'], + enumName: 'event_priority', + default: 'medium', + }) + priority!: EventPriority; + + @Column({ + type: 'enum', + enum: ['scheduled', 'in_progress', 'completed', 'cancelled', 'postponed'], + enumName: 'event_status', + default: 'scheduled', + }) + status!: EventStatus; + + // Fechas y hora + @Column({ name: 'event_date', type: 'timestamptz' }) + eventDate!: Date; + + @Column({ name: 'end_date', type: 'timestamptz', nullable: true }) + endDate?: Date; + + @Column({ name: 'is_all_day', type: 'boolean', default: false }) + isAllDay!: boolean; + + @Column({ name: 'timezone', length: 50, default: 'America/Mexico_City' }) + timezone!: string; + + // Ubicación + @Column({ length: 255, nullable: true }) + location?: string; + + @Column({ name: 'is_virtual', type: 'boolean', default: false }) + isVirtual!: boolean; + + @Column({ name: 'meeting_link', length: 500, nullable: true }) + meetingLink?: string; + + // Recordatorios + @Column({ name: 'reminder_minutes', type: 'int', array: true, nullable: true }) + reminderMinutes?: number[]; + + @Column({ name: 'reminder_sent', type: 'boolean', default: false }) + reminderSent!: boolean; + + @Column({ name: 'last_reminder_at', type: 'timestamptz', nullable: true }) + lastReminderAt?: Date; + + // Asignación + @Column({ name: 'assigned_to_id', type: 'uuid', nullable: true }) + assignedToId?: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'assigned_to_id' }) + assignedTo?: User; + + @Column({ name: 'attendees', type: 'uuid', array: true, nullable: true }) + attendees?: string[]; + + // Resultado del evento + @Column({ name: 'outcome', type: 'text', nullable: true }) + outcome?: string; + + @Column({ name: 'action_items', type: 'jsonb', nullable: true }) + actionItems?: Record[]; + + // Recurrencia + @Column({ name: 'is_recurring', type: 'boolean', default: false }) + isRecurring!: boolean; + + @Column({ name: 'recurrence_rule', length: 255, nullable: true }) + recurrenceRule?: string; + + @Column({ name: 'parent_event_id', type: 'uuid', nullable: true }) + parentEventId?: string; + + // Flags + @Column({ name: 'is_mandatory', type: 'boolean', default: false }) + isMandatory!: boolean; + + @Column({ name: 'is_external', type: 'boolean', default: false }) + isExternal!: boolean; + + @Column({ name: 'requires_preparation', type: 'boolean', default: false }) + requiresPreparation!: boolean; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/bidding/entities/bid-competitor.entity.ts b/src/modules/bidding/entities/bid-competitor.entity.ts new file mode 100644 index 0000000..9779d7f --- /dev/null +++ b/src/modules/bidding/entities/bid-competitor.entity.ts @@ -0,0 +1,203 @@ +/** + * BidCompetitor Entity - Competidores en Licitación + * + * Información de competidores en el proceso de licitación. + * + * @module Bidding + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Bid } from './bid.entity'; + +export type CompetitorStatus = + | 'identified' + | 'registered' + | 'qualified' + | 'disqualified' + | 'withdrew' + | 'submitted' + | 'winner' + | 'loser'; + +export type ThreatLevel = 'low' | 'medium' | 'high' | 'critical'; + +@Entity('bid_competitors', { schema: 'bidding' }) +@Index(['tenantId', 'bidId']) +export class BidCompetitor { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Referencia a licitación + @Column({ name: 'bid_id', type: 'uuid' }) + bidId!: string; + + @ManyToOne(() => Bid, (bid) => bid.competitors) + @JoinColumn({ name: 'bid_id' }) + bid?: Bid; + + // Información del competidor + @Column({ name: 'company_name', length: 255 }) + companyName!: string; + + @Column({ name: 'trade_name', length: 255, nullable: true }) + tradeName?: string; + + @Column({ name: 'rfc', length: 13, nullable: true }) + rfc?: string; + + @Column({ name: 'contact_name', length: 255, nullable: true }) + contactName?: string; + + @Column({ name: 'contact_email', length: 255, nullable: true }) + contactEmail?: string; + + @Column({ name: 'contact_phone', length: 50, nullable: true }) + contactPhone?: string; + + @Column({ length: 255, nullable: true }) + website?: string; + + // Estado y análisis + @Column({ + type: 'enum', + enum: ['identified', 'registered', 'qualified', 'disqualified', 'withdrew', 'submitted', 'winner', 'loser'], + enumName: 'competitor_status', + default: 'identified', + }) + status!: CompetitorStatus; + + @Column({ + name: 'threat_level', + type: 'enum', + enum: ['low', 'medium', 'high', 'critical'], + enumName: 'competitor_threat_level', + default: 'medium', + }) + threatLevel!: ThreatLevel; + + // Capacidades conocidas + @Column({ + name: 'estimated_annual_revenue', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + estimatedAnnualRevenue?: number; + + @Column({ name: 'employee_count', type: 'int', nullable: true }) + employeeCount?: number; + + @Column({ name: 'years_in_business', type: 'int', nullable: true }) + yearsInBusiness?: number; + + @Column({ name: 'certifications', type: 'text', array: true, nullable: true }) + certifications?: string[]; + + @Column({ name: 'specializations', type: 'text', array: true, nullable: true }) + specializations?: string[]; + + // Histórico de competencia + @Column({ name: 'previous_encounters', type: 'int', default: 0 }) + previousEncounters!: number; + + @Column({ name: 'wins_against', type: 'int', default: 0 }) + winsAgainst!: number; + + @Column({ name: 'losses_against', type: 'int', default: 0 }) + lossesAgainst!: number; + + // Información de propuesta (si es pública) + @Column({ + name: 'proposed_amount', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + proposedAmount?: number; + + @Column({ + name: 'technical_score', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + technicalScore?: number; + + @Column({ + name: 'economic_score', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + economicScore?: number; + + @Column({ + name: 'final_score', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + finalScore?: number; + + @Column({ name: 'ranking_position', type: 'int', nullable: true }) + rankingPosition?: number; + + // Fortalezas y debilidades + @Column({ type: 'text', array: true, nullable: true }) + strengths?: string[]; + + @Column({ type: 'text', array: true, nullable: true }) + weaknesses?: string[]; + + // Análisis FODA resumido + @Column({ name: 'competitive_advantage', type: 'text', nullable: true }) + competitiveAdvantage?: string; + + @Column({ name: 'vulnerability', type: 'text', nullable: true }) + vulnerability?: string; + + // Razón de descalificación/retiro + @Column({ name: 'disqualification_reason', type: 'text', nullable: true }) + disqualificationReason?: string; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/bidding/entities/bid-document.entity.ts b/src/modules/bidding/entities/bid-document.entity.ts new file mode 100644 index 0000000..e7f340c --- /dev/null +++ b/src/modules/bidding/entities/bid-document.entity.ts @@ -0,0 +1,170 @@ +/** + * BidDocument Entity - Documentos de Licitación + * + * Almacena documentos asociados a una licitación. + * + * @module Bidding + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; +import { Bid } from './bid.entity'; + +export type DocumentCategory = + | 'tender_bases' + | 'clarifications' + | 'annexes' + | 'technical_proposal' + | 'economic_proposal' + | 'legal_documents' + | 'experience_certificates' + | 'financial_statements' + | 'bonds' + | 'contracts' + | 'correspondence' + | 'meeting_minutes' + | 'other'; + +export type DocumentStatus = 'draft' | 'pending_review' | 'approved' | 'rejected' | 'submitted' | 'archived'; + +@Entity('bid_documents', { schema: 'bidding' }) +@Index(['tenantId', 'bidId']) +@Index(['tenantId', 'category']) +export class BidDocument { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Referencia a licitación + @Column({ name: 'bid_id', type: 'uuid' }) + bidId!: string; + + @ManyToOne(() => Bid, (bid) => bid.documents) + @JoinColumn({ name: 'bid_id' }) + bid?: Bid; + + // Información del documento + @Column({ length: 255 }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ + type: 'enum', + enum: ['tender_bases', 'clarifications', 'annexes', 'technical_proposal', 'economic_proposal', 'legal_documents', 'experience_certificates', 'financial_statements', 'bonds', 'contracts', 'correspondence', 'meeting_minutes', 'other'], + enumName: 'bid_document_category', + }) + category!: DocumentCategory; + + @Column({ + type: 'enum', + enum: ['draft', 'pending_review', 'approved', 'rejected', 'submitted', 'archived'], + enumName: 'bid_document_status', + default: 'draft', + }) + status!: DocumentStatus; + + // Archivo + @Column({ name: 'file_path', length: 500 }) + filePath!: string; + + @Column({ name: 'file_name', length: 255 }) + fileName!: string; + + @Column({ name: 'file_type', length: 100 }) + fileType!: string; + + @Column({ name: 'file_size', type: 'bigint' }) + fileSize!: number; + + @Column({ name: 'mime_type', length: 100, nullable: true }) + mimeType?: string; + + // Versión + @Column({ type: 'int', default: 1 }) + version!: number; + + @Column({ name: 'is_current_version', type: 'boolean', default: true }) + isCurrentVersion!: boolean; + + @Column({ name: 'previous_version_id', type: 'uuid', nullable: true }) + previousVersionId?: string; + + // Metadatos de revisión + @Column({ name: 'reviewed_by_id', type: 'uuid', nullable: true }) + reviewedById?: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'reviewed_by_id' }) + reviewedBy?: User; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date; + + @Column({ name: 'review_comments', type: 'text', nullable: true }) + reviewComments?: string; + + // Flags + @Column({ name: 'is_required', type: 'boolean', default: false }) + isRequired!: boolean; + + @Column({ name: 'is_confidential', type: 'boolean', default: false }) + isConfidential!: boolean; + + @Column({ name: 'is_submitted', type: 'boolean', default: false }) + isSubmitted!: boolean; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt?: Date; + + // Fecha de vencimiento (para documentos con vigencia) + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate?: Date; + + // Hash para verificación de integridad + @Column({ name: 'file_hash', length: 128, nullable: true }) + fileHash?: string; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'uploaded_by_id', type: 'uuid', nullable: true }) + uploadedById?: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'uploaded_by_id' }) + uploadedBy?: User; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/bidding/entities/bid-team.entity.ts b/src/modules/bidding/entities/bid-team.entity.ts new file mode 100644 index 0000000..d802f37 --- /dev/null +++ b/src/modules/bidding/entities/bid-team.entity.ts @@ -0,0 +1,176 @@ +/** + * BidTeam Entity - Equipo de Licitación + * + * Miembros del equipo asignados a una licitación. + * + * @module Bidding + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; +import { Bid } from './bid.entity'; + +export type TeamRole = + | 'bid_manager' + | 'technical_lead' + | 'cost_engineer' + | 'legal_advisor' + | 'commercial_manager' + | 'project_manager' + | 'quality_manager' + | 'hse_manager' + | 'procurement_lead' + | 'design_lead' + | 'reviewer' + | 'contributor' + | 'support'; + +export type MemberStatus = 'active' | 'inactive' | 'pending' | 'removed'; + +@Entity('bid_team', { schema: 'bidding' }) +@Index(['tenantId', 'bidId']) +@Index(['tenantId', 'userId']) +export class BidTeam { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Referencia a licitación + @Column({ name: 'bid_id', type: 'uuid' }) + bidId!: string; + + @ManyToOne(() => Bid, (bid) => bid.teamMembers) + @JoinColumn({ name: 'bid_id' }) + bid?: Bid; + + // Referencia a usuario + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user?: User; + + // Rol y responsabilidades + @Column({ + type: 'enum', + enum: ['bid_manager', 'technical_lead', 'cost_engineer', 'legal_advisor', 'commercial_manager', 'project_manager', 'quality_manager', 'hse_manager', 'procurement_lead', 'design_lead', 'reviewer', 'contributor', 'support'], + enumName: 'bid_team_role', + }) + role!: TeamRole; + + @Column({ + type: 'enum', + enum: ['active', 'inactive', 'pending', 'removed'], + enumName: 'bid_team_status', + default: 'active', + }) + status!: MemberStatus; + + @Column({ type: 'text', array: true, nullable: true }) + responsibilities?: string[]; + + // Dedicación + @Column({ + name: 'allocation_percentage', + type: 'decimal', + precision: 5, + scale: 2, + default: 100, + }) + allocationPercentage!: number; + + @Column({ name: 'estimated_hours', type: 'decimal', precision: 8, scale: 2, nullable: true }) + estimatedHours?: number; + + @Column({ name: 'actual_hours', type: 'decimal', precision: 8, scale: 2, default: 0 }) + actualHours!: number; + + // Fechas de participación + @Column({ name: 'start_date', type: 'date' }) + startDate!: Date; + + @Column({ name: 'end_date', type: 'date', nullable: true }) + endDate?: Date; + + // Permisos específicos + @Column({ name: 'can_edit_technical', type: 'boolean', default: false }) + canEditTechnical!: boolean; + + @Column({ name: 'can_edit_economic', type: 'boolean', default: false }) + canEditEconomic!: boolean; + + @Column({ name: 'can_approve', type: 'boolean', default: false }) + canApprove!: boolean; + + @Column({ name: 'can_submit', type: 'boolean', default: false }) + canSubmit!: boolean; + + // Notificaciones + @Column({ name: 'receive_notifications', type: 'boolean', default: true }) + receiveNotifications!: boolean; + + @Column({ name: 'notification_preferences', type: 'jsonb', nullable: true }) + notificationPreferences?: Record; + + // Evaluación de participación + @Column({ + name: 'performance_rating', + type: 'decimal', + precision: 3, + scale: 2, + nullable: true, + }) + performanceRating?: number; + + @Column({ name: 'performance_notes', type: 'text', nullable: true }) + performanceNotes?: string; + + // Información de contacto externa (si no es empleado) + @Column({ name: 'is_external', type: 'boolean', default: false }) + isExternal!: boolean; + + @Column({ name: 'external_company', length: 255, nullable: true }) + externalCompany?: string; + + @Column({ name: 'external_email', length: 255, nullable: true }) + externalEmail?: string; + + @Column({ name: 'external_phone', length: 50, nullable: true }) + externalPhone?: string; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/bidding/entities/bid.entity.ts b/src/modules/bidding/entities/bid.entity.ts new file mode 100644 index 0000000..7712ea8 --- /dev/null +++ b/src/modules/bidding/entities/bid.entity.ts @@ -0,0 +1,311 @@ +/** + * Bid Entity - Licitaciones/Propuestas + * + * Representa una licitación o propuesta formal vinculada a una oportunidad. + * + * @module Bidding + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; +import { Opportunity } from './opportunity.entity'; +import { BidDocument } from './bid-document.entity'; +import { BidCalendar } from './bid-calendar.entity'; +import { BidBudget } from './bid-budget.entity'; +import { BidCompetitor } from './bid-competitor.entity'; +import { BidTeam } from './bid-team.entity'; + +export type BidType = 'public' | 'private' | 'invitation' | 'direct_award' | 'framework_agreement'; + +export type BidStatus = + | 'draft' + | 'preparation' + | 'review' + | 'approved' + | 'submitted' + | 'clarification' + | 'evaluation' + | 'awarded' + | 'rejected' + | 'cancelled' + | 'withdrawn'; + +export type BidStage = + | 'initial' + | 'technical_proposal' + | 'economic_proposal' + | 'final_submission' + | 'post_submission'; + +@Entity('bids', { schema: 'bidding' }) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'bidType']) +@Index(['tenantId', 'opportunityId']) +export class Bid { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Referencia a oportunidad + @Column({ name: 'opportunity_id', type: 'uuid' }) + opportunityId!: string; + + @ManyToOne(() => Opportunity, (opp) => opp.bids) + @JoinColumn({ name: 'opportunity_id' }) + opportunity?: Opportunity; + + // Información básica + @Column({ length: 100 }) + code!: string; + + @Column({ length: 500 }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ + name: 'bid_type', + type: 'enum', + enum: ['public', 'private', 'invitation', 'direct_award', 'framework_agreement'], + enumName: 'bid_type', + }) + bidType!: BidType; + + @Column({ + type: 'enum', + enum: ['draft', 'preparation', 'review', 'approved', 'submitted', 'clarification', 'evaluation', 'awarded', 'rejected', 'cancelled', 'withdrawn'], + enumName: 'bid_status', + default: 'draft', + }) + status!: BidStatus; + + @Column({ + type: 'enum', + enum: ['initial', 'technical_proposal', 'economic_proposal', 'final_submission', 'post_submission'], + enumName: 'bid_stage', + default: 'initial', + }) + stage!: BidStage; + + // Referencia de convocatoria + @Column({ name: 'tender_number', length: 100, nullable: true }) + tenderNumber?: string; + + @Column({ name: 'tender_name', length: 500, nullable: true }) + tenderName?: string; + + @Column({ name: 'contracting_entity', length: 255, nullable: true }) + contractingEntity?: string; + + // Fechas clave + @Column({ name: 'publication_date', type: 'date', nullable: true }) + publicationDate?: Date; + + @Column({ name: 'site_visit_date', type: 'timestamptz', nullable: true }) + siteVisitDate?: Date; + + @Column({ name: 'clarification_deadline', type: 'timestamptz', nullable: true }) + clarificationDeadline?: Date; + + @Column({ name: 'submission_deadline', type: 'timestamptz' }) + submissionDeadline!: Date; + + @Column({ name: 'opening_date', type: 'timestamptz', nullable: true }) + openingDate?: Date; + + @Column({ name: 'award_date', type: 'date', nullable: true }) + awardDate?: Date; + + @Column({ name: 'contract_signing_date', type: 'date', nullable: true }) + contractSigningDate?: Date; + + // Montos + @Column({ + name: 'base_budget', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + baseBudget?: number; + + @Column({ + name: 'our_proposal_amount', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + ourProposalAmount?: number; + + @Column({ + name: 'winning_amount', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + winningAmount?: number; + + @Column({ name: 'currency', length: 3, default: 'MXN' }) + currency!: string; + + // Propuesta técnica + @Column({ + name: 'technical_score', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + technicalScore?: number; + + @Column({ + name: 'technical_weight', + type: 'decimal', + precision: 5, + scale: 2, + default: 50, + }) + technicalWeight!: number; + + // Propuesta económica + @Column({ + name: 'economic_score', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + economicScore?: number; + + @Column({ + name: 'economic_weight', + type: 'decimal', + precision: 5, + scale: 2, + default: 50, + }) + economicWeight!: number; + + // Puntuación final + @Column({ + name: 'final_score', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + finalScore?: number; + + @Column({ name: 'ranking_position', type: 'int', nullable: true }) + rankingPosition?: number; + + // Garantías + @Column({ + name: 'bid_bond_amount', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + bidBondAmount?: number; + + @Column({ name: 'bid_bond_number', length: 100, nullable: true }) + bidBondNumber?: string; + + @Column({ name: 'bid_bond_expiry', type: 'date', nullable: true }) + bidBondExpiry?: Date; + + // Asignación + @Column({ name: 'bid_manager_id', type: 'uuid', nullable: true }) + bidManagerId?: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'bid_manager_id' }) + bidManager?: User; + + // Resultado + @Column({ name: 'winner_name', length: 255, nullable: true }) + winnerName?: string; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason?: string; + + @Column({ name: 'lessons_learned', type: 'text', nullable: true }) + lessonsLearned?: string; + + // Progreso + @Column({ + name: 'completion_percentage', + type: 'decimal', + precision: 5, + scale: 2, + default: 0, + }) + completionPercentage!: number; + + // Checklist de documentos + @Column({ name: 'checklist', type: 'jsonb', nullable: true }) + checklist?: Record; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Relaciones + @OneToMany(() => BidDocument, (doc) => doc.bid) + documents?: BidDocument[]; + + @OneToMany(() => BidCalendar, (event) => event.bid) + calendarEvents?: BidCalendar[]; + + @OneToMany(() => BidBudget, (budget) => budget.bid) + budgetItems?: BidBudget[]; + + @OneToMany(() => BidCompetitor, (comp) => comp.bid) + competitors?: BidCompetitor[]; + + @OneToMany(() => BidTeam, (team) => team.bid) + teamMembers?: BidTeam[]; + + // Conversión a proyecto + @Column({ name: 'converted_to_project_id', type: 'uuid', nullable: true }) + convertedToProjectId?: string; + + @Column({ name: 'converted_at', type: 'timestamptz', nullable: true }) + convertedAt?: Date; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/bidding/entities/index.ts b/src/modules/bidding/entities/index.ts new file mode 100644 index 0000000..dee4637 --- /dev/null +++ b/src/modules/bidding/entities/index.ts @@ -0,0 +1,12 @@ +/** + * Bidding Entities Index + * @module Bidding + */ + +export { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority, ProjectType } from './opportunity.entity'; +export { Bid, BidType, BidStatus, BidStage } from './bid.entity'; +export { BidDocument, DocumentCategory, DocumentStatus } from './bid-document.entity'; +export { BidCalendar, CalendarEventType, EventPriority, EventStatus } from './bid-calendar.entity'; +export { BidBudget, BudgetItemType, BudgetStatus } from './bid-budget.entity'; +export { BidCompetitor, CompetitorStatus, ThreatLevel } from './bid-competitor.entity'; +export { BidTeam, TeamRole, MemberStatus } from './bid-team.entity'; diff --git a/src/modules/bidding/entities/opportunity.entity.ts b/src/modules/bidding/entities/opportunity.entity.ts new file mode 100644 index 0000000..b1b8813 --- /dev/null +++ b/src/modules/bidding/entities/opportunity.entity.ts @@ -0,0 +1,280 @@ +/** + * Opportunity Entity - Oportunidades de Negocio + * + * Representa oportunidades de licitación/proyecto en el pipeline comercial. + * + * @module Bidding + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; +import { Bid } from './bid.entity'; + +export type OpportunitySource = + | 'portal_compranet' + | 'portal_state' + | 'direct_invitation' + | 'referral' + | 'public_notice' + | 'networking' + | 'repeat_client' + | 'cold_call' + | 'website' + | 'other'; + +export type OpportunityStatus = + | 'identified' + | 'qualified' + | 'pursuing' + | 'bid_submitted' + | 'won' + | 'lost' + | 'cancelled' + | 'on_hold'; + +export type OpportunityPriority = 'low' | 'medium' | 'high' | 'critical'; + +export type ProjectType = + | 'residential' + | 'commercial' + | 'industrial' + | 'infrastructure' + | 'institutional' + | 'mixed_use' + | 'renovation' + | 'maintenance'; + +@Entity('opportunities', { schema: 'bidding' }) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'source']) +@Index(['tenantId', 'assignedToId']) +export class Opportunity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Información básica + @Column({ length: 100 }) + code!: string; + + @Column({ length: 500 }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ + type: 'enum', + enum: ['portal_compranet', 'portal_state', 'direct_invitation', 'referral', 'public_notice', 'networking', 'repeat_client', 'cold_call', 'website', 'other'], + enumName: 'opportunity_source', + }) + source!: OpportunitySource; + + @Column({ + type: 'enum', + enum: ['identified', 'qualified', 'pursuing', 'bid_submitted', 'won', 'lost', 'cancelled', 'on_hold'], + enumName: 'opportunity_status', + default: 'identified', + }) + status!: OpportunityStatus; + + @Column({ + type: 'enum', + enum: ['low', 'medium', 'high', 'critical'], + enumName: 'opportunity_priority', + default: 'medium', + }) + priority!: OpportunityPriority; + + @Column({ + name: 'project_type', + type: 'enum', + enum: ['residential', 'commercial', 'industrial', 'infrastructure', 'institutional', 'mixed_use', 'renovation', 'maintenance'], + enumName: 'project_type', + }) + projectType!: ProjectType; + + // Cliente/Convocante + @Column({ name: 'client_name', length: 255 }) + clientName!: string; + + @Column({ name: 'client_contact', length: 255, nullable: true }) + clientContact?: string; + + @Column({ name: 'client_email', length: 255, nullable: true }) + clientEmail?: string; + + @Column({ name: 'client_phone', length: 50, nullable: true }) + clientPhone?: string; + + @Column({ name: 'client_type', length: 50, nullable: true }) + clientType?: string; // 'gobierno_federal', 'gobierno_estatal', 'privado', etc. + + // Ubicación + @Column({ length: 255, nullable: true }) + location?: string; + + @Column({ length: 100, nullable: true }) + state?: string; + + @Column({ length: 100, nullable: true }) + city?: string; + + // Montos estimados + @Column({ + name: 'estimated_value', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + estimatedValue?: number; + + @Column({ name: 'currency', length: 3, default: 'MXN' }) + currency!: string; + + @Column({ + name: 'construction_area_m2', + type: 'decimal', + precision: 12, + scale: 2, + nullable: true, + }) + constructionAreaM2?: number; + + @Column({ + name: 'land_area_m2', + type: 'decimal', + precision: 12, + scale: 2, + nullable: true, + }) + landAreaM2?: number; + + // Fechas clave + @Column({ name: 'identification_date', type: 'date' }) + identificationDate!: Date; + + @Column({ name: 'deadline_date', type: 'timestamptz', nullable: true }) + deadlineDate?: Date; + + @Column({ name: 'expected_award_date', type: 'date', nullable: true }) + expectedAwardDate?: Date; + + @Column({ name: 'expected_start_date', type: 'date', nullable: true }) + expectedStartDate?: Date; + + @Column({ name: 'expected_duration_months', type: 'int', nullable: true }) + expectedDurationMonths?: number; + + // Probabilidad y análisis + @Column({ + name: 'win_probability', + type: 'decimal', + precision: 5, + scale: 2, + default: 0, + }) + winProbability!: number; + + @Column({ + name: 'weighted_value', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + weightedValue?: number; + + // Requisitos + @Column({ name: 'requires_bond', type: 'boolean', default: false }) + requiresBond!: boolean; + + @Column({ name: 'requires_experience', type: 'boolean', default: false }) + requiresExperience!: boolean; + + @Column({ + name: 'minimum_experience_years', + type: 'int', + nullable: true, + }) + minimumExperienceYears?: number; + + @Column({ + name: 'minimum_capital', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + minimumCapital?: number; + + @Column({ + name: 'required_certifications', + type: 'text', + array: true, + nullable: true, + }) + requiredCertifications?: string[]; + + // Asignación + @Column({ name: 'assigned_to_id', type: 'uuid', nullable: true }) + assignedToId?: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'assigned_to_id' }) + assignedTo?: User; + + // Razón de resultado + @Column({ name: 'loss_reason', type: 'text', nullable: true }) + lossReason?: string; + + @Column({ name: 'win_factors', type: 'text', nullable: true }) + winFactors?: string; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'source_url', length: 500, nullable: true }) + sourceUrl?: string; + + @Column({ name: 'source_reference', length: 255, nullable: true }) + sourceReference?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Relaciones + @OneToMany(() => Bid, (bid) => bid.opportunity) + bids?: Bid[]; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/bidding/services/bid-analytics.service.ts b/src/modules/bidding/services/bid-analytics.service.ts new file mode 100644 index 0000000..8d443f9 --- /dev/null +++ b/src/modules/bidding/services/bid-analytics.service.ts @@ -0,0 +1,385 @@ +/** + * BidAnalyticsService - Análisis y Reportes de Licitaciones + * + * Estadísticas, tendencias y análisis de competitividad. + * + * @module Bidding + */ + +import { Repository } from 'typeorm'; +import { ServiceContext } from '../../../shared/services/base.service'; +import { Bid, BidStatus, BidType } from '../entities/bid.entity'; +import { Opportunity, OpportunitySource, OpportunityStatus } from '../entities/opportunity.entity'; +import { BidCompetitor } from '../entities/bid-competitor.entity'; + +export class BidAnalyticsService { + constructor( + private readonly bidRepository: Repository, + private readonly opportunityRepository: Repository, + private readonly competitorRepository: Repository + ) {} + + /** + * Dashboard general de licitaciones + */ + async getDashboard(ctx: ServiceContext): Promise { + const now = new Date(); + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + // Oportunidades activas + const activeOpportunities = await this.opportunityRepository.count({ + where: { + tenantId: ctx.tenantId, + deletedAt: undefined, + status: 'pursuing' as OpportunityStatus, + }, + }); + + // Licitaciones activas + const activeBids = await this.bidRepository.count({ + where: { + tenantId: ctx.tenantId, + deletedAt: undefined, + status: 'preparation' as BidStatus, + }, + }); + + // Valor del pipeline + const pipelineValue = await this.opportunityRepository + .createQueryBuilder('o') + .select('SUM(o.weighted_value)', 'value') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['won', 'lost', 'cancelled'] }) + .getRawOne(); + + // Próximas fechas límite + const upcomingDeadlines = await this.bidRepository + .createQueryBuilder('b') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.status IN (:...activeStatuses)', { activeStatuses: ['draft', 'preparation', 'review', 'approved'] }) + .andWhere('b.submission_deadline >= :now', { now }) + .orderBy('b.submission_deadline', 'ASC') + .take(5) + .getMany(); + + // Win rate últimos 12 meses + const yearAgo = new Date(); + yearAgo.setFullYear(yearAgo.getFullYear() - 1); + + const winRateStats = await this.bidRepository + .createQueryBuilder('b') + .select('b.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'rejected'] }) + .andWhere('b.award_date >= :yearAgo', { yearAgo }) + .groupBy('b.status') + .getRawMany(); + + const awarded = winRateStats.find((s) => s.status === 'awarded')?.count || 0; + const rejected = winRateStats.find((s) => s.status === 'rejected')?.count || 0; + const totalClosed = parseInt(awarded) + parseInt(rejected); + const winRate = totalClosed > 0 ? (parseInt(awarded) / totalClosed) * 100 : 0; + + // Valor ganado este año + const startOfYear = new Date(now.getFullYear(), 0, 1); + const wonValue = await this.bidRepository + .createQueryBuilder('b') + .select('SUM(b.winning_amount)', 'value') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.status = :status', { status: 'awarded' }) + .andWhere('b.award_date >= :startOfYear', { startOfYear }) + .getRawOne(); + + return { + activeOpportunities, + activeBids, + pipelineValue: parseFloat(pipelineValue?.value) || 0, + upcomingDeadlines: upcomingDeadlines.map((b) => ({ + id: b.id, + name: b.name, + deadline: b.submissionDeadline, + status: b.status, + })), + winRate, + wonValueYTD: parseFloat(wonValue?.value) || 0, + }; + } + + /** + * Análisis de pipeline por fuente + */ + async getPipelineBySource(ctx: ServiceContext): Promise { + const result = await this.opportunityRepository + .createQueryBuilder('o') + .select('o.source', 'source') + .addSelect('COUNT(*)', 'count') + .addSelect('SUM(o.estimated_value)', 'totalValue') + .addSelect('SUM(o.weighted_value)', 'weightedValue') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['won', 'lost', 'cancelled'] }) + .groupBy('o.source') + .orderBy('SUM(o.weighted_value)', 'DESC') + .getRawMany(); + + return result.map((r) => ({ + source: r.source as OpportunitySource, + count: parseInt(r.count), + totalValue: parseFloat(r.totalValue) || 0, + weightedValue: parseFloat(r.weightedValue) || 0, + })); + } + + /** + * Análisis de win rate por tipo de licitación + */ + async getWinRateByType(ctx: ServiceContext, months = 12): Promise { + const fromDate = new Date(); + fromDate.setMonth(fromDate.getMonth() - months); + + const result = await this.bidRepository + .createQueryBuilder('b') + .select('b.bid_type', 'bidType') + .addSelect('COUNT(*) FILTER (WHERE b.status = \'awarded\')', 'won') + .addSelect('COUNT(*) FILTER (WHERE b.status IN (\'awarded\', \'rejected\'))', 'total') + .addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'wonValue') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.award_date >= :fromDate', { fromDate }) + .groupBy('b.bid_type') + .getRawMany(); + + return result.map((r) => ({ + bidType: r.bidType as BidType, + won: parseInt(r.won) || 0, + total: parseInt(r.total) || 0, + winRate: parseInt(r.total) > 0 ? (parseInt(r.won) / parseInt(r.total)) * 100 : 0, + wonValue: parseFloat(r.wonValue) || 0, + })); + } + + /** + * Tendencia mensual de oportunidades + */ + async getMonthlyTrend(ctx: ServiceContext, months = 12): Promise { + const fromDate = new Date(); + fromDate.setMonth(fromDate.getMonth() - months); + + const result = await this.opportunityRepository + .createQueryBuilder('o') + .select("TO_CHAR(o.identification_date, 'YYYY-MM')", 'month') + .addSelect('COUNT(*)', 'identified') + .addSelect('COUNT(*) FILTER (WHERE o.status = \'won\')', 'won') + .addSelect('COUNT(*) FILTER (WHERE o.status = \'lost\')', 'lost') + .addSelect('SUM(CASE WHEN o.status = \'won\' THEN o.estimated_value ELSE 0 END)', 'wonValue') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.identification_date >= :fromDate', { fromDate }) + .groupBy("TO_CHAR(o.identification_date, 'YYYY-MM')") + .orderBy("TO_CHAR(o.identification_date, 'YYYY-MM')", 'ASC') + .getRawMany(); + + return result.map((r) => ({ + month: r.month, + identified: parseInt(r.identified) || 0, + won: parseInt(r.won) || 0, + lost: parseInt(r.lost) || 0, + wonValue: parseFloat(r.wonValue) || 0, + })); + } + + /** + * Análisis de competidores + */ + async getCompetitorAnalysis(ctx: ServiceContext): Promise { + const result = await this.competitorRepository + .createQueryBuilder('c') + .select('c.company_name', 'companyName') + .addSelect('COUNT(*)', 'encounters') + .addSelect('SUM(CASE WHEN c.status = \'winner\' THEN 1 ELSE 0 END)', 'theirWins') + .addSelect('SUM(CASE WHEN c.status = \'loser\' THEN 1 ELSE 0 END)', 'ourWins') + .addSelect('AVG(c.proposed_amount)', 'avgProposedAmount') + .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('c.deleted_at IS NULL') + .groupBy('c.company_name') + .having('COUNT(*) >= 2') + .orderBy('COUNT(*)', 'DESC') + .take(20) + .getRawMany(); + + return result.map((r) => ({ + companyName: r.companyName, + encounters: parseInt(r.encounters) || 0, + theirWins: parseInt(r.theirWins) || 0, + ourWins: parseInt(r.ourWins) || 0, + winRateAgainst: parseInt(r.encounters) > 0 + ? (parseInt(r.ourWins) / parseInt(r.encounters)) * 100 + : 0, + avgProposedAmount: parseFloat(r.avgProposedAmount) || 0, + })); + } + + /** + * Análisis de conversión del funnel + */ + async getFunnelAnalysis(ctx: ServiceContext, months = 12): Promise { + const fromDate = new Date(); + fromDate.setMonth(fromDate.getMonth() - months); + + const baseQuery = this.opportunityRepository + .createQueryBuilder('o') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.identification_date >= :fromDate', { fromDate }); + + const identified = await baseQuery.clone().getCount(); + + const qualified = await baseQuery.clone() + .andWhere('o.status NOT IN (:...earlyStatuses)', { earlyStatuses: ['identified'] }) + .getCount(); + + const pursuing = await baseQuery.clone() + .andWhere('o.status IN (:...pursuitStatuses)', { pursuitStatuses: ['pursuing', 'bid_submitted', 'won', 'lost'] }) + .getCount(); + + const bidSubmitted = await baseQuery.clone() + .andWhere('o.status IN (:...submittedStatuses)', { submittedStatuses: ['bid_submitted', 'won', 'lost'] }) + .getCount(); + + const won = await baseQuery.clone() + .andWhere('o.status = :status', { status: 'won' }) + .getCount(); + + return { + identified, + qualified, + pursuing, + bidSubmitted, + won, + conversionRates: { + identifiedToQualified: identified > 0 ? (qualified / identified) * 100 : 0, + qualifiedToPursuing: qualified > 0 ? (pursuing / qualified) * 100 : 0, + pursuingToSubmitted: pursuing > 0 ? (bidSubmitted / pursuing) * 100 : 0, + submittedToWon: bidSubmitted > 0 ? (won / bidSubmitted) * 100 : 0, + overallConversion: identified > 0 ? (won / identified) * 100 : 0, + }, + }; + } + + /** + * Análisis de tiempos de ciclo + */ + async getCycleTimeAnalysis(ctx: ServiceContext, months = 12): Promise { + const fromDate = new Date(); + fromDate.setMonth(fromDate.getMonth() - months); + + const result = await this.opportunityRepository + .createQueryBuilder('o') + .select('AVG(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'avgDays') + .addSelect('MIN(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'minDays') + .addSelect('MAX(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'maxDays') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] }) + .andWhere('o.identification_date >= :fromDate', { fromDate }) + .getRawOne(); + + const byOutcome = await this.opportunityRepository + .createQueryBuilder('o') + .select('o.status', 'outcome') + .addSelect('AVG(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'avgDays') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] }) + .andWhere('o.identification_date >= :fromDate', { fromDate }) + .groupBy('o.status') + .getRawMany(); + + return { + overall: { + avgDays: Math.round(parseFloat(result?.avgDays) || 0), + minDays: Math.round(parseFloat(result?.minDays) || 0), + maxDays: Math.round(parseFloat(result?.maxDays) || 0), + }, + byOutcome: byOutcome.map((r) => ({ + outcome: r.outcome as 'won' | 'lost', + avgDays: Math.round(parseFloat(r.avgDays) || 0), + })), + }; + } +} + +// Types +export interface BidDashboard { + activeOpportunities: number; + activeBids: number; + pipelineValue: number; + upcomingDeadlines: { id: string; name: string; deadline: Date; status: BidStatus }[]; + winRate: number; + wonValueYTD: number; +} + +export interface PipelineBySource { + source: OpportunitySource; + count: number; + totalValue: number; + weightedValue: number; +} + +export interface WinRateByType { + bidType: BidType; + won: number; + total: number; + winRate: number; + wonValue: number; +} + +export interface MonthlyTrend { + month: string; + identified: number; + won: number; + lost: number; + wonValue: number; +} + +export interface CompetitorAnalysis { + companyName: string; + encounters: number; + theirWins: number; + ourWins: number; + winRateAgainst: number; + avgProposedAmount: number; +} + +export interface FunnelAnalysis { + identified: number; + qualified: number; + pursuing: number; + bidSubmitted: number; + won: number; + conversionRates: { + identifiedToQualified: number; + qualifiedToPursuing: number; + pursuingToSubmitted: number; + submittedToWon: number; + overallConversion: number; + }; +} + +export interface CycleTimeAnalysis { + overall: { + avgDays: number; + minDays: number; + maxDays: number; + }; + byOutcome: { + outcome: 'won' | 'lost'; + avgDays: number; + }[]; +} diff --git a/src/modules/bidding/services/bid-budget.service.ts b/src/modules/bidding/services/bid-budget.service.ts new file mode 100644 index 0000000..f156b70 --- /dev/null +++ b/src/modules/bidding/services/bid-budget.service.ts @@ -0,0 +1,388 @@ +/** + * BidBudgetService - Gestión de Presupuestos de Licitación + * + * CRUD y cálculos para propuestas económicas. + * + * @module Bidding + */ + +import { Repository } from 'typeorm'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { BidBudget, BudgetItemType, BudgetStatus } from '../entities/bid-budget.entity'; + +export interface CreateBudgetItemDto { + bidId: string; + parentId?: string; + code: string; + name: string; + description?: string; + itemType: BudgetItemType; + unit?: string; + quantity?: number; + unitPrice?: number; + materialsCost?: number; + laborCost?: number; + equipmentCost?: number; + subcontractCost?: number; + indirectPercentage?: number; + profitPercentage?: number; + financingPercentage?: number; + baseAmount?: number; + catalogConceptId?: string; + notes?: string; + metadata?: Record; +} + +export interface UpdateBudgetItemDto extends Partial { + status?: BudgetStatus; + adjustmentReason?: string; +} + +export interface BudgetFilters { + bidId: string; + itemType?: BudgetItemType; + status?: BudgetStatus; + parentId?: string | null; + isSummary?: boolean; +} + +export class BidBudgetService { + constructor(private readonly repository: Repository) {} + + /** + * Crear item de presupuesto + */ + async create(ctx: ServiceContext, data: CreateBudgetItemDto): Promise { + // Calcular nivel jerárquico + let level = 0; + if (data.parentId) { + const parent = await this.repository.findOne({ + where: { id: data.parentId, tenantId: ctx.tenantId }, + }); + if (parent) { + level = parent.level + 1; + } + } + + // Calcular orden + const lastItem = await this.repository + .createQueryBuilder('bb') + .where('bb.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('bb.bid_id = :bidId', { bidId: data.bidId }) + .andWhere(data.parentId ? 'bb.parent_id = :parentId' : 'bb.parent_id IS NULL', { parentId: data.parentId }) + .orderBy('bb.sort_order', 'DESC') + .getOne(); + + const sortOrder = lastItem ? lastItem.sortOrder + 1 : 0; + + // Calcular totales + const quantity = data.quantity || 0; + const unitPrice = data.unitPrice || 0; + const totalAmount = quantity * unitPrice; + + // Calcular varianza si hay base + let varianceAmount = null; + let variancePercentage = null; + if (data.baseAmount !== undefined && data.baseAmount > 0) { + varianceAmount = totalAmount - data.baseAmount; + variancePercentage = (varianceAmount / data.baseAmount) * 100; + } + + const item = this.repository.create({ + tenantId: ctx.tenantId, + bidId: data.bidId, + parentId: data.parentId, + code: data.code, + name: data.name, + description: data.description, + itemType: data.itemType, + unit: data.unit, + quantity: data.quantity || 0, + unitPrice: data.unitPrice || 0, + materialsCost: data.materialsCost, + laborCost: data.laborCost, + equipmentCost: data.equipmentCost, + subcontractCost: data.subcontractCost, + indirectPercentage: data.indirectPercentage, + profitPercentage: data.profitPercentage, + financingPercentage: data.financingPercentage, + baseAmount: data.baseAmount, + catalogConceptId: data.catalogConceptId, + notes: data.notes, + metadata: data.metadata, + level, + sortOrder, + totalAmount, + varianceAmount: varianceAmount ?? undefined, + variancePercentage: variancePercentage ?? undefined, + status: 'draft', + isSummary: false, + isCalculated: true, + createdBy: ctx.userId, + updatedBy: ctx.userId, + }); + + const saved = await this.repository.save(item); + + // Recalcular padres + if (data.parentId) { + await this.recalculateParent(ctx, data.parentId); + } + + return saved; + } + + /** + * Buscar por ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: undefined }, + }); + } + + /** + * Buscar items de un presupuesto + */ + async findByBid(ctx: ServiceContext, bidId: string): Promise { + return this.repository.find({ + where: { bidId, tenantId: ctx.tenantId, deletedAt: undefined }, + order: { sortOrder: 'ASC' }, + }); + } + + /** + * Buscar items con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: BudgetFilters, + page = 1, + limit = 100 + ): Promise> { + const qb = this.repository + .createQueryBuilder('bb') + .where('bb.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('bb.bid_id = :bidId', { bidId: filters.bidId }) + .andWhere('bb.deleted_at IS NULL'); + + if (filters.itemType) { + qb.andWhere('bb.item_type = :itemType', { itemType: filters.itemType }); + } + if (filters.status) { + qb.andWhere('bb.status = :status', { status: filters.status }); + } + if (filters.parentId !== undefined) { + if (filters.parentId === null) { + qb.andWhere('bb.parent_id IS NULL'); + } else { + qb.andWhere('bb.parent_id = :parentId', { parentId: filters.parentId }); + } + } + if (filters.isSummary !== undefined) { + qb.andWhere('bb.is_summary = :isSummary', { isSummary: filters.isSummary }); + } + + const skip = (page - 1) * limit; + qb.orderBy('bb.sort_order', 'ASC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Obtener árbol jerárquico + */ + async getTree(ctx: ServiceContext, bidId: string): Promise { + const items = await this.findByBid(ctx, bidId); + return this.buildTree(items); + } + + private buildTree(items: BidBudget[], parentId: string | null = null): (BidBudget & { children?: BidBudget[] })[] { + return items + .filter((item) => item.parentId === parentId) + .map((item) => ({ + ...item, + children: this.buildTree(items, item.id), + })); + } + + /** + * Actualizar item + */ + async update(ctx: ServiceContext, id: string, data: UpdateBudgetItemDto): Promise { + const item = await this.findById(ctx, id); + if (!item) return null; + + // Recalcular totales si cambian cantidad o precio + const quantity = data.quantity ?? item.quantity; + const unitPrice = data.unitPrice ?? item.unitPrice; + const totalAmount = quantity * unitPrice; + + // Recalcular varianza + const baseAmount = data.baseAmount ?? item.baseAmount; + let varianceAmount = item.varianceAmount; + let variancePercentage = item.variancePercentage; + if (baseAmount !== undefined && baseAmount > 0) { + varianceAmount = totalAmount - baseAmount; + variancePercentage = (varianceAmount / baseAmount) * 100; + } + + // Marcar como ajustado si hay razón + const isAdjusted = data.adjustmentReason ? true : item.isAdjusted; + + Object.assign(item, { + ...data, + totalAmount, + varianceAmount, + variancePercentage, + isAdjusted, + isCalculated: true, + updatedBy: ctx.userId, + }); + + const saved = await this.repository.save(item); + + // Recalcular padres + if (item.parentId) { + await this.recalculateParent(ctx, item.parentId); + } + + return saved; + } + + /** + * Recalcular item padre + */ + private async recalculateParent(ctx: ServiceContext, parentId: string): Promise { + const parent = await this.findById(ctx, parentId); + if (!parent) return; + + const children = await this.repository.find({ + where: { parentId, tenantId: ctx.tenantId, deletedAt: undefined }, + }); + + const totalAmount = children.reduce((sum, child) => sum + (Number(child.totalAmount) || 0), 0); + + parent.totalAmount = totalAmount; + parent.isSummary = children.length > 0; + parent.isCalculated = true; + parent.updatedBy = ctx.userId; + + // Recalcular varianza + if (parent.baseAmount !== undefined && parent.baseAmount > 0) { + parent.varianceAmount = totalAmount - Number(parent.baseAmount); + parent.variancePercentage = (parent.varianceAmount / Number(parent.baseAmount)) * 100; + } + + await this.repository.save(parent); + + // Recursivamente actualizar ancestros + if (parent.parentId) { + await this.recalculateParent(ctx, parent.parentId); + } + } + + /** + * Obtener resumen de presupuesto + */ + async getSummary(ctx: ServiceContext, bidId: string): Promise { + const items = await this.findByBid(ctx, bidId); + + const directCosts = items + .filter((i) => i.itemType === 'direct_cost' || i.itemType === 'labor' || i.itemType === 'materials' || i.itemType === 'equipment' || i.itemType === 'subcontract') + .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); + + const indirectCosts = items + .filter((i) => i.itemType === 'indirect_cost' || i.itemType === 'overhead') + .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); + + const profit = items + .filter((i) => i.itemType === 'profit') + .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); + + const financing = items + .filter((i) => i.itemType === 'financing') + .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); + + const taxes = items + .filter((i) => i.itemType === 'taxes') + .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); + + const bonds = items + .filter((i) => i.itemType === 'bonds') + .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); + + const contingency = items + .filter((i) => i.itemType === 'contingency') + .reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0); + + const subtotal = directCosts + indirectCosts + profit + financing + contingency + bonds; + const total = subtotal + taxes; + + const baseTotal = items.reduce((sum, i) => sum + (Number(i.baseAmount) || 0), 0); + + return { + directCosts, + indirectCosts, + profit, + financing, + taxes, + bonds, + contingency, + subtotal, + total, + baseTotal, + variance: total - baseTotal, + variancePercentage: baseTotal > 0 ? ((total - baseTotal) / baseTotal) * 100 : 0, + itemCount: items.length, + }; + } + + /** + * Cambiar estado del presupuesto + */ + async changeStatus(ctx: ServiceContext, bidId: string, status: BudgetStatus): Promise { + const result = await this.repository.update( + { bidId, tenantId: ctx.tenantId, deletedAt: undefined }, + { status, updatedBy: ctx.userId } + ); + return result.affected || 0; + } + + /** + * Soft delete + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), updatedBy: ctx.userId } + ); + return (result.affected || 0) > 0; + } +} + +export interface BudgetSummary { + directCosts: number; + indirectCosts: number; + profit: number; + financing: number; + taxes: number; + bonds: number; + contingency: number; + subtotal: number; + total: number; + baseTotal: number; + variance: number; + variancePercentage: number; + itemCount: number; +} diff --git a/src/modules/bidding/services/bid.service.ts b/src/modules/bidding/services/bid.service.ts new file mode 100644 index 0000000..13e1469 --- /dev/null +++ b/src/modules/bidding/services/bid.service.ts @@ -0,0 +1,384 @@ +/** + * BidService - Gestión de Licitaciones + * + * CRUD y lógica de negocio para licitaciones/propuestas. + * + * @module Bidding + */ + +import { Repository, In, Between } from 'typeorm'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Bid, BidType, BidStatus, BidStage } from '../entities/bid.entity'; + +export interface CreateBidDto { + opportunityId: string; + code: string; + name: string; + description?: string; + bidType: BidType; + tenderNumber?: string; + tenderName?: string; + contractingEntity?: string; + publicationDate?: Date; + siteVisitDate?: Date; + clarificationDeadline?: Date; + submissionDeadline: Date; + openingDate?: Date; + baseBudget?: number; + currency?: string; + technicalWeight?: number; + economicWeight?: number; + bidBondAmount?: number; + bidManagerId?: string; + notes?: string; + metadata?: Record; +} + +export interface UpdateBidDto extends Partial { + status?: BidStatus; + stage?: BidStage; + ourProposalAmount?: number; + technicalScore?: number; + economicScore?: number; + finalScore?: number; + rankingPosition?: number; + bidBondNumber?: string; + bidBondExpiry?: Date; + awardDate?: Date; + contractSigningDate?: Date; + winnerName?: string; + winningAmount?: number; + rejectionReason?: string; + lessonsLearned?: string; + completionPercentage?: number; + checklist?: Record; +} + +export interface BidFilters { + status?: BidStatus | BidStatus[]; + bidType?: BidType; + stage?: BidStage; + opportunityId?: string; + bidManagerId?: string; + contractingEntity?: string; + deadlineFrom?: Date; + deadlineTo?: Date; + minBudget?: number; + maxBudget?: number; + search?: string; +} + +export class BidService { + constructor(private readonly repository: Repository) {} + + /** + * Crear licitación + */ + async create(ctx: ServiceContext, data: CreateBidDto): Promise { + const bid = this.repository.create({ + tenantId: ctx.tenantId, + ...data, + status: 'draft', + stage: 'initial', + completionPercentage: 0, + createdBy: ctx.userId, + updatedBy: ctx.userId, + }); + + return this.repository.save(bid); + } + + /** + * Buscar por ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: undefined }, + relations: ['opportunity', 'bidManager', 'documents', 'calendarEvents', 'teamMembers'], + }); + } + + /** + * Buscar con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: BidFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('b') + .leftJoinAndSelect('b.opportunity', 'o') + .leftJoinAndSelect('b.bidManager', 'm') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.deleted_at IS NULL'); + + if (filters.status) { + if (Array.isArray(filters.status)) { + qb.andWhere('b.status IN (:...statuses)', { statuses: filters.status }); + } else { + qb.andWhere('b.status = :status', { status: filters.status }); + } + } + if (filters.bidType) { + qb.andWhere('b.bid_type = :bidType', { bidType: filters.bidType }); + } + if (filters.stage) { + qb.andWhere('b.stage = :stage', { stage: filters.stage }); + } + if (filters.opportunityId) { + qb.andWhere('b.opportunity_id = :opportunityId', { opportunityId: filters.opportunityId }); + } + if (filters.bidManagerId) { + qb.andWhere('b.bid_manager_id = :bidManagerId', { bidManagerId: filters.bidManagerId }); + } + if (filters.contractingEntity) { + qb.andWhere('b.contracting_entity ILIKE :entity', { entity: `%${filters.contractingEntity}%` }); + } + if (filters.deadlineFrom) { + qb.andWhere('b.submission_deadline >= :deadlineFrom', { deadlineFrom: filters.deadlineFrom }); + } + if (filters.deadlineTo) { + qb.andWhere('b.submission_deadline <= :deadlineTo', { deadlineTo: filters.deadlineTo }); + } + if (filters.minBudget !== undefined) { + qb.andWhere('b.base_budget >= :minBudget', { minBudget: filters.minBudget }); + } + if (filters.maxBudget !== undefined) { + qb.andWhere('b.base_budget <= :maxBudget', { maxBudget: filters.maxBudget }); + } + if (filters.search) { + qb.andWhere( + '(b.name ILIKE :search OR b.code ILIKE :search OR b.tender_number ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + const skip = (page - 1) * limit; + qb.orderBy('b.submission_deadline', 'ASC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Actualizar licitación + */ + async update(ctx: ServiceContext, id: string, data: UpdateBidDto): Promise { + const bid = await this.findById(ctx, id); + if (!bid) return null; + + // Calcular puntuación final si hay scores + let finalScore = data.finalScore ?? bid.finalScore; + const techScore = data.technicalScore ?? bid.technicalScore; + const econScore = data.economicScore ?? bid.economicScore; + const techWeight = data.technicalWeight ?? bid.technicalWeight; + const econWeight = data.economicWeight ?? bid.economicWeight; + + if (techScore !== undefined && econScore !== undefined) { + finalScore = (techScore * techWeight / 100) + (econScore * econWeight / 100); + } + + Object.assign(bid, { + ...data, + finalScore, + updatedBy: ctx.userId, + }); + + return this.repository.save(bid); + } + + /** + * Cambiar estado + */ + async changeStatus(ctx: ServiceContext, id: string, status: BidStatus): Promise { + const bid = await this.findById(ctx, id); + if (!bid) return null; + + bid.status = status; + bid.updatedBy = ctx.userId; + + return this.repository.save(bid); + } + + /** + * Cambiar etapa + */ + async changeStage(ctx: ServiceContext, id: string, stage: BidStage): Promise { + const bid = await this.findById(ctx, id); + if (!bid) return null; + + bid.stage = stage; + bid.updatedBy = ctx.userId; + + return this.repository.save(bid); + } + + /** + * Marcar como presentada + */ + async submit(ctx: ServiceContext, id: string, proposalAmount: number): Promise { + const bid = await this.findById(ctx, id); + if (!bid) return null; + + bid.status = 'submitted'; + bid.stage = 'post_submission'; + bid.ourProposalAmount = proposalAmount; + bid.updatedBy = ctx.userId; + + return this.repository.save(bid); + } + + /** + * Registrar resultado + */ + async recordResult( + ctx: ServiceContext, + id: string, + won: boolean, + details: { + winnerName?: string; + winningAmount?: number; + rankingPosition?: number; + rejectionReason?: string; + lessonsLearned?: string; + } + ): Promise { + const bid = await this.findById(ctx, id); + if (!bid) return null; + + bid.status = won ? 'awarded' : 'rejected'; + bid.awardDate = new Date(); + Object.assign(bid, details); + bid.updatedBy = ctx.userId; + + return this.repository.save(bid); + } + + /** + * Convertir a proyecto + */ + async convertToProject(ctx: ServiceContext, id: string, projectId: string): Promise { + const bid = await this.findById(ctx, id); + if (!bid || bid.status !== 'awarded') return null; + + bid.convertedToProjectId = projectId; + bid.convertedAt = new Date(); + bid.updatedBy = ctx.userId; + + return this.repository.save(bid); + } + + /** + * Obtener próximas fechas límite + */ + async getUpcomingDeadlines(ctx: ServiceContext, days = 7): Promise { + const now = new Date(); + const future = new Date(); + future.setDate(future.getDate() + days); + + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: undefined, + status: In(['draft', 'preparation', 'review', 'approved']), + submissionDeadline: Between(now, future), + }, + relations: ['opportunity', 'bidManager'], + order: { submissionDeadline: 'ASC' }, + }); + } + + /** + * Obtener estadísticas + */ + async getStats(ctx: ServiceContext, year?: number): Promise { + const currentYear = year || new Date().getFullYear(); + const startDate = new Date(currentYear, 0, 1); + const endDate = new Date(currentYear, 11, 31); + + const total = await this.repository + .createQueryBuilder('b') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getCount(); + + const byStatus = await this.repository + .createQueryBuilder('b') + .select('b.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('b.status') + .getRawMany(); + + const byType = await this.repository + .createQueryBuilder('b') + .select('b.bid_type', 'bidType') + .addSelect('COUNT(*)', 'count') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('b.bid_type') + .getRawMany(); + + const valueStats = await this.repository + .createQueryBuilder('b') + .select('SUM(b.base_budget)', 'totalBudget') + .addSelect('SUM(b.our_proposal_amount)', 'totalProposed') + .addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'totalWon') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + + const awardedCount = byStatus.find((s) => s.status === 'awarded')?.count || 0; + const rejectedCount = byStatus.find((s) => s.status === 'rejected')?.count || 0; + const closedCount = parseInt(awardedCount) + parseInt(rejectedCount); + + return { + year: currentYear, + total, + byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })), + byType: byType.map((r) => ({ bidType: r.bidType, count: parseInt(r.count) })), + totalBudget: parseFloat(valueStats?.totalBudget) || 0, + totalProposed: parseFloat(valueStats?.totalProposed) || 0, + totalWon: parseFloat(valueStats?.totalWon) || 0, + winRate: closedCount > 0 ? (parseInt(awardedCount) / closedCount) * 100 : 0, + }; + } + + /** + * Soft delete + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), updatedBy: ctx.userId } + ); + return (result.affected || 0) > 0; + } +} + +export interface BidStats { + year: number; + total: number; + byStatus: { status: BidStatus; count: number }[]; + byType: { bidType: BidType; count: number }[]; + totalBudget: number; + totalProposed: number; + totalWon: number; + winRate: number; +} diff --git a/src/modules/bidding/services/index.ts b/src/modules/bidding/services/index.ts new file mode 100644 index 0000000..12cc587 --- /dev/null +++ b/src/modules/bidding/services/index.ts @@ -0,0 +1,9 @@ +/** + * Bidding Services Index + * @module Bidding + */ + +export { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters, PipelineData, OpportunityStats } from './opportunity.service'; +export { BidService, CreateBidDto, UpdateBidDto, BidFilters, BidStats } from './bid.service'; +export { BidBudgetService, CreateBudgetItemDto, UpdateBudgetItemDto, BudgetFilters, BudgetSummary } from './bid-budget.service'; +export { BidAnalyticsService, BidDashboard, PipelineBySource, WinRateByType, MonthlyTrend, CompetitorAnalysis, FunnelAnalysis, CycleTimeAnalysis } from './bid-analytics.service'; diff --git a/src/modules/bidding/services/opportunity.service.ts b/src/modules/bidding/services/opportunity.service.ts new file mode 100644 index 0000000..e67f9ff --- /dev/null +++ b/src/modules/bidding/services/opportunity.service.ts @@ -0,0 +1,392 @@ +/** + * OpportunityService - Gestión de Oportunidades de Negocio + * + * CRUD y lógica de negocio para el pipeline de oportunidades. + * + * @module Bidding + */ + +import { Repository, In, Between } from 'typeorm'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority, ProjectType } from '../entities/opportunity.entity'; + +export interface CreateOpportunityDto { + code: string; + name: string; + description?: string; + source: OpportunitySource; + projectType: ProjectType; + clientName: string; + clientContact?: string; + clientEmail?: string; + clientPhone?: string; + clientType?: string; + location?: string; + state?: string; + city?: string; + estimatedValue?: number; + currency?: string; + constructionAreaM2?: number; + landAreaM2?: number; + identificationDate: Date; + deadlineDate?: Date; + expectedAwardDate?: Date; + expectedStartDate?: Date; + expectedDurationMonths?: number; + winProbability?: number; + requiresBond?: boolean; + requiresExperience?: boolean; + minimumExperienceYears?: number; + minimumCapital?: number; + requiredCertifications?: string[]; + assignedToId?: string; + sourceUrl?: string; + sourceReference?: string; + notes?: string; + metadata?: Record; +} + +export interface UpdateOpportunityDto extends Partial { + status?: OpportunityStatus; + priority?: OpportunityPriority; + lossReason?: string; + winFactors?: string; +} + +export interface OpportunityFilters { + status?: OpportunityStatus | OpportunityStatus[]; + source?: OpportunitySource; + projectType?: ProjectType; + priority?: OpportunityPriority; + assignedToId?: string; + clientName?: string; + state?: string; + dateFrom?: Date; + dateTo?: Date; + minValue?: number; + maxValue?: number; + search?: string; +} + +export class OpportunityService { + constructor(private readonly repository: Repository) {} + + /** + * Crear oportunidad + */ + async create(ctx: ServiceContext, data: CreateOpportunityDto): Promise { + const weightedValue = data.estimatedValue && data.winProbability + ? data.estimatedValue * (data.winProbability / 100) + : undefined; + + const opportunity = this.repository.create({ + tenantId: ctx.tenantId, + code: data.code, + name: data.name, + description: data.description, + source: data.source, + projectType: data.projectType, + clientName: data.clientName, + clientContact: data.clientContact, + clientEmail: data.clientEmail, + clientPhone: data.clientPhone, + clientType: data.clientType, + location: data.location, + state: data.state, + city: data.city, + estimatedValue: data.estimatedValue, + currency: data.currency || 'MXN', + constructionAreaM2: data.constructionAreaM2, + landAreaM2: data.landAreaM2, + identificationDate: data.identificationDate, + deadlineDate: data.deadlineDate, + expectedAwardDate: data.expectedAwardDate, + expectedStartDate: data.expectedStartDate, + expectedDurationMonths: data.expectedDurationMonths, + winProbability: data.winProbability || 0, + requiresBond: data.requiresBond || false, + requiresExperience: data.requiresExperience || false, + minimumExperienceYears: data.minimumExperienceYears, + minimumCapital: data.minimumCapital, + requiredCertifications: data.requiredCertifications, + assignedToId: data.assignedToId, + sourceUrl: data.sourceUrl, + sourceReference: data.sourceReference, + notes: data.notes, + metadata: data.metadata, + status: 'identified', + priority: 'medium', + weightedValue, + createdBy: ctx.userId, + updatedBy: ctx.userId, + }); + + return this.repository.save(opportunity); + } + + /** + * Buscar por ID + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: undefined }, + relations: ['assignedTo', 'bids'], + }); + } + + /** + * Buscar con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: OpportunityFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('o') + .leftJoinAndSelect('o.assignedTo', 'u') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL'); + + if (filters.status) { + if (Array.isArray(filters.status)) { + qb.andWhere('o.status IN (:...statuses)', { statuses: filters.status }); + } else { + qb.andWhere('o.status = :status', { status: filters.status }); + } + } + if (filters.source) { + qb.andWhere('o.source = :source', { source: filters.source }); + } + if (filters.projectType) { + qb.andWhere('o.project_type = :projectType', { projectType: filters.projectType }); + } + if (filters.priority) { + qb.andWhere('o.priority = :priority', { priority: filters.priority }); + } + if (filters.assignedToId) { + qb.andWhere('o.assigned_to_id = :assignedToId', { assignedToId: filters.assignedToId }); + } + if (filters.clientName) { + qb.andWhere('o.client_name ILIKE :clientName', { clientName: `%${filters.clientName}%` }); + } + if (filters.state) { + qb.andWhere('o.state = :state', { state: filters.state }); + } + if (filters.dateFrom) { + qb.andWhere('o.identification_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('o.identification_date <= :dateTo', { dateTo: filters.dateTo }); + } + if (filters.minValue !== undefined) { + qb.andWhere('o.estimated_value >= :minValue', { minValue: filters.minValue }); + } + if (filters.maxValue !== undefined) { + qb.andWhere('o.estimated_value <= :maxValue', { maxValue: filters.maxValue }); + } + if (filters.search) { + qb.andWhere( + '(o.name ILIKE :search OR o.code ILIKE :search OR o.client_name ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + const skip = (page - 1) * limit; + qb.orderBy('o.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Actualizar oportunidad + */ + async update(ctx: ServiceContext, id: string, data: UpdateOpportunityDto): Promise { + const opportunity = await this.findById(ctx, id); + if (!opportunity) return null; + + // Recalcular weighted value si cambian los factores + let weightedValue = opportunity.weightedValue; + const estimatedValue = data.estimatedValue ?? opportunity.estimatedValue; + const winProbability = data.winProbability ?? opportunity.winProbability; + if (estimatedValue && winProbability) { + weightedValue = estimatedValue * (winProbability / 100); + } + + Object.assign(opportunity, { + ...data, + weightedValue, + updatedBy: ctx.userId, + }); + + return this.repository.save(opportunity); + } + + /** + * Cambiar estado + */ + async changeStatus( + ctx: ServiceContext, + id: string, + status: OpportunityStatus, + reason?: string + ): Promise { + const opportunity = await this.findById(ctx, id); + if (!opportunity) return null; + + opportunity.status = status; + if (status === 'lost' && reason) { + opportunity.lossReason = reason; + } else if (status === 'won' && reason) { + opportunity.winFactors = reason; + } + opportunity.updatedBy = ctx.userId; + + return this.repository.save(opportunity); + } + + /** + * Obtener pipeline (agrupado por status) + */ + async getPipeline(ctx: ServiceContext): Promise { + const result = await this.repository + .createQueryBuilder('o') + .select('o.status', 'status') + .addSelect('COUNT(*)', 'count') + .addSelect('SUM(o.estimated_value)', 'totalValue') + .addSelect('SUM(o.weighted_value)', 'weightedValue') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .groupBy('o.status') + .getRawMany(); + + return result.map((r) => ({ + status: r.status as OpportunityStatus, + count: parseInt(r.count), + totalValue: parseFloat(r.totalValue) || 0, + weightedValue: parseFloat(r.weightedValue) || 0, + })); + } + + /** + * Obtener oportunidades próximas a vencer + */ + async getUpcomingDeadlines(ctx: ServiceContext, days = 7): Promise { + const now = new Date(); + const future = new Date(); + future.setDate(future.getDate() + days); + + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: undefined, + status: In(['identified', 'qualified', 'pursuing']), + deadlineDate: Between(now, future), + }, + relations: ['assignedTo'], + order: { deadlineDate: 'ASC' }, + }); + } + + /** + * Obtener estadísticas + */ + async getStats(ctx: ServiceContext, year?: number): Promise { + const currentYear = year || new Date().getFullYear(); + const startDate = new Date(currentYear, 0, 1); + const endDate = new Date(currentYear, 11, 31); + + const baseQuery = this.repository + .createQueryBuilder('o') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate }); + + const total = await baseQuery.getCount(); + + const byStatus = await this.repository + .createQueryBuilder('o') + .select('o.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('o.status') + .getRawMany(); + + const bySource = await this.repository + .createQueryBuilder('o') + .select('o.source', 'source') + .addSelect('COUNT(*)', 'count') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('o.source') + .getRawMany(); + + const valueStats = await this.repository + .createQueryBuilder('o') + .select('SUM(o.estimated_value)', 'totalValue') + .addSelect('SUM(o.weighted_value)', 'weightedValue') + .addSelect('AVG(o.estimated_value)', 'avgValue') + .where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('o.deleted_at IS NULL') + .andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + + const wonCount = byStatus.find((s) => s.status === 'won')?.count || 0; + const lostCount = byStatus.find((s) => s.status === 'lost')?.count || 0; + const closedCount = parseInt(wonCount) + parseInt(lostCount); + + return { + year: currentYear, + total, + byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })), + bySource: bySource.map((r) => ({ source: r.source, count: parseInt(r.count) })), + totalValue: parseFloat(valueStats?.totalValue) || 0, + weightedValue: parseFloat(valueStats?.weightedValue) || 0, + avgValue: parseFloat(valueStats?.avgValue) || 0, + winRate: closedCount > 0 ? (parseInt(wonCount) / closedCount) * 100 : 0, + }; + } + + /** + * Soft delete + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.update( + { id, tenantId: ctx.tenantId }, + { deletedAt: new Date(), updatedBy: ctx.userId } + ); + return (result.affected || 0) > 0; + } +} + +export interface PipelineData { + status: OpportunityStatus; + count: number; + totalValue: number; + weightedValue: number; +} + +export interface OpportunityStats { + year: number; + total: number; + byStatus: { status: OpportunityStatus; count: number }[]; + bySource: { source: OpportunitySource; count: number }[]; + totalValue: number; + weightedValue: number; + avgValue: number; + winRate: number; +} diff --git a/src/modules/budgets/controllers/concepto.controller.ts b/src/modules/budgets/controllers/concepto.controller.ts new file mode 100644 index 0000000..bbd80e9 --- /dev/null +++ b/src/modules/budgets/controllers/concepto.controller.ts @@ -0,0 +1,252 @@ +/** + * ConceptoController - Controller de conceptos de obra + * + * Endpoints REST para gestión del catálogo de conceptos. + * + * @module Budgets + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ConceptoService, CreateConceptoDto, UpdateConceptoDto } from '../services/concepto.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Concepto } from '../entities/concepto.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de conceptos + */ +export function createConceptoController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const conceptoRepository = dataSource.getRepository(Concepto); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const conceptoService = new ConceptoService(conceptoRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /conceptos + * Listar conceptos raíz + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + + const result = await conceptoService.findRootConceptos(getContext(req), page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /conceptos/search + * Buscar conceptos por código o nombre + */ + router.get('/search', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const term = req.query.q as string; + if (!term || term.length < 2) { + res.status(400).json({ error: 'Bad Request', message: 'Search term must be at least 2 characters' }); + return; + } + + const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); + const conceptos = await conceptoService.search(getContext(req), term, limit); + + res.status(200).json({ success: true, data: conceptos }); + } catch (error) { + next(error); + } + }); + + /** + * GET /conceptos/tree + * Obtener árbol de conceptos + */ + router.get('/tree', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const rootId = req.query.rootId as string; + const tree = await conceptoService.getConceptoTree(getContext(req), rootId); + + res.status(200).json({ success: true, data: tree }); + } catch (error) { + next(error); + } + }); + + /** + * GET /conceptos/:id + * Obtener concepto por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const concepto = await conceptoService.findById(getContext(req), req.params.id); + if (!concepto) { + res.status(404).json({ error: 'Not Found', message: 'Concept not found' }); + return; + } + + res.status(200).json({ success: true, data: concepto }); + } catch (error) { + next(error); + } + }); + + /** + * GET /conceptos/:id/children + * Obtener hijos de un concepto + */ + router.get('/:id/children', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const children = await conceptoService.findChildren(getContext(req), req.params.id); + res.status(200).json({ success: true, data: children }); + } catch (error) { + next(error); + } + }); + + /** + * POST /conceptos + * Crear concepto + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateConceptoDto = req.body; + + if (!dto.code || !dto.name) { + res.status(400).json({ error: 'Bad Request', message: 'code and name are required' }); + return; + } + + // Verificar código único + const exists = await conceptoService.codeExists(getContext(req), dto.code); + if (exists) { + res.status(409).json({ error: 'Conflict', message: 'Concept code already exists' }); + return; + } + + const concepto = await conceptoService.createConcepto(getContext(req), dto); + res.status(201).json({ success: true, data: concepto }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /conceptos/:id + * Actualizar concepto + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateConceptoDto = req.body; + const concepto = await conceptoService.update(getContext(req), req.params.id, dto); + + if (!concepto) { + res.status(404).json({ error: 'Not Found', message: 'Concept not found' }); + return; + } + + res.status(200).json({ success: true, data: concepto }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /conceptos/:id + * Eliminar concepto (soft delete) + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await conceptoService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Concept not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Concept deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createConceptoController; diff --git a/src/modules/budgets/controllers/index.ts b/src/modules/budgets/controllers/index.ts new file mode 100644 index 0000000..1e73b39 --- /dev/null +++ b/src/modules/budgets/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * Budgets Controllers Index + * @module Budgets + */ + +export { createConceptoController } from './concepto.controller'; +export { createPresupuestoController } from './presupuesto.controller'; diff --git a/src/modules/budgets/controllers/presupuesto.controller.ts b/src/modules/budgets/controllers/presupuesto.controller.ts new file mode 100644 index 0000000..d56c1a0 --- /dev/null +++ b/src/modules/budgets/controllers/presupuesto.controller.ts @@ -0,0 +1,287 @@ +/** + * PresupuestoController - Controller de presupuestos + * + * Endpoints REST para gestión de presupuestos de obra. + * + * @module Budgets + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { PresupuestoService, CreatePresupuestoDto, AddPartidaDto, UpdatePartidaDto } from '../services/presupuesto.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Presupuesto } from '../entities/presupuesto.entity'; +import { PresupuestoPartida } from '../entities/presupuesto-partida.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de presupuestos + */ +export function createPresupuestoController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const presupuestoRepository = dataSource.getRepository(Presupuesto); + const partidaRepository = dataSource.getRepository(PresupuestoPartida); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const presupuestoService = new PresupuestoService(presupuestoRepository, partidaRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /presupuestos + * Listar presupuestos + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const fraccionamientoId = req.query.fraccionamientoId as string; + + let result; + if (fraccionamientoId) { + result = await presupuestoService.findByFraccionamiento(getContext(req), fraccionamientoId, page, limit); + } else { + result = await presupuestoService.findAll(getContext(req), { page, limit }); + } + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /presupuestos/:id + * Obtener presupuesto por ID con sus partidas + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const presupuesto = await presupuestoService.findWithPartidas(getContext(req), req.params.id); + if (!presupuesto) { + res.status(404).json({ error: 'Not Found', message: 'Budget not found' }); + return; + } + + res.status(200).json({ success: true, data: presupuesto }); + } catch (error) { + next(error); + } + }); + + /** + * POST /presupuestos + * Crear presupuesto + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreatePresupuestoDto = req.body; + + if (!dto.code || !dto.name) { + res.status(400).json({ error: 'Bad Request', message: 'code and name are required' }); + return; + } + + const presupuesto = await presupuestoService.createPresupuesto(getContext(req), dto); + res.status(201).json({ success: true, data: presupuesto }); + } catch (error) { + next(error); + } + }); + + /** + * POST /presupuestos/:id/partidas + * Agregar partida al presupuesto + */ + router.post('/:id/partidas', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: AddPartidaDto = req.body; + + if (!dto.conceptoId || dto.quantity === undefined || dto.unitPrice === undefined) { + res.status(400).json({ error: 'Bad Request', message: 'conceptoId, quantity and unitPrice are required' }); + return; + } + + const partida = await presupuestoService.addPartida(getContext(req), req.params.id, dto); + res.status(201).json({ success: true, data: partida }); + } catch (error) { + if (error instanceof Error && error.message === 'Presupuesto not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /presupuestos/:id/partidas/:partidaId + * Actualizar partida + */ + router.patch('/:id/partidas/:partidaId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdatePartidaDto = req.body; + const partida = await presupuestoService.updatePartida(getContext(req), req.params.partidaId, dto); + + if (!partida) { + res.status(404).json({ error: 'Not Found', message: 'Budget item not found' }); + return; + } + + res.status(200).json({ success: true, data: partida }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /presupuestos/:id/partidas/:partidaId + * Eliminar partida + */ + router.delete('/:id/partidas/:partidaId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await presupuestoService.removePartida(getContext(req), req.params.partidaId); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Budget item not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Budget item deleted' }); + } catch (error) { + next(error); + } + }); + + /** + * POST /presupuestos/:id/version + * Crear nueva versión del presupuesto + */ + router.post('/:id/version', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const newVersion = await presupuestoService.createNewVersion(getContext(req), req.params.id); + res.status(201).json({ success: true, data: newVersion }); + } catch (error) { + if (error instanceof Error && error.message === 'Presupuesto not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /presupuestos/:id/approve + * Aprobar presupuesto + */ + router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const presupuesto = await presupuestoService.approve(getContext(req), req.params.id); + if (!presupuesto) { + res.status(404).json({ error: 'Not Found', message: 'Budget not found' }); + return; + } + + res.status(200).json({ success: true, data: presupuesto, message: 'Budget approved' }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /presupuestos/:id + * Eliminar presupuesto (soft delete) + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await presupuestoService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Budget not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Budget deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createPresupuestoController; diff --git a/src/modules/budgets/entities/concepto.entity.ts b/src/modules/budgets/entities/concepto.entity.ts new file mode 100644 index 0000000..da83fc8 --- /dev/null +++ b/src/modules/budgets/entities/concepto.entity.ts @@ -0,0 +1,100 @@ +/** + * Concepto Entity + * Catalogo de conceptos de obra (estructura jerarquica) + * + * @module Budgets + * @table construction.conceptos + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +@Entity({ schema: 'construction', name: 'conceptos' }) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId']) +@Index(['parentId']) +@Index(['code']) +export class Concepto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string | null; + + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'unit_id', type: 'uuid', nullable: true }) + unitId: string | null; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 4, nullable: true }) + unitPrice: number | null; + + @Column({ name: 'is_composite', type: 'boolean', default: false }) + isComposite: boolean; + + @Column({ type: 'integer', default: 0 }) + level: number; + + @Column({ type: 'varchar', length: 500, nullable: true }) + path: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Concepto, (c) => c.children, { nullable: true }) + @JoinColumn({ name: 'parent_id' }) + parent: Concepto | null; + + @OneToMany(() => Concepto, (c) => c.parent) + children: Concepto[]; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User | null; +} diff --git a/src/modules/budgets/entities/index.ts b/src/modules/budgets/entities/index.ts new file mode 100644 index 0000000..94a23af --- /dev/null +++ b/src/modules/budgets/entities/index.ts @@ -0,0 +1,8 @@ +/** + * Budgets Module - Entity Exports + * MAI-003: Presupuestos + */ + +export * from './concepto.entity'; +export * from './presupuesto.entity'; +export * from './presupuesto-partida.entity'; diff --git a/src/modules/budgets/entities/presupuesto-partida.entity.ts b/src/modules/budgets/entities/presupuesto-partida.entity.ts new file mode 100644 index 0000000..aa926fb --- /dev/null +++ b/src/modules/budgets/entities/presupuesto-partida.entity.ts @@ -0,0 +1,95 @@ +/** + * PresupuestoPartida Entity + * Lineas/partidas de un presupuesto + * + * @module Budgets + * @table construction.presupuesto_partidas + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Presupuesto } from './presupuesto.entity'; +import { Concepto } from './concepto.entity'; + +@Entity({ schema: 'construction', name: 'presupuesto_partidas' }) +@Index(['presupuestoId', 'conceptoId'], { unique: true }) +@Index(['tenantId']) +export class PresupuestoPartida { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'presupuesto_id', type: 'uuid' }) + presupuestoId: string; + + @Column({ name: 'concepto_id', type: 'uuid' }) + conceptoId: string; + + @Column({ type: 'integer', default: 0 }) + sequence: number; + + @Column({ type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantity: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 4, default: 0 }) + unitPrice: number; + + // Columna calculada (GENERATED ALWAYS AS) - solo lectura + @Column({ + name: 'total_amount', + type: 'decimal', + precision: 14, + scale: 2, + insert: false, + update: false, + }) + totalAmount: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Presupuesto, (p) => p.partidas) + @JoinColumn({ name: 'presupuesto_id' }) + presupuesto: Presupuesto; + + @ManyToOne(() => Concepto) + @JoinColumn({ name: 'concepto_id' }) + concepto: Concepto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/src/modules/budgets/entities/presupuesto.entity.ts b/src/modules/budgets/entities/presupuesto.entity.ts new file mode 100644 index 0000000..a4da148 --- /dev/null +++ b/src/modules/budgets/entities/presupuesto.entity.ts @@ -0,0 +1,107 @@ +/** + * Presupuesto Entity + * Presupuestos de obra por prototipo o fraccionamiento + * + * @module Budgets + * @table construction.presupuestos + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { PresupuestoPartida } from './presupuesto-partida.entity'; + +@Entity({ schema: 'construction', name: 'presupuestos' }) +@Index(['tenantId', 'code', 'version'], { unique: true }) +@Index(['tenantId']) +@Index(['fraccionamientoId']) +export class Presupuesto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true }) + fraccionamientoId: string | null; + + @Column({ name: 'prototipo_id', type: 'uuid', nullable: true }) + prototipoId: string | null; + + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'integer', default: 1 }) + version: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'total_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + totalAmount: number; + + @Column({ name: 'currency_id', type: 'uuid', nullable: true }) + currencyId: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento, { nullable: true }) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User | null; + + @OneToMany(() => PresupuestoPartida, (p) => p.presupuesto) + partidas: PresupuestoPartida[]; +} diff --git a/src/modules/budgets/services/concepto.service.ts b/src/modules/budgets/services/concepto.service.ts new file mode 100644 index 0000000..0108de4 --- /dev/null +++ b/src/modules/budgets/services/concepto.service.ts @@ -0,0 +1,160 @@ +/** + * ConceptoService - Catalogo de Conceptos de Obra + * + * Gestiona el catálogo jerárquico de conceptos de obra. + * Los conceptos pueden tener estructura padre-hijo (niveles). + * + * @module Budgets + */ + +import { Repository, IsNull } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Concepto } from '../entities/concepto.entity'; + +export interface CreateConceptoDto { + code: string; + name: string; + description?: string; + parentId?: string; + unitId?: string; + unitPrice?: number; + isComposite?: boolean; +} + +export interface UpdateConceptoDto { + name?: string; + description?: string; + unitId?: string; + unitPrice?: number; + isComposite?: boolean; +} + +export class ConceptoService extends BaseService { + constructor(repository: Repository) { + super(repository); + } + + /** + * Crear un nuevo concepto con cálculo automático de nivel y path + */ + async createConcepto( + ctx: ServiceContext, + data: CreateConceptoDto + ): Promise { + let level = 0; + let path = data.code; + + if (data.parentId) { + const parent = await this.findById(ctx, data.parentId); + if (parent) { + level = parent.level + 1; + path = `${parent.path}/${data.code}`; + } + } + + return this.create(ctx, { + ...data, + level, + path, + }); + } + + /** + * Obtener conceptos raíz (sin padre) + */ + async findRootConceptos( + ctx: ServiceContext, + page = 1, + limit = 50 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { parentId: IsNull() } as any, + }); + } + + /** + * Obtener hijos de un concepto + */ + async findChildren( + ctx: ServiceContext, + parentId: string + ): Promise { + return this.find(ctx, { + where: { parentId } as any, + order: { code: 'ASC' }, + }); + } + + /** + * Obtener árbol completo de conceptos + */ + async getConceptoTree( + ctx: ServiceContext, + rootId?: string + ): Promise { + const where = rootId + ? { parentId: rootId } + : { parentId: IsNull() }; + + const roots = await this.find(ctx, { + where: where as any, + order: { code: 'ASC' }, + }); + + return this.buildTree(ctx, roots); + } + + private async buildTree( + ctx: ServiceContext, + conceptos: Concepto[] + ): Promise { + const tree: ConceptoNode[] = []; + + for (const concepto of conceptos) { + const children = await this.findChildren(ctx, concepto.id); + const childNodes = children.length > 0 + ? await this.buildTree(ctx, children) + : []; + + tree.push({ + ...concepto, + children: childNodes, + }); + } + + return tree; + } + + /** + * Buscar conceptos por código o nombre + */ + async search( + ctx: ServiceContext, + term: string, + limit = 20 + ): Promise { + return this.repository + .createQueryBuilder('c') + .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('c.deleted_at IS NULL') + .andWhere('(c.code ILIKE :term OR c.name ILIKE :term)', { + term: `%${term}%`, + }) + .orderBy('c.code', 'ASC') + .take(limit) + .getMany(); + } + + /** + * Verificar si un código ya existe + */ + async codeExists(ctx: ServiceContext, code: string): Promise { + return this.exists(ctx, { code } as any); + } +} + +interface ConceptoNode extends Concepto { + children: ConceptoNode[]; +} diff --git a/src/modules/budgets/services/index.ts b/src/modules/budgets/services/index.ts new file mode 100644 index 0000000..f7e3fe4 --- /dev/null +++ b/src/modules/budgets/services/index.ts @@ -0,0 +1,6 @@ +/** + * Budgets Module - Service Exports + */ + +export * from './concepto.service'; +export * from './presupuesto.service'; diff --git a/src/modules/budgets/services/presupuesto.service.ts b/src/modules/budgets/services/presupuesto.service.ts new file mode 100644 index 0000000..d3879be --- /dev/null +++ b/src/modules/budgets/services/presupuesto.service.ts @@ -0,0 +1,262 @@ +/** + * PresupuestoService - Gestión de Presupuestos de Obra + * + * Gestiona presupuestos de obra con sus partidas. + * Soporta versionamiento y aprobación. + * + * @module Budgets + */ + +import { Repository } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Presupuesto } from '../entities/presupuesto.entity'; +import { PresupuestoPartida } from '../entities/presupuesto-partida.entity'; + +export interface CreatePresupuestoDto { + code: string; + name: string; + description?: string; + fraccionamientoId?: string; + prototipoId?: string; + currencyId?: string; +} + +export interface AddPartidaDto { + conceptoId: string; + quantity: number; + unitPrice: number; + sequence?: number; +} + +export interface UpdatePartidaDto { + quantity?: number; + unitPrice?: number; + sequence?: number; +} + +export class PresupuestoService extends BaseService { + constructor( + repository: Repository, + private readonly partidaRepository: Repository + ) { + super(repository); + } + + /** + * Crear nuevo presupuesto + */ + async createPresupuesto( + ctx: ServiceContext, + data: CreatePresupuestoDto + ): Promise { + return this.create(ctx, { + ...data, + version: 1, + isActive: true, + totalAmount: 0, + }); + } + + /** + * Obtener presupuestos por fraccionamiento + */ + async findByFraccionamiento( + ctx: ServiceContext, + fraccionamientoId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { fraccionamientoId, isActive: true } as any, + }); + } + + /** + * Obtener presupuesto con sus partidas + */ + async findWithPartidas( + ctx: ServiceContext, + id: string + ): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + relations: ['partidas', 'partidas.concepto'], + }); + } + + /** + * Agregar partida al presupuesto + */ + async addPartida( + ctx: ServiceContext, + presupuestoId: string, + data: AddPartidaDto + ): Promise { + const presupuesto = await this.findById(ctx, presupuestoId); + if (!presupuesto) { + throw new Error('Presupuesto not found'); + } + + const partida = this.partidaRepository.create({ + tenantId: ctx.tenantId, + presupuestoId, + conceptoId: data.conceptoId, + quantity: data.quantity, + unitPrice: data.unitPrice, + sequence: data.sequence || 0, + createdById: ctx.userId, + }); + + const savedPartida = await this.partidaRepository.save(partida); + await this.recalculateTotal(ctx, presupuestoId); + + return savedPartida; + } + + /** + * Actualizar partida + */ + async updatePartida( + ctx: ServiceContext, + partidaId: string, + data: UpdatePartidaDto + ): Promise { + const partida = await this.partidaRepository.findOne({ + where: { + id: partidaId, + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + }); + + if (!partida) { + return null; + } + + const updated = this.partidaRepository.merge(partida, { + ...data, + updatedById: ctx.userId, + }); + + const saved = await this.partidaRepository.save(updated); + await this.recalculateTotal(ctx, partida.presupuestoId); + + return saved; + } + + /** + * Eliminar partida + */ + async removePartida(ctx: ServiceContext, partidaId: string): Promise { + const partida = await this.partidaRepository.findOne({ + where: { + id: partidaId, + tenantId: ctx.tenantId, + } as any, + }); + + if (!partida) { + return false; + } + + await this.partidaRepository.update( + { id: partidaId }, + { + deletedAt: new Date(), + deletedById: ctx.userId, + } + ); + + await this.recalculateTotal(ctx, partida.presupuestoId); + return true; + } + + /** + * Recalcular total del presupuesto + */ + async recalculateTotal(ctx: ServiceContext, presupuestoId: string): Promise { + const result = await this.partidaRepository + .createQueryBuilder('p') + .select('SUM(p.quantity * p.unit_price)', 'total') + .where('p.presupuesto_id = :presupuestoId', { presupuestoId }) + .andWhere('p.deleted_at IS NULL') + .getRawOne(); + + const total = parseFloat(result?.total || '0'); + + await this.repository.update( + { id: presupuestoId }, + { totalAmount: total, updatedById: ctx.userId } + ); + } + + /** + * Crear nueva versión del presupuesto + */ + async createNewVersion( + ctx: ServiceContext, + presupuestoId: string + ): Promise { + const original = await this.findWithPartidas(ctx, presupuestoId); + if (!original) { + throw new Error('Presupuesto not found'); + } + + // Desactivar versión anterior + await this.repository.update( + { id: presupuestoId }, + { isActive: false, updatedById: ctx.userId } + ); + + // Crear nueva versión + const newVersion = await this.create(ctx, { + code: original.code, + name: original.name, + description: original.description, + fraccionamientoId: original.fraccionamientoId, + prototipoId: original.prototipoId, + currencyId: original.currencyId, + version: original.version + 1, + isActive: true, + totalAmount: original.totalAmount, + }); + + // Copiar partidas + for (const partida of original.partidas) { + await this.partidaRepository.save( + this.partidaRepository.create({ + tenantId: ctx.tenantId, + presupuestoId: newVersion.id, + conceptoId: partida.conceptoId, + quantity: partida.quantity, + unitPrice: partida.unitPrice, + sequence: partida.sequence, + createdById: ctx.userId, + }) + ); + } + + return newVersion; + } + + /** + * Aprobar presupuesto + */ + async approve(ctx: ServiceContext, presupuestoId: string): Promise { + const presupuesto = await this.findById(ctx, presupuestoId); + if (!presupuesto) { + return null; + } + + return this.update(ctx, presupuestoId, { + approvedAt: new Date(), + approvedById: ctx.userId, + }); + } +} diff --git a/src/modules/construction/controllers/etapa.controller.ts b/src/modules/construction/controllers/etapa.controller.ts new file mode 100644 index 0000000..b8a8125 --- /dev/null +++ b/src/modules/construction/controllers/etapa.controller.ts @@ -0,0 +1,181 @@ +/** + * EtapaController - Controller de etapas + * + * Endpoints REST para gestión de etapas de fraccionamientos. + * + * @module Construction + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { EtapaService, CreateEtapaDto, UpdateEtapaDto } from '../services/etapa.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Etapa } from '../entities/etapa.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; + +/** + * Crear router de etapas + */ +export function createEtapaController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const etapaRepository = dataSource.getRepository(Etapa); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const etapaService = new EtapaService(etapaRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + /** + * GET /etapas + * Listar etapas + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const search = req.query.search as string; + const status = req.query.status as string; + const fraccionamientoId = req.query.fraccionamientoId as string; + + const result = await etapaService.findAll({ tenantId, page, limit, search, status, fraccionamientoId }); + + res.status(200).json({ + success: true, + data: result.items, + pagination: { + page, + limit, + total: result.total, + totalPages: Math.ceil(result.total / limit), + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /etapas/:id + * Obtener etapa por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const etapa = await etapaService.findById(req.params.id, tenantId); + if (!etapa) { + res.status(404).json({ error: 'Not Found', message: 'Stage not found' }); + return; + } + + res.status(200).json({ success: true, data: etapa }); + } catch (error) { + next(error); + } + }); + + /** + * POST /etapas + * Crear etapa + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateEtapaDto = req.body; + + if (!dto.fraccionamientoId || !dto.code || !dto.name) { + res.status(400).json({ error: 'Bad Request', message: 'fraccionamientoId, code and name are required' }); + return; + } + + const etapa = await etapaService.create(tenantId, dto, req.user?.sub); + res.status(201).json({ success: true, data: etapa }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /etapas/:id + * Actualizar etapa + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateEtapaDto = req.body; + const etapa = await etapaService.update(req.params.id, tenantId, dto, req.user?.sub); + res.status(200).json({ success: true, data: etapa }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Stage not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * DELETE /etapas/:id + * Eliminar etapa + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + await etapaService.delete(req.params.id, tenantId, req.user?.sub); + res.status(200).json({ success: true, message: 'Stage deleted' }); + } catch (error) { + if (error instanceof Error && error.message === 'Stage not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + return router; +} + +export default createEtapaController; diff --git a/src/modules/construction/controllers/fraccionamiento.controller.ts b/src/modules/construction/controllers/fraccionamiento.controller.ts new file mode 100644 index 0000000..adb73a4 --- /dev/null +++ b/src/modules/construction/controllers/fraccionamiento.controller.ts @@ -0,0 +1,157 @@ +/** + * Fraccionamiento Controller + * API endpoints para gestión de fraccionamientos/obras + * + * @module Construction + * @prefix /api/v1/fraccionamientos + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { + FraccionamientoService, + CreateFraccionamientoDto, + UpdateFraccionamientoDto +} from '../services/fraccionamiento.service'; + +const router = Router(); +const fraccionamientoService = new FraccionamientoService(); + +/** + * GET /api/v1/fraccionamientos + * Lista todos los fraccionamientos del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { proyectoId, estado } = req.query; + + const fraccionamientos = await fraccionamientoService.findAll({ + tenantId, + proyectoId: proyectoId as string, + estado: estado as any, + }); + + return res.json({ + success: true, + data: fraccionamientos, + count: fraccionamientos.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/fraccionamientos/:id + * Obtiene un fraccionamiento por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const fraccionamiento = await fraccionamientoService.findById(req.params.id, tenantId); + if (!fraccionamiento) { + return res.status(404).json({ error: 'Fraccionamiento no encontrado' }); + } + + return res.json({ success: true, data: fraccionamiento }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/fraccionamientos + * Crea un nuevo fraccionamiento + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: CreateFraccionamientoDto = { + ...req.body, + tenantId, + createdById: (req as any).user?.id, + }; + + // Validate required fields + if (!data.codigo || !data.nombre || !data.proyectoId) { + return res.status(400).json({ + error: 'codigo, nombre y proyectoId son requeridos' + }); + } + + // Check if codigo already exists + const existing = await fraccionamientoService.findByCodigo(data.codigo, tenantId); + if (existing) { + return res.status(409).json({ error: 'Ya existe un fraccionamiento con ese código' }); + } + + const fraccionamiento = await fraccionamientoService.create(data); + return res.status(201).json({ success: true, data: fraccionamiento }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/fraccionamientos/:id + * Actualiza un fraccionamiento + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateFraccionamientoDto = req.body; + const fraccionamiento = await fraccionamientoService.update( + req.params.id, + tenantId, + data + ); + + if (!fraccionamiento) { + return res.status(404).json({ error: 'Fraccionamiento no encontrado' }); + } + + return res.json({ success: true, data: fraccionamiento }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/fraccionamientos/:id + * Elimina un fraccionamiento + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const deleted = await fraccionamientoService.delete(req.params.id, tenantId); + if (!deleted) { + return res.status(404).json({ error: 'Fraccionamiento no encontrado' }); + } + + return res.json({ success: true, message: 'Fraccionamiento eliminado' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/construction/controllers/index.ts b/src/modules/construction/controllers/index.ts new file mode 100644 index 0000000..624f318 --- /dev/null +++ b/src/modules/construction/controllers/index.ts @@ -0,0 +1,11 @@ +/** + * Construction Controllers Index + * @module Construction + */ + +export { default as proyectoController } from './proyecto.controller'; +export { default as fraccionamientoController } from './fraccionamiento.controller'; +export { createEtapaController } from './etapa.controller'; +export { createManzanaController } from './manzana.controller'; +export { createLoteController } from './lote.controller'; +export { createPrototipoController } from './prototipo.controller'; diff --git a/src/modules/construction/controllers/lote.controller.ts b/src/modules/construction/controllers/lote.controller.ts new file mode 100644 index 0000000..2749045 --- /dev/null +++ b/src/modules/construction/controllers/lote.controller.ts @@ -0,0 +1,273 @@ +/** + * LoteController - Controller de lotes + * + * Endpoints REST para gestión de lotes/terrenos. + * + * @module Construction + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { LoteService, CreateLoteDto, UpdateLoteDto } from '../services/lote.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Lote } from '../entities/lote.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; + +/** + * Crear router de lotes + */ +export function createLoteController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const loteRepository = dataSource.getRepository(Lote); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const loteService = new LoteService(loteRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + /** + * GET /lotes + * Listar lotes + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const search = req.query.search as string; + const status = req.query.status as string; + const manzanaId = req.query.manzanaId as string; + const prototipoId = req.query.prototipoId as string; + + const result = await loteService.findAll({ tenantId, page, limit, search, status, manzanaId, prototipoId }); + + res.status(200).json({ + success: true, + data: result.items, + pagination: { + page, + limit, + total: result.total, + totalPages: Math.ceil(result.total / limit), + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /lotes/stats + * Estadísticas de lotes por estado + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const manzanaId = req.query.manzanaId as string; + const stats = await loteService.getStatsByStatus(tenantId, manzanaId); + + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /lotes/:id + * Obtener lote por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const lote = await loteService.findById(req.params.id, tenantId); + if (!lote) { + res.status(404).json({ error: 'Not Found', message: 'Lot not found' }); + return; + } + + res.status(200).json({ success: true, data: lote }); + } catch (error) { + next(error); + } + }); + + /** + * POST /lotes + * Crear lote + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateLoteDto = req.body; + + if (!dto.manzanaId || !dto.code) { + res.status(400).json({ error: 'Bad Request', message: 'manzanaId and code are required' }); + return; + } + + const lote = await loteService.create(tenantId, dto, req.user?.sub); + res.status(201).json({ success: true, data: lote }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /lotes/:id + * Actualizar lote + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateLoteDto = req.body; + const lote = await loteService.update(req.params.id, tenantId, dto, req.user?.sub); + res.status(200).json({ success: true, data: lote }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Lot not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * PATCH /lotes/:id/prototipo + * Asignar prototipo a lote + */ + router.patch('/:id/prototipo', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { prototipoId } = req.body; + if (!prototipoId) { + res.status(400).json({ error: 'Bad Request', message: 'prototipoId is required' }); + return; + } + + const lote = await loteService.assignPrototipo(req.params.id, tenantId, prototipoId, req.user?.sub); + res.status(200).json({ success: true, data: lote }); + } catch (error) { + if (error instanceof Error && error.message === 'Lot not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /lotes/:id/status + * Cambiar estado del lote + */ + router.patch('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'finance'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { status } = req.body; + if (!status) { + res.status(400).json({ error: 'Bad Request', message: 'status is required' }); + return; + } + + const validStatuses = ['available', 'reserved', 'sold', 'blocked', 'in_construction']; + if (!validStatuses.includes(status)) { + res.status(400).json({ error: 'Bad Request', message: `Invalid status. Must be one of: ${validStatuses.join(', ')}` }); + return; + } + + const lote = await loteService.changeStatus(req.params.id, tenantId, status, req.user?.sub); + res.status(200).json({ success: true, data: lote }); + } catch (error) { + if (error instanceof Error && error.message === 'Lot not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * DELETE /lotes/:id + * Eliminar lote + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + await loteService.delete(req.params.id, tenantId, req.user?.sub); + res.status(200).json({ success: true, message: 'Lot deleted' }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Lot not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message === 'Cannot delete a sold lot') { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + return router; +} + +export default createLoteController; diff --git a/src/modules/construction/controllers/manzana.controller.ts b/src/modules/construction/controllers/manzana.controller.ts new file mode 100644 index 0000000..c5287e3 --- /dev/null +++ b/src/modules/construction/controllers/manzana.controller.ts @@ -0,0 +1,180 @@ +/** + * ManzanaController - Controller de manzanas + * + * Endpoints REST para gestión de manzanas (bloques). + * + * @module Construction + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ManzanaService, CreateManzanaDto, UpdateManzanaDto } from '../services/manzana.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Manzana } from '../entities/manzana.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; + +/** + * Crear router de manzanas + */ +export function createManzanaController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const manzanaRepository = dataSource.getRepository(Manzana); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const manzanaService = new ManzanaService(manzanaRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + /** + * GET /manzanas + * Listar manzanas + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const search = req.query.search as string; + const etapaId = req.query.etapaId as string; + + const result = await manzanaService.findAll({ tenantId, page, limit, search, etapaId }); + + res.status(200).json({ + success: true, + data: result.items, + pagination: { + page, + limit, + total: result.total, + totalPages: Math.ceil(result.total / limit), + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /manzanas/:id + * Obtener manzana por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const manzana = await manzanaService.findById(req.params.id, tenantId); + if (!manzana) { + res.status(404).json({ error: 'Not Found', message: 'Block not found' }); + return; + } + + res.status(200).json({ success: true, data: manzana }); + } catch (error) { + next(error); + } + }); + + /** + * POST /manzanas + * Crear manzana + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateManzanaDto = req.body; + + if (!dto.etapaId || !dto.code) { + res.status(400).json({ error: 'Bad Request', message: 'etapaId and code are required' }); + return; + } + + const manzana = await manzanaService.create(tenantId, dto, req.user?.sub); + res.status(201).json({ success: true, data: manzana }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /manzanas/:id + * Actualizar manzana + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateManzanaDto = req.body; + const manzana = await manzanaService.update(req.params.id, tenantId, dto, req.user?.sub); + res.status(200).json({ success: true, data: manzana }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Block not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * DELETE /manzanas/:id + * Eliminar manzana + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + await manzanaService.delete(req.params.id, tenantId, req.user?.sub); + res.status(200).json({ success: true, message: 'Block deleted' }); + } catch (error) { + if (error instanceof Error && error.message === 'Block not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + return router; +} + +export default createManzanaController; diff --git a/src/modules/construction/controllers/prototipo.controller.ts b/src/modules/construction/controllers/prototipo.controller.ts new file mode 100644 index 0000000..eb5efcf --- /dev/null +++ b/src/modules/construction/controllers/prototipo.controller.ts @@ -0,0 +1,181 @@ +/** + * PrototipoController - Controller de prototipos + * + * Endpoints REST para gestión de prototipos de vivienda. + * + * @module Construction + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { PrototipoService, CreatePrototipoDto, UpdatePrototipoDto } from '../services/prototipo.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Prototipo } from '../entities/prototipo.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; + +/** + * Crear router de prototipos + */ +export function createPrototipoController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const prototipoRepository = dataSource.getRepository(Prototipo); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const prototipoService = new PrototipoService(prototipoRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + /** + * GET /prototipos + * Listar prototipos + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const search = req.query.search as string; + const type = req.query.type as string; + const isActive = req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined; + + const result = await prototipoService.findAll({ tenantId, page, limit, search, type, isActive }); + + res.status(200).json({ + success: true, + data: result.items, + pagination: { + page, + limit, + total: result.total, + totalPages: Math.ceil(result.total / limit), + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /prototipos/:id + * Obtener prototipo por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const prototipo = await prototipoService.findById(req.params.id, tenantId); + if (!prototipo) { + res.status(404).json({ error: 'Not Found', message: 'Prototype not found' }); + return; + } + + res.status(200).json({ success: true, data: prototipo }); + } catch (error) { + next(error); + } + }); + + /** + * POST /prototipos + * Crear prototipo + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreatePrototipoDto = req.body; + + if (!dto.code || !dto.name) { + res.status(400).json({ error: 'Bad Request', message: 'Code and name are required' }); + return; + } + + const prototipo = await prototipoService.create(tenantId, dto, req.user?.sub); + res.status(201).json({ success: true, data: prototipo }); + } catch (error) { + if (error instanceof Error && error.message === 'Prototype code already exists') { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /prototipos/:id + * Actualizar prototipo + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdatePrototipoDto = req.body; + const prototipo = await prototipoService.update(req.params.id, tenantId, dto, req.user?.sub); + res.status(200).json({ success: true, data: prototipo }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Prototype not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message === 'Prototype code already exists') { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * DELETE /prototipos/:id + * Eliminar prototipo + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + await prototipoService.delete(req.params.id, tenantId, req.user?.sub); + res.status(200).json({ success: true, message: 'Prototype deleted' }); + } catch (error) { + if (error instanceof Error && error.message === 'Prototype not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + return router; +} + +export default createPrototipoController; diff --git a/src/modules/construction/controllers/proyecto.controller.ts b/src/modules/construction/controllers/proyecto.controller.ts new file mode 100644 index 0000000..d87ee2c --- /dev/null +++ b/src/modules/construction/controllers/proyecto.controller.ts @@ -0,0 +1,165 @@ +/** + * Proyecto Controller + * API endpoints para gestión de proyectos + * + * @module Construction + * @prefix /api/v1/proyectos + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { ProyectoService, CreateProyectoDto, UpdateProyectoDto } from '../services/proyecto.service'; + +const router = Router(); +const proyectoService = new ProyectoService(); + +/** + * GET /api/v1/proyectos + * Lista todos los proyectos del tenant + */ +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const { estadoProyecto, ciudad } = req.query; + + const proyectos = await proyectoService.findAll({ + tenantId, + estadoProyecto: estadoProyecto as any, + ciudad: ciudad as string, + }); + + return res.json({ + success: true, + data: proyectos, + count: proyectos.length, + }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/proyectos/statistics + * Estadísticas de proyectos + */ +router.get('/statistics', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const stats = await proyectoService.getStatistics(tenantId); + return res.json({ success: true, data: stats }); + } catch (error) { + return next(error); + } +}); + +/** + * GET /api/v1/proyectos/:id + * Obtiene un proyecto por ID + */ +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const proyecto = await proyectoService.findById(req.params.id, tenantId); + if (!proyecto) { + return res.status(404).json({ error: 'Proyecto no encontrado' }); + } + + return res.json({ success: true, data: proyecto }); + } catch (error) { + return next(error); + } +}); + +/** + * POST /api/v1/proyectos + * Crea un nuevo proyecto + */ +router.post('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: CreateProyectoDto = { + ...req.body, + tenantId, + createdById: (req as any).user?.id, + }; + + // Validate required fields + if (!data.codigo || !data.nombre) { + return res.status(400).json({ error: 'codigo y nombre son requeridos' }); + } + + // Check if codigo already exists + const existing = await proyectoService.findByCodigo(data.codigo, tenantId); + if (existing) { + return res.status(409).json({ error: 'Ya existe un proyecto con ese código' }); + } + + const proyecto = await proyectoService.create(data); + return res.status(201).json({ success: true, data: proyecto }); + } catch (error) { + return next(error); + } +}); + +/** + * PATCH /api/v1/proyectos/:id + * Actualiza un proyecto + */ +router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const data: UpdateProyectoDto = req.body; + const proyecto = await proyectoService.update(req.params.id, tenantId, data); + + if (!proyecto) { + return res.status(404).json({ error: 'Proyecto no encontrado' }); + } + + return res.json({ success: true, data: proyecto }); + } catch (error) { + return next(error); + } +}); + +/** + * DELETE /api/v1/proyectos/:id + * Elimina un proyecto + */ +router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'X-Tenant-Id header required' }); + } + + const deleted = await proyectoService.delete(req.params.id, tenantId); + if (!deleted) { + return res.status(404).json({ error: 'Proyecto no encontrado' }); + } + + return res.json({ success: true, message: 'Proyecto eliminado' }); + } catch (error) { + return next(error); + } +}); + +export default router; diff --git a/src/modules/construction/entities/etapa.entity.ts b/src/modules/construction/entities/etapa.entity.ts new file mode 100644 index 0000000..bc37c7a --- /dev/null +++ b/src/modules/construction/entities/etapa.entity.ts @@ -0,0 +1,83 @@ +/** + * Etapa Entity + * Etapas/Fases de un fraccionamiento + * + * @module Construction + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Fraccionamiento } from './fraccionamiento.entity'; +import { Manzana } from './manzana.entity'; + +@Entity({ schema: 'construction', name: 'etapas' }) +@Index(['fraccionamientoId', 'code'], { unique: true }) +export class Etapa { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'integer', default: 1 }) + sequence: number; + + @Column({ name: 'total_lots', type: 'integer', default: 0 }) + totalLots: number; + + @Column({ type: 'varchar', length: 50, default: 'draft' }) + status: string; + + @Column({ name: 'start_date', type: 'date', nullable: true }) + startDate: Date; + + @Column({ name: 'expected_end_date', type: 'date', nullable: true }) + expectedEndDate: Date; + + @Column({ name: 'actual_end_date', type: 'date', nullable: true }) + actualEndDate: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relations + @ManyToOne(() => Fraccionamiento, (f) => f.etapas, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @OneToMany(() => Manzana, (m) => m.etapa) + manzanas: Manzana[]; +} diff --git a/src/modules/construction/entities/fraccionamiento.entity.ts b/src/modules/construction/entities/fraccionamiento.entity.ts new file mode 100644 index 0000000..cb66c7d --- /dev/null +++ b/src/modules/construction/entities/fraccionamiento.entity.ts @@ -0,0 +1,95 @@ +/** + * Fraccionamiento Entity + * Obras/fraccionamientos dentro de un proyecto + * + * @module Construction + * @table construction.fraccionamientos + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Proyecto } from './proyecto.entity'; +import { Etapa } from './etapa.entity'; + +export type EstadoFraccionamiento = 'activo' | 'pausado' | 'completado' | 'cancelado'; + +@Entity({ schema: 'construction', name: 'fraccionamientos' }) +@Index(['tenantId', 'codigo'], { unique: true }) +export class Fraccionamiento { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'proyecto_id', type: 'uuid' }) + proyectoId: string; + + @Column({ type: 'varchar', length: 20 }) + codigo: string; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ type: 'text', nullable: true }) + direccion: string; + + // PostGIS geometry - stored as GeoJSON for TypeORM compatibility + @Column({ + name: 'ubicacion_geo', + type: 'geometry', + spatialFeatureType: 'Point', + srid: 4326, + nullable: true + }) + ubicacionGeo: string; + + @Column({ name: 'fecha_inicio', type: 'date', nullable: true }) + fechaInicio: Date; + + @Column({ name: 'fecha_fin_estimada', type: 'date', nullable: true }) + fechaFinEstimada: Date; + + @Column({ type: 'varchar', length: 20, default: 'activo' }) + estado: EstadoFraccionamiento; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Proyecto, (p) => p.fraccionamientos) + @JoinColumn({ name: 'proyecto_id' }) + proyecto: Proyecto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => Etapa, (e) => e.fraccionamiento) + etapas: Etapa[]; +} diff --git a/src/modules/construction/entities/index.ts b/src/modules/construction/entities/index.ts new file mode 100644 index 0000000..6bfb8b8 --- /dev/null +++ b/src/modules/construction/entities/index.ts @@ -0,0 +1,11 @@ +/** + * Construction Entities Index + * @module Construction + */ + +export { Proyecto } from './proyecto.entity'; +export { Fraccionamiento } from './fraccionamiento.entity'; +export { Etapa } from './etapa.entity'; +export { Manzana } from './manzana.entity'; +export { Lote } from './lote.entity'; +export { Prototipo } from './prototipo.entity'; diff --git a/src/modules/construction/entities/lote.entity.ts b/src/modules/construction/entities/lote.entity.ts new file mode 100644 index 0000000..9da49c0 --- /dev/null +++ b/src/modules/construction/entities/lote.entity.ts @@ -0,0 +1,92 @@ +/** + * Lote Entity + * Lotes/Terrenos individuales + * + * @module Construction + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Manzana } from './manzana.entity'; +import { Prototipo } from './prototipo.entity'; + +@Entity({ schema: 'construction', name: 'lotes' }) +@Index(['manzanaId', 'code'], { unique: true }) +export class Lote { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'manzana_id', type: 'uuid' }) + manzanaId: string; + + @Column({ name: 'prototipo_id', type: 'uuid', nullable: true }) + prototipoId: string; + + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ name: 'official_number', type: 'varchar', length: 50, nullable: true }) + officialNumber: string; + + @Column({ name: 'area_m2', type: 'decimal', precision: 10, scale: 2, nullable: true }) + areaM2: number; + + @Column({ name: 'front_m', type: 'decimal', precision: 8, scale: 2, nullable: true }) + frontM: number; + + @Column({ name: 'depth_m', type: 'decimal', precision: 8, scale: 2, nullable: true }) + depthM: number; + + @Column({ type: 'varchar', length: 50, default: 'available' }) + status: string; + + @Column({ name: 'price_base', type: 'decimal', precision: 14, scale: 2, nullable: true }) + priceBase: number; + + @Column({ name: 'price_final', type: 'decimal', precision: 14, scale: 2, nullable: true }) + priceFinal: number; + + @Column({ name: 'buyer_id', type: 'uuid', nullable: true }) + buyerId: string; + + @Column({ name: 'sale_date', type: 'date', nullable: true }) + saleDate: Date; + + @Column({ name: 'delivery_date', type: 'date', nullable: true }) + deliveryDate: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relations + @ManyToOne(() => Manzana, (m) => m.lotes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'manzana_id' }) + manzana: Manzana; + + @ManyToOne(() => Prototipo) + @JoinColumn({ name: 'prototipo_id' }) + prototipo: Prototipo; +} diff --git a/src/modules/construction/entities/manzana.entity.ts b/src/modules/construction/entities/manzana.entity.ts new file mode 100644 index 0000000..a20613d --- /dev/null +++ b/src/modules/construction/entities/manzana.entity.ts @@ -0,0 +1,65 @@ +/** + * Manzana Entity + * Manzanas (bloques) dentro de una etapa + * + * @module Construction + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Etapa } from './etapa.entity'; +import { Lote } from './lote.entity'; + +@Entity({ schema: 'construction', name: 'manzanas' }) +@Index(['etapaId', 'code'], { unique: true }) +export class Manzana { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'etapa_id', type: 'uuid' }) + etapaId: string; + + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + name: string; + + @Column({ name: 'total_lots', type: 'integer', default: 0 }) + totalLots: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relations + @ManyToOne(() => Etapa, (e) => e.manzanas, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'etapa_id' }) + etapa: Etapa; + + @OneToMany(() => Lote, (l) => l.manzana) + lotes: Lote[]; +} diff --git a/src/modules/construction/entities/prototipo.entity.ts b/src/modules/construction/entities/prototipo.entity.ts new file mode 100644 index 0000000..cffc3ad --- /dev/null +++ b/src/modules/construction/entities/prototipo.entity.ts @@ -0,0 +1,85 @@ +/** + * Prototipo Entity + * Prototipos de vivienda (modelos) + * + * @module Construction + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'construction', name: 'prototipos' }) +@Index(['tenantId', 'code'], { unique: true }) +export class Prototipo { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'varchar', length: 50, default: 'horizontal' }) + type: string; + + @Column({ name: 'area_construction_m2', type: 'decimal', precision: 10, scale: 2, nullable: true }) + areaConstructionM2: number; + + @Column({ name: 'area_terrain_m2', type: 'decimal', precision: 10, scale: 2, nullable: true }) + areaTerrainM2: number; + + @Column({ type: 'integer', default: 0 }) + bedrooms: number; + + @Column({ type: 'decimal', precision: 3, scale: 1, default: 0 }) + bathrooms: number; + + @Column({ name: 'parking_spaces', type: 'integer', default: 0 }) + parkingSpaces: number; + + @Column({ type: 'integer', default: 1 }) + floors: number; + + @Column({ name: 'base_price', type: 'decimal', precision: 14, scale: 2, nullable: true }) + basePrice: number; + + @Column({ name: 'blueprint_url', type: 'varchar', length: 500, nullable: true }) + blueprintUrl: string; + + @Column({ name: 'render_url', type: 'varchar', length: 500, nullable: true }) + renderUrl: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/construction/entities/proyecto.entity.ts b/src/modules/construction/entities/proyecto.entity.ts new file mode 100644 index 0000000..95964d4 --- /dev/null +++ b/src/modules/construction/entities/proyecto.entity.ts @@ -0,0 +1,88 @@ +/** + * Proyecto Entity + * Proyectos de desarrollo inmobiliario + * + * @module Construction + * @table construction.proyectos + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from './fraccionamiento.entity'; + +export type EstadoProyecto = 'activo' | 'pausado' | 'completado' | 'cancelado'; + +@Entity({ schema: 'construction', name: 'proyectos' }) +@Index(['tenantId', 'codigo'], { unique: true }) +export class Proyecto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + codigo: string; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ type: 'text', nullable: true }) + direccion: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + ciudad: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + estado: string; + + @Column({ name: 'fecha_inicio', type: 'date', nullable: true }) + fechaInicio: Date; + + @Column({ name: 'fecha_fin_estimada', type: 'date', nullable: true }) + fechaFinEstimada: Date; + + @Column({ + name: 'estado_proyecto', + type: 'varchar', + length: 20, + default: 'activo' + }) + estadoProyecto: EstadoProyecto; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => Fraccionamiento, (f) => f.proyecto) + fraccionamientos: Fraccionamiento[]; +} diff --git a/src/modules/construction/services/etapa.service.ts b/src/modules/construction/services/etapa.service.ts new file mode 100644 index 0000000..e43e492 --- /dev/null +++ b/src/modules/construction/services/etapa.service.ts @@ -0,0 +1,163 @@ +/** + * EtapaService - Gestión de etapas de fraccionamientos + * + * CRUD de etapas con soporte multi-tenant. + * + * @module Construction + */ + +import { Repository, IsNull } from 'typeorm'; +import { Etapa } from '../entities/etapa.entity'; + +export interface CreateEtapaDto { + fraccionamientoId: string; + code: string; + name: string; + description?: string; + sequence?: number; + totalLots?: number; + status?: string; + startDate?: Date; + expectedEndDate?: Date; +} + +export interface UpdateEtapaDto extends Partial { + actualEndDate?: Date; +} + +export interface EtapaListOptions { + tenantId: string; + fraccionamientoId?: string; + page?: number; + limit?: number; + search?: string; + status?: string; +} + +export class EtapaService { + constructor(private readonly repository: Repository) {} + + /** + * Listar etapas + */ + async findAll(options: EtapaListOptions): Promise<{ items: Etapa[]; total: number }> { + const { tenantId, fraccionamientoId, page = 1, limit = 20, search, status } = options; + + const query = this.repository + .createQueryBuilder('e') + .where('e.tenant_id = :tenantId', { tenantId }) + .andWhere('e.deleted_at IS NULL'); + + if (fraccionamientoId) { + query.andWhere('e.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + if (search) { + query.andWhere('(e.code ILIKE :search OR e.name ILIKE :search)', { search: `%${search}%` }); + } + + if (status) { + query.andWhere('e.status = :status', { status }); + } + + const total = await query.getCount(); + const items = await query + .skip((page - 1) * limit) + .take(limit) + .orderBy('e.sequence', 'ASC') + .addOrderBy('e.name', 'ASC') + .getMany(); + + return { items, total }; + } + + /** + * Obtener etapa por ID + */ + async findById(id: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { id, tenantId, deletedAt: IsNull() } as any, + relations: ['manzanas'], + }); + } + + /** + * Obtener etapa por código dentro de un fraccionamiento + */ + async findByCode(code: string, fraccionamientoId: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { code, fraccionamientoId, tenantId, deletedAt: IsNull() } as any, + }); + } + + /** + * Crear etapa + */ + async create(tenantId: string, dto: CreateEtapaDto, createdBy?: string): Promise { + const existing = await this.findByCode(dto.code, dto.fraccionamientoId, tenantId); + if (existing) { + throw new Error('Stage code already exists in this fraccionamiento'); + } + + return this.repository.save( + this.repository.create({ + tenantId, + ...dto, + createdBy, + status: dto.status || 'draft', + }) + ); + } + + /** + * Actualizar etapa + */ + async update(id: string, tenantId: string, dto: UpdateEtapaDto, updatedBy?: string): Promise { + const etapa = await this.findById(id, tenantId); + if (!etapa) { + throw new Error('Stage not found'); + } + + // Verificar código único si se está cambiando + if (dto.code && dto.code !== etapa.code) { + const existing = await this.findByCode(dto.code, etapa.fraccionamientoId, tenantId); + if (existing) { + throw new Error('Stage code already exists in this fraccionamiento'); + } + } + + await this.repository.update(id, { + ...dto, + updatedBy, + updatedAt: new Date(), + }); + + return this.findById(id, tenantId) as Promise; + } + + /** + * Eliminar etapa (soft delete) + */ + async delete(id: string, tenantId: string, _deletedBy?: string): Promise { + const etapa = await this.findById(id, tenantId); + if (!etapa) { + throw new Error('Stage not found'); + } + + // TODO: Verificar si tiene manzanas antes de eliminar + + await this.repository.update(id, { + deletedAt: new Date(), + }); + } + + /** + * Obtener etapas por fraccionamiento + */ + async findByFraccionamiento(fraccionamientoId: string, tenantId: string): Promise { + return this.repository.find({ + where: { fraccionamientoId, tenantId, deletedAt: IsNull() } as any, + order: { sequence: 'ASC' }, + }); + } +} diff --git a/src/modules/construction/services/fraccionamiento.service.ts b/src/modules/construction/services/fraccionamiento.service.ts new file mode 100644 index 0000000..604b234 --- /dev/null +++ b/src/modules/construction/services/fraccionamiento.service.ts @@ -0,0 +1,117 @@ +/** + * Fraccionamiento Service + * Servicio para gestión de fraccionamientos/obras + * + * @module Construction + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { Fraccionamiento, EstadoFraccionamiento } from '../entities/fraccionamiento.entity'; + +export interface CreateFraccionamientoDto { + tenantId: string; + proyectoId: string; + codigo: string; + nombre: string; + descripcion?: string; + direccion?: string; + ubicacionGeo?: string; + fechaInicio?: Date; + fechaFinEstimada?: Date; + createdById?: string; +} + +export interface UpdateFraccionamientoDto { + nombre?: string; + descripcion?: string; + direccion?: string; + ubicacionGeo?: string; + fechaInicio?: Date; + fechaFinEstimada?: Date; + estado?: EstadoFraccionamiento; +} + +export interface FraccionamientoFilters { + tenantId: string; + proyectoId?: string; + estado?: EstadoFraccionamiento; +} + +export class FraccionamientoService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(Fraccionamiento); + } + + async findAll(filters: FraccionamientoFilters): Promise { + const where: FindOptionsWhere = { + tenantId: filters.tenantId, + }; + + if (filters.proyectoId) { + where.proyectoId = filters.proyectoId; + } + + if (filters.estado) { + where.estado = filters.estado; + } + + return this.repository.find({ + where, + relations: ['proyecto'], + order: { createdAt: 'DESC' }, + }); + } + + async findById(id: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['proyecto', 'createdBy'], + }); + } + + async findByCodigo(codigo: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { codigo, tenantId }, + }); + } + + async findByProyecto(proyectoId: string, tenantId: string): Promise { + return this.repository.find({ + where: { proyectoId, tenantId }, + order: { codigo: 'ASC' }, + }); + } + + async create(data: CreateFraccionamientoDto): Promise { + const fraccionamiento = this.repository.create(data); + return this.repository.save(fraccionamiento); + } + + async update( + id: string, + tenantId: string, + data: UpdateFraccionamientoDto + ): Promise { + const fraccionamiento = await this.findById(id, tenantId); + if (!fraccionamiento) { + return null; + } + + Object.assign(fraccionamiento, data); + return this.repository.save(fraccionamiento); + } + + async delete(id: string, tenantId: string): Promise { + const result = await this.repository.delete({ id, tenantId }); + return result.affected ? result.affected > 0 : false; + } + + async countByProyecto(proyectoId: string, tenantId: string): Promise { + return this.repository.count({ + where: { proyectoId, tenantId }, + }); + } +} diff --git a/src/modules/construction/services/index.ts b/src/modules/construction/services/index.ts new file mode 100644 index 0000000..743750a --- /dev/null +++ b/src/modules/construction/services/index.ts @@ -0,0 +1,11 @@ +/** + * Construction Services Index + * @module Construction + */ + +export * from './proyecto.service'; +export * from './fraccionamiento.service'; +export * from './etapa.service'; +export * from './manzana.service'; +export * from './lote.service'; +export * from './prototipo.service'; diff --git a/src/modules/construction/services/lote.service.ts b/src/modules/construction/services/lote.service.ts new file mode 100644 index 0000000..50beb8a --- /dev/null +++ b/src/modules/construction/services/lote.service.ts @@ -0,0 +1,230 @@ +/** + * LoteService - Gestión de lotes/terrenos + * + * CRUD de lotes con soporte multi-tenant. + * + * @module Construction + */ + +import { Repository, IsNull } from 'typeorm'; +import { Lote } from '../entities/lote.entity'; + +export interface CreateLoteDto { + manzanaId: string; + prototipoId?: string; + code: string; + officialNumber?: string; + areaM2?: number; + frontM?: number; + depthM?: number; + status?: string; + priceBase?: number; + priceFinal?: number; +} + +export interface UpdateLoteDto extends Partial { + buyerId?: string; + saleDate?: Date; + deliveryDate?: Date; +} + +export interface LoteListOptions { + tenantId: string; + manzanaId?: string; + prototipoId?: string; + page?: number; + limit?: number; + search?: string; + status?: string; +} + +export class LoteService { + constructor(private readonly repository: Repository) {} + + /** + * Listar lotes + */ + async findAll(options: LoteListOptions): Promise<{ items: Lote[]; total: number }> { + const { tenantId, manzanaId, prototipoId, page = 1, limit = 20, search, status } = options; + + const query = this.repository + .createQueryBuilder('l') + .where('l.tenant_id = :tenantId', { tenantId }) + .andWhere('l.deleted_at IS NULL'); + + if (manzanaId) { + query.andWhere('l.manzana_id = :manzanaId', { manzanaId }); + } + + if (prototipoId) { + query.andWhere('l.prototipo_id = :prototipoId', { prototipoId }); + } + + if (search) { + query.andWhere('(l.code ILIKE :search OR l.official_number ILIKE :search)', { search: `%${search}%` }); + } + + if (status) { + query.andWhere('l.status = :status', { status }); + } + + const total = await query.getCount(); + const items = await query + .leftJoinAndSelect('l.prototipo', 'prototipo') + .skip((page - 1) * limit) + .take(limit) + .orderBy('l.code', 'ASC') + .getMany(); + + return { items, total }; + } + + /** + * Obtener lote por ID + */ + async findById(id: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { id, tenantId, deletedAt: IsNull() } as any, + relations: ['prototipo', 'manzana'], + }); + } + + /** + * Obtener lote por código dentro de una manzana + */ + async findByCode(code: string, manzanaId: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { code, manzanaId, tenantId, deletedAt: IsNull() } as any, + }); + } + + /** + * Crear lote + */ + async create(tenantId: string, dto: CreateLoteDto, createdBy?: string): Promise { + const existing = await this.findByCode(dto.code, dto.manzanaId, tenantId); + if (existing) { + throw new Error('Lot code already exists in this block'); + } + + return this.repository.save( + this.repository.create({ + tenantId, + ...dto, + createdBy, + status: dto.status || 'available', + }) + ); + } + + /** + * Actualizar lote + */ + async update(id: string, tenantId: string, dto: UpdateLoteDto, updatedBy?: string): Promise { + const lote = await this.findById(id, tenantId); + if (!lote) { + throw new Error('Lot not found'); + } + + // Verificar código único si se está cambiando + if (dto.code && dto.code !== lote.code) { + const existing = await this.findByCode(dto.code, lote.manzanaId, tenantId); + if (existing) { + throw new Error('Lot code already exists in this block'); + } + } + + await this.repository.update(id, { + ...dto, + updatedBy, + updatedAt: new Date(), + }); + + return this.findById(id, tenantId) as Promise; + } + + /** + * Eliminar lote (soft delete) + */ + async delete(id: string, tenantId: string, _deletedBy?: string): Promise { + const lote = await this.findById(id, tenantId); + if (!lote) { + throw new Error('Lot not found'); + } + + // Verificar que no esté vendido + if (lote.status === 'sold') { + throw new Error('Cannot delete a sold lot'); + } + + await this.repository.update(id, { + deletedAt: new Date(), + }); + } + + /** + * Obtener lotes por manzana + */ + async findByManzana(manzanaId: string, tenantId: string): Promise { + return this.repository.find({ + where: { manzanaId, tenantId, deletedAt: IsNull() } as any, + order: { code: 'ASC' }, + relations: ['prototipo'], + }); + } + + /** + * Asignar prototipo a lote + */ + async assignPrototipo(id: string, tenantId: string, prototipoId: string, updatedBy?: string): Promise { + const lote = await this.findById(id, tenantId); + if (!lote) { + throw new Error('Lot not found'); + } + + await this.repository.update(id, { + prototipoId, + updatedBy, + updatedAt: new Date(), + }); + + return this.findById(id, tenantId) as Promise; + } + + /** + * Cambiar estado del lote + */ + async changeStatus(id: string, tenantId: string, status: string, updatedBy?: string): Promise { + const lote = await this.findById(id, tenantId); + if (!lote) { + throw new Error('Lot not found'); + } + + await this.repository.update(id, { + status, + updatedBy, + updatedAt: new Date(), + }); + + return this.findById(id, tenantId) as Promise; + } + + /** + * Obtener estadísticas de lotes por estado + */ + async getStatsByStatus(tenantId: string, manzanaId?: string): Promise<{ status: string; count: number }[]> { + const query = this.repository + .createQueryBuilder('l') + .select('l.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('l.tenant_id = :tenantId', { tenantId }) + .andWhere('l.deleted_at IS NULL') + .groupBy('l.status'); + + if (manzanaId) { + query.andWhere('l.manzana_id = :manzanaId', { manzanaId }); + } + + return query.getRawMany(); + } +} diff --git a/src/modules/construction/services/manzana.service.ts b/src/modules/construction/services/manzana.service.ts new file mode 100644 index 0000000..3b96307 --- /dev/null +++ b/src/modules/construction/services/manzana.service.ts @@ -0,0 +1,149 @@ +/** + * ManzanaService - Gestión de manzanas (bloques) + * + * CRUD de manzanas con soporte multi-tenant. + * + * @module Construction + */ + +import { Repository, IsNull } from 'typeorm'; +import { Manzana } from '../entities/manzana.entity'; + +export interface CreateManzanaDto { + etapaId: string; + code: string; + name?: string; + totalLots?: number; +} + +export interface UpdateManzanaDto extends Partial {} + +export interface ManzanaListOptions { + tenantId: string; + etapaId?: string; + page?: number; + limit?: number; + search?: string; +} + +export class ManzanaService { + constructor(private readonly repository: Repository) {} + + /** + * Listar manzanas + */ + async findAll(options: ManzanaListOptions): Promise<{ items: Manzana[]; total: number }> { + const { tenantId, etapaId, page = 1, limit = 20, search } = options; + + const query = this.repository + .createQueryBuilder('m') + .where('m.tenant_id = :tenantId', { tenantId }) + .andWhere('m.deleted_at IS NULL'); + + if (etapaId) { + query.andWhere('m.etapa_id = :etapaId', { etapaId }); + } + + if (search) { + query.andWhere('(m.code ILIKE :search OR m.name ILIKE :search)', { search: `%${search}%` }); + } + + const total = await query.getCount(); + const items = await query + .skip((page - 1) * limit) + .take(limit) + .orderBy('m.code', 'ASC') + .getMany(); + + return { items, total }; + } + + /** + * Obtener manzana por ID + */ + async findById(id: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { id, tenantId, deletedAt: IsNull() } as any, + relations: ['lotes'], + }); + } + + /** + * Obtener manzana por código dentro de una etapa + */ + async findByCode(code: string, etapaId: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { code, etapaId, tenantId, deletedAt: IsNull() } as any, + }); + } + + /** + * Crear manzana + */ + async create(tenantId: string, dto: CreateManzanaDto, createdBy?: string): Promise { + const existing = await this.findByCode(dto.code, dto.etapaId, tenantId); + if (existing) { + throw new Error('Block code already exists in this stage'); + } + + return this.repository.save( + this.repository.create({ + tenantId, + ...dto, + createdBy, + }) + ); + } + + /** + * Actualizar manzana + */ + async update(id: string, tenantId: string, dto: UpdateManzanaDto, updatedBy?: string): Promise { + const manzana = await this.findById(id, tenantId); + if (!manzana) { + throw new Error('Block not found'); + } + + // Verificar código único si se está cambiando + if (dto.code && dto.code !== manzana.code) { + const existing = await this.findByCode(dto.code, manzana.etapaId, tenantId); + if (existing) { + throw new Error('Block code already exists in this stage'); + } + } + + await this.repository.update(id, { + ...dto, + updatedBy, + updatedAt: new Date(), + }); + + return this.findById(id, tenantId) as Promise; + } + + /** + * Eliminar manzana (soft delete) + */ + async delete(id: string, tenantId: string, _deletedBy?: string): Promise { + const manzana = await this.findById(id, tenantId); + if (!manzana) { + throw new Error('Block not found'); + } + + // TODO: Verificar si tiene lotes antes de eliminar + + await this.repository.update(id, { + deletedAt: new Date(), + }); + } + + /** + * Obtener manzanas por etapa + */ + async findByEtapa(etapaId: string, tenantId: string): Promise { + return this.repository.find({ + where: { etapaId, tenantId, deletedAt: IsNull() } as any, + order: { code: 'ASC' }, + }); + } +} diff --git a/src/modules/construction/services/prototipo.service.ts b/src/modules/construction/services/prototipo.service.ts new file mode 100644 index 0000000..39cf51f --- /dev/null +++ b/src/modules/construction/services/prototipo.service.ts @@ -0,0 +1,173 @@ +/** + * PrototipoService - Gestión de prototipos de vivienda + * + * CRUD de prototipos con soporte multi-tenant. + * + * @module Construction + */ + +import { Repository, IsNull } from 'typeorm'; +import { Prototipo } from '../entities/prototipo.entity'; + +export interface CreatePrototipoDto { + code: string; + name: string; + description?: string; + type?: string; + areaConstructionM2?: number; + areaTerrainM2?: number; + bedrooms?: number; + bathrooms?: number; + parkingSpaces?: number; + floors?: number; + basePrice?: number; + blueprintUrl?: string; + renderUrl?: string; + metadata?: Record; +} + +export interface UpdatePrototipoDto extends Partial { + isActive?: boolean; +} + +export interface PrototipoListOptions { + tenantId: string; + page?: number; + limit?: number; + search?: string; + type?: string; + isActive?: boolean; +} + +export class PrototipoService { + constructor(private readonly repository: Repository) {} + + /** + * Listar prototipos + */ + async findAll(options: PrototipoListOptions): Promise<{ items: Prototipo[]; total: number }> { + const { tenantId, page = 1, limit = 20, search, type, isActive } = options; + + const query = this.repository + .createQueryBuilder('p') + .where('p.tenant_id = :tenantId', { tenantId }) + .andWhere('p.deleted_at IS NULL'); + + if (search) { + query.andWhere('(p.code ILIKE :search OR p.name ILIKE :search)', { search: `%${search}%` }); + } + + if (type) { + query.andWhere('p.type = :type', { type }); + } + + if (isActive !== undefined) { + query.andWhere('p.is_active = :isActive', { isActive }); + } + + const total = await query.getCount(); + const items = await query + .skip((page - 1) * limit) + .take(limit) + .orderBy('p.name', 'ASC') + .getMany(); + + return { items, total }; + } + + /** + * Obtener prototipo por ID + */ + async findById(id: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { id, tenantId, deletedAt: IsNull() } as any, + }); + } + + /** + * Obtener prototipo por código + */ + async findByCode(code: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { code, tenantId, deletedAt: IsNull() } as any, + }); + } + + /** + * Crear prototipo + */ + async create(tenantId: string, dto: CreatePrototipoDto, createdBy?: string): Promise { + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new Error('Prototype code already exists'); + } + + return this.repository.save( + this.repository.create({ + tenantId, + ...dto, + createdBy, + isActive: true, + }) + ); + } + + /** + * Actualizar prototipo + */ + async update(id: string, tenantId: string, dto: UpdatePrototipoDto, updatedBy?: string): Promise { + const prototipo = await this.findById(id, tenantId); + if (!prototipo) { + throw new Error('Prototype not found'); + } + + // Verificar código único si se está cambiando + if (dto.code && dto.code !== prototipo.code) { + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new Error('Prototype code already exists'); + } + } + + // Exclude metadata from spread to avoid TypeORM type issues + const { metadata, ...updateData } = dto; + await this.repository.update(id, { + ...updateData, + ...(metadata && { metadata: metadata as any }), + updatedBy, + updatedAt: new Date(), + }); + + return this.findById(id, tenantId) as Promise; + } + + /** + * Eliminar prototipo (soft delete) + */ + async delete(id: string, tenantId: string, _deletedBy?: string): Promise { + const prototipo = await this.findById(id, tenantId); + if (!prototipo) { + throw new Error('Prototype not found'); + } + + // TODO: Verificar si está asignado a lotes antes de eliminar + + await this.repository.update(id, { + deletedAt: new Date(), + isActive: false, + }); + } + + /** + * Activar/Desactivar prototipo + */ + async setActive(id: string, tenantId: string, isActive: boolean): Promise { + const prototipo = await this.findById(id, tenantId); + if (!prototipo) { + throw new Error('Prototype not found'); + } + + await this.repository.update(id, { isActive }); + return this.findById(id, tenantId) as Promise; + } +} diff --git a/src/modules/construction/services/proyecto.service.ts b/src/modules/construction/services/proyecto.service.ts new file mode 100644 index 0000000..ae55f2d --- /dev/null +++ b/src/modules/construction/services/proyecto.service.ts @@ -0,0 +1,117 @@ +/** + * Proyecto Service + * Servicio para gestión de proyectos de construcción + * + * @module Construction + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { AppDataSource } from '../../../shared/database/typeorm.config'; +import { Proyecto, EstadoProyecto } from '../entities/proyecto.entity'; + +export interface CreateProyectoDto { + tenantId: string; + codigo: string; + nombre: string; + descripcion?: string; + direccion?: string; + ciudad?: string; + estado?: string; + fechaInicio?: Date; + fechaFinEstimada?: Date; + createdById?: string; +} + +export interface UpdateProyectoDto { + nombre?: string; + descripcion?: string; + direccion?: string; + ciudad?: string; + estado?: string; + fechaInicio?: Date; + fechaFinEstimada?: Date; + estadoProyecto?: EstadoProyecto; +} + +export interface ProyectoFilters { + tenantId: string; + estadoProyecto?: EstadoProyecto; + ciudad?: string; +} + +export class ProyectoService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(Proyecto); + } + + async findAll(filters: ProyectoFilters): Promise { + const where: FindOptionsWhere = { + tenantId: filters.tenantId, + }; + + if (filters.estadoProyecto) { + where.estadoProyecto = filters.estadoProyecto; + } + + if (filters.ciudad) { + where.ciudad = filters.ciudad; + } + + return this.repository.find({ + where, + relations: ['fraccionamientos'], + order: { createdAt: 'DESC' }, + }); + } + + async findById(id: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['fraccionamientos', 'createdBy'], + }); + } + + async findByCodigo(codigo: string, tenantId: string): Promise { + return this.repository.findOne({ + where: { codigo, tenantId }, + }); + } + + async create(data: CreateProyectoDto): Promise { + const proyecto = this.repository.create(data); + return this.repository.save(proyecto); + } + + async update(id: string, tenantId: string, data: UpdateProyectoDto): Promise { + const proyecto = await this.findById(id, tenantId); + if (!proyecto) { + return null; + } + + Object.assign(proyecto, data); + return this.repository.save(proyecto); + } + + async delete(id: string, tenantId: string): Promise { + const result = await this.repository.delete({ id, tenantId }); + return result.affected ? result.affected > 0 : false; + } + + async getStatistics(tenantId: string): Promise<{ + total: number; + activos: number; + completados: number; + pausados: number; + }> { + const proyectos = await this.repository.find({ where: { tenantId } }); + + return { + total: proyectos.length, + activos: proyectos.filter(p => p.estadoProyecto === 'activo').length, + completados: proyectos.filter(p => p.estadoProyecto === 'completado').length, + pausados: proyectos.filter(p => p.estadoProyecto === 'pausado').length, + }; + } +} diff --git a/src/modules/contracts/controllers/contract.controller.ts b/src/modules/contracts/controllers/contract.controller.ts new file mode 100644 index 0000000..ed8bd49 --- /dev/null +++ b/src/modules/contracts/controllers/contract.controller.ts @@ -0,0 +1,415 @@ +/** + * ContractController - REST API for contracts + * + * Endpoints para gestión de contratos. + * + * @module Contracts + * @routes /api/contracts + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ContractService, ContractFilters } from '../services/contract.service'; +import { Contract } from '../entities/contract.entity'; +import { ContractAddendum } from '../entities/contract-addendum.entity'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createContractController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const contractRepo = dataSource.getRepository(Contract); + const addendumRepo = dataSource.getRepository(ContractAddendum); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const service = new ContractService(contractRepo, addendumRepo); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper for service context + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /api/contracts + * List contracts with filters + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: ContractFilters = {}; + if (req.query.projectId) filters.projectId = req.query.projectId as string; + if (req.query.fraccionamientoId) filters.fraccionamientoId = req.query.fraccionamientoId as string; + if (req.query.contractType) filters.contractType = req.query.contractType as ContractFilters['contractType']; + if (req.query.subcontractorId) filters.subcontractorId = req.query.subcontractorId as string; + if (req.query.status) filters.status = req.query.status as ContractFilters['status']; + if (req.query.expiringInDays) filters.expiringInDays = parseInt(req.query.expiringInDays as string); + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await service.findWithFilters(getContext(req), filters, page, limit); + res.status(200).json({ success: true, data: result.data, pagination: result.meta }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/contracts/expiring + * Get contracts expiring soon + */ + router.get('/expiring', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const days = parseInt(req.query.days as string) || 30; + const contracts = await service.getExpiringContracts(getContext(req), days); + res.status(200).json({ success: true, data: contracts }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/contracts/:id + * Get contract with details + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const contract = await service.findWithDetails(getContext(req), req.params.id); + if (!contract) { + res.status(404).json({ error: 'Not Found', message: 'Contract not found' }); + return; + } + + res.status(200).json({ success: true, data: contract }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/contracts + * Create new contract + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const contract = await service.create(getContext(req), { + projectId: req.body.projectId, + fraccionamientoId: req.body.fraccionamientoId, + contractType: req.body.contractType, + clientContractType: req.body.clientContractType, + name: req.body.name, + description: req.body.description, + clientName: req.body.clientName, + clientRfc: req.body.clientRfc, + clientAddress: req.body.clientAddress, + subcontractorId: req.body.subcontractorId, + specialty: req.body.specialty, + startDate: new Date(req.body.startDate), + endDate: new Date(req.body.endDate), + contractAmount: req.body.contractAmount, + currency: req.body.currency, + paymentTerms: req.body.paymentTerms, + retentionPercentage: req.body.retentionPercentage, + advancePercentage: req.body.advancePercentage, + notes: req.body.notes, + }); + + res.status(201).json({ success: true, data: contract }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/contracts/:id/submit + * Submit contract for review + */ + router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const contract = await service.submitForReview(getContext(req), req.params.id); + if (!contract) { + res.status(404).json({ error: 'Not Found', message: 'Contract not found' }); + return; + } + + res.status(200).json({ success: true, data: contract }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/contracts/:id/approve-legal + * Legal approval + */ + router.post('/:id/approve-legal', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const contract = await service.approveLegal(getContext(req), req.params.id); + if (!contract) { + res.status(404).json({ error: 'Not Found', message: 'Contract not found' }); + return; + } + + res.status(200).json({ success: true, data: contract }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/contracts/:id/approve + * Final approval + */ + router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const contract = await service.approve(getContext(req), req.params.id); + if (!contract) { + res.status(404).json({ error: 'Not Found', message: 'Contract not found' }); + return; + } + + res.status(200).json({ success: true, data: contract }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/contracts/:id/activate + * Activate signed contract + */ + router.post('/:id/activate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const contract = await service.activate(getContext(req), req.params.id, req.body.signedDocumentUrl); + if (!contract) { + res.status(404).json({ error: 'Not Found', message: 'Contract not found' }); + return; + } + + res.status(200).json({ success: true, data: contract }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/contracts/:id/complete + * Mark contract as completed + */ + router.post('/:id/complete', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const contract = await service.complete(getContext(req), req.params.id); + if (!contract) { + res.status(404).json({ error: 'Not Found', message: 'Contract not found' }); + return; + } + + res.status(200).json({ success: true, data: contract }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/contracts/:id/terminate + * Terminate contract + */ + router.post('/:id/terminate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const contract = await service.terminate(getContext(req), req.params.id, req.body.reason); + if (!contract) { + res.status(404).json({ error: 'Not Found', message: 'Contract not found' }); + return; + } + + res.status(200).json({ success: true, data: contract }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /api/contracts/:id/progress + * Update contract progress + */ + router.put('/:id/progress', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const contract = await service.updateProgress( + getContext(req), + req.params.id, + req.body.progressPercentage, + req.body.invoicedAmount, + req.body.paidAmount + ); + + if (!contract) { + res.status(404).json({ error: 'Not Found', message: 'Contract not found' }); + return; + } + + res.status(200).json({ success: true, data: contract }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/contracts/:id/addendums + * Create contract addendum + */ + router.post('/:id/addendums', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const addendum = await service.createAddendum(getContext(req), req.params.id, { + addendumType: req.body.addendumType, + title: req.body.title, + description: req.body.description, + effectiveDate: new Date(req.body.effectiveDate), + newEndDate: req.body.newEndDate ? new Date(req.body.newEndDate) : undefined, + amountChange: req.body.amountChange, + scopeChanges: req.body.scopeChanges, + notes: req.body.notes, + }); + + res.status(201).json({ success: true, data: addendum }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/contracts/addendums/:addendumId/approve + * Approve addendum + */ + router.post('/addendums/:addendumId/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'legal', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const addendum = await service.approveAddendum(getContext(req), req.params.addendumId); + if (!addendum) { + res.status(404).json({ error: 'Not Found', message: 'Addendum not found' }); + return; + } + + res.status(200).json({ success: true, data: addendum }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /api/contracts/:id + * Soft delete contract + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await service.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Contract not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/src/modules/contracts/controllers/index.ts b/src/modules/contracts/controllers/index.ts new file mode 100644 index 0000000..b9de7e8 --- /dev/null +++ b/src/modules/contracts/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * Contracts Controllers Index + * @module Contracts + */ + +export * from './contract.controller'; +export * from './subcontractor.controller'; diff --git a/src/modules/contracts/controllers/subcontractor.controller.ts b/src/modules/contracts/controllers/subcontractor.controller.ts new file mode 100644 index 0000000..a29365f --- /dev/null +++ b/src/modules/contracts/controllers/subcontractor.controller.ts @@ -0,0 +1,257 @@ +/** + * SubcontractorController - REST API for subcontractors + * + * Endpoints para gestión de subcontratistas. + * + * @module Contracts + * @routes /api/subcontractors + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { SubcontractorService, SubcontractorFilters } from '../services/subcontractor.service'; +import { Subcontractor } from '../entities/subcontractor.entity'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createSubcontractorController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const subcontractorRepo = dataSource.getRepository(Subcontractor); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const service = new SubcontractorService(subcontractorRepo); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper for service context + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /api/subcontractors + * List subcontractors with filters + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: SubcontractorFilters = {}; + if (req.query.specialty) filters.specialty = req.query.specialty as SubcontractorFilters['specialty']; + if (req.query.status) filters.status = req.query.status as SubcontractorFilters['status']; + if (req.query.search) filters.search = req.query.search as string; + if (req.query.minRating) filters.minRating = parseFloat(req.query.minRating as string); + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await service.findWithFilters(getContext(req), filters, page, limit); + res.status(200).json({ success: true, data: result.data, pagination: result.meta }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/subcontractors/specialty/:specialty + * Get subcontractors by specialty + */ + router.get('/specialty/:specialty', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const subcontractors = await service.getBySpecialty(getContext(req), req.params.specialty as any); + res.status(200).json({ success: true, data: subcontractors }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/subcontractors/:id + * Get subcontractor by ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const subcontractor = await service.findById(getContext(req), req.params.id); + if (!subcontractor) { + res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' }); + return; + } + + res.status(200).json({ success: true, data: subcontractor }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/subcontractors + * Create new subcontractor + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const subcontractor = await service.create(getContext(req), req.body); + res.status(201).json({ success: true, data: subcontractor }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /api/subcontractors/:id + * Update subcontractor + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const subcontractor = await service.update(getContext(req), req.params.id, req.body); + if (!subcontractor) { + res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' }); + return; + } + + res.status(200).json({ success: true, data: subcontractor }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/subcontractors/:id/rate + * Rate subcontractor + */ + router.post('/:id/rate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const subcontractor = await service.updateRating(getContext(req), req.params.id, req.body.rating); + if (!subcontractor) { + res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' }); + return; + } + + res.status(200).json({ success: true, data: subcontractor }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/subcontractors/:id/deactivate + * Deactivate subcontractor + */ + router.post('/:id/deactivate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'contracts'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const subcontractor = await service.deactivate(getContext(req), req.params.id); + if (!subcontractor) { + res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' }); + return; + } + + res.status(200).json({ success: true, data: subcontractor }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/subcontractors/:id/blacklist + * Blacklist subcontractor + */ + router.post('/:id/blacklist', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const subcontractor = await service.blacklist(getContext(req), req.params.id, req.body.reason); + if (!subcontractor) { + res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' }); + return; + } + + res.status(200).json({ success: true, data: subcontractor }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /api/subcontractors/:id + * Soft delete subcontractor + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await service.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Subcontractor not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/src/modules/contracts/entities/contract-addendum.entity.ts b/src/modules/contracts/entities/contract-addendum.entity.ts new file mode 100644 index 0000000..fd3cdd9 --- /dev/null +++ b/src/modules/contracts/entities/contract-addendum.entity.ts @@ -0,0 +1,114 @@ +/** + * ContractAddendum Entity + * Addendas y modificaciones a contratos + * + * @module Contracts + * @table contracts.contract_addendums + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Contract } from './contract.entity'; + +export type AddendumType = 'extension' | 'amount_increase' | 'amount_decrease' | 'scope_change' | 'termination' | 'other'; +export type AddendumStatus = 'draft' | 'review' | 'approved' | 'rejected'; + +@Entity({ schema: 'contracts', name: 'contract_addendums' }) +@Index(['tenantId', 'addendumNumber'], { unique: true }) +export class ContractAddendum { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'contract_id', type: 'uuid' }) + contractId: string; + + @Column({ name: 'addendum_number', type: 'varchar', length: 50 }) + addendumNumber: string; + + @Column({ name: 'addendum_type', type: 'varchar', length: 30 }) + addendumType: AddendumType; + + @Column({ type: 'varchar', length: 255 }) + title: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ name: 'effective_date', type: 'date' }) + effectiveDate: Date; + + // Changes + @Column({ name: 'new_end_date', type: 'date', nullable: true }) + newEndDate: Date; + + @Column({ name: 'amount_change', type: 'decimal', precision: 16, scale: 2, default: 0 }) + amountChange: number; + + @Column({ name: 'new_contract_amount', type: 'decimal', precision: 16, scale: 2, nullable: true }) + newContractAmount: number; + + @Column({ name: 'scope_changes', type: 'text', nullable: true }) + scopeChanges: string; + + // Status + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: AddendumStatus; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason: string; + + // Document + @Column({ name: 'document_url', type: 'varchar', length: 500, nullable: true }) + documentUrl: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Contract, (c) => c.addendums, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract: Contract; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User; +} diff --git a/src/modules/contracts/entities/contract.entity.ts b/src/modules/contracts/entities/contract.entity.ts new file mode 100644 index 0000000..b416f03 --- /dev/null +++ b/src/modules/contracts/entities/contract.entity.ts @@ -0,0 +1,192 @@ +/** + * Contract Entity + * Contratos con clientes y subcontratistas + * + * @module Contracts + * @table contracts.contracts + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { ContractAddendum } from './contract-addendum.entity'; + +export type ContractType = 'client' | 'subcontractor'; +export type ContractStatus = 'draft' | 'review' | 'approved' | 'active' | 'completed' | 'terminated'; +export type ClientContractType = 'desarrollo' | 'llave_en_mano' | 'administracion'; + +@Entity({ schema: 'contracts', name: 'contracts' }) +@Index(['tenantId', 'contractNumber'], { unique: true }) +export class Contract { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'project_id', type: 'uuid', nullable: true }) + projectId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true }) + fraccionamientoId: string; + + @Column({ name: 'contract_number', type: 'varchar', length: 50 }) + contractNumber: string; + + @Column({ name: 'contract_type', type: 'varchar', length: 20 }) + contractType: ContractType; + + @Column({ name: 'client_contract_type', type: 'varchar', length: 30, nullable: true }) + clientContractType: ClientContractType; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Client info (for client contracts) + @Column({ name: 'client_name', type: 'varchar', length: 255, nullable: true }) + clientName: string; + + @Column({ name: 'client_rfc', type: 'varchar', length: 13, nullable: true }) + clientRfc: string; + + @Column({ name: 'client_address', type: 'text', nullable: true }) + clientAddress: string; + + // Subcontractor info (for subcontractor contracts) + @Column({ name: 'subcontractor_id', type: 'uuid', nullable: true }) + subcontractorId: string; + + @Column({ name: 'specialty', type: 'varchar', length: 50, nullable: true }) + specialty: string; + + // Contract terms + @Column({ name: 'start_date', type: 'date' }) + startDate: Date; + + @Column({ name: 'end_date', type: 'date' }) + endDate: Date; + + @Column({ name: 'contract_amount', type: 'decimal', precision: 16, scale: 2 }) + contractAmount: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'payment_terms', type: 'text', nullable: true }) + paymentTerms: string; + + @Column({ name: 'retention_percentage', type: 'decimal', precision: 5, scale: 2, default: 5 }) + retentionPercentage: number; + + @Column({ name: 'advance_percentage', type: 'decimal', precision: 5, scale: 2, default: 0 }) + advancePercentage: number; + + // Status and workflow + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: ContractStatus; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt: Date; + + @Column({ name: 'submitted_by', type: 'uuid', nullable: true }) + submittedById: string; + + @Column({ name: 'legal_approved_at', type: 'timestamptz', nullable: true }) + legalApprovedAt: Date; + + @Column({ name: 'legal_approved_by', type: 'uuid', nullable: true }) + legalApprovedById: string; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string; + + @Column({ name: 'signed_at', type: 'timestamptz', nullable: true }) + signedAt: Date; + + @Column({ name: 'terminated_at', type: 'timestamptz', nullable: true }) + terminatedAt: Date; + + @Column({ name: 'termination_reason', type: 'text', nullable: true }) + terminationReason: string; + + // Documents + @Column({ name: 'document_url', type: 'varchar', length: 500, nullable: true }) + documentUrl: string; + + @Column({ name: 'signed_document_url', type: 'varchar', length: 500, nullable: true }) + signedDocumentUrl: string; + + // Progress tracking + @Column({ name: 'progress_percentage', type: 'decimal', precision: 5, scale: 2, default: 0 }) + progressPercentage: number; + + @Column({ name: 'invoiced_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + invoicedAmount: number; + + @Column({ name: 'paid_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + paidAmount: number; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Computed properties + get remainingAmount(): number { + return Number(this.contractAmount) - Number(this.invoicedAmount); + } + + get isExpiring(): boolean { + const thirtyDaysFromNow = new Date(); + thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); + return this.endDate <= thirtyDaysFromNow && this.status === 'active'; + } + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User; + + @OneToMany(() => ContractAddendum, (a) => a.contract) + addendums: ContractAddendum[]; +} diff --git a/src/modules/contracts/entities/index.ts b/src/modules/contracts/entities/index.ts new file mode 100644 index 0000000..289d628 --- /dev/null +++ b/src/modules/contracts/entities/index.ts @@ -0,0 +1,10 @@ +/** + * Contracts Entities Index + * @module Contracts + * + * Gestión de contratos y subcontratos (MAI-012) + */ + +export * from './contract.entity'; +export * from './subcontractor.entity'; +export * from './contract-addendum.entity'; diff --git a/src/modules/contracts/entities/subcontractor.entity.ts b/src/modules/contracts/entities/subcontractor.entity.ts new file mode 100644 index 0000000..89dabf3 --- /dev/null +++ b/src/modules/contracts/entities/subcontractor.entity.ts @@ -0,0 +1,123 @@ +/** + * Subcontractor Entity + * Catálogo de subcontratistas + * + * @module Contracts + * @table contracts.subcontractors + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +export type SubcontractorSpecialty = 'cimentacion' | 'estructura' | 'instalaciones_electricas' | 'instalaciones_hidraulicas' | 'acabados' | 'urbanizacion' | 'carpinteria' | 'herreria' | 'otros'; +export type SubcontractorStatus = 'active' | 'inactive' | 'blacklisted'; + +@Entity({ schema: 'contracts', name: 'subcontractors' }) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId', 'rfc'], { unique: true }) +export class Subcontractor { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ name: 'business_name', type: 'varchar', length: 255 }) + businessName: string; + + @Column({ name: 'trade_name', type: 'varchar', length: 255, nullable: true }) + tradeName: string; + + @Column({ type: 'varchar', length: 13 }) + rfc: string; + + @Column({ type: 'text', nullable: true }) + address: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ name: 'contact_name', type: 'varchar', length: 200, nullable: true }) + contactName: string; + + @Column({ name: 'contact_phone', type: 'varchar', length: 20, nullable: true }) + contactPhone: string; + + @Column({ name: 'primary_specialty', type: 'varchar', length: 50 }) + primarySpecialty: SubcontractorSpecialty; + + @Column({ name: 'secondary_specialties', type: 'simple-array', nullable: true }) + secondarySpecialties: string[]; + + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: SubcontractorStatus; + + // Performance tracking + @Column({ name: 'total_contracts', type: 'integer', default: 0 }) + totalContracts: number; + + @Column({ name: 'completed_contracts', type: 'integer', default: 0 }) + completedContracts: number; + + @Column({ name: 'average_rating', type: 'decimal', precision: 3, scale: 2, default: 0 }) + averageRating: number; + + @Column({ name: 'total_incidents', type: 'integer', default: 0 }) + totalIncidents: number; + + // Financial info + @Column({ name: 'bank_name', type: 'varchar', length: 100, nullable: true }) + bankName: string; + + @Column({ name: 'bank_account', type: 'varchar', length: 30, nullable: true }) + bankAccount: string; + + @Column({ type: 'varchar', length: 18, nullable: true }) + clabe: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/contracts/services/contract.service.ts b/src/modules/contracts/services/contract.service.ts new file mode 100644 index 0000000..1a197aa --- /dev/null +++ b/src/modules/contracts/services/contract.service.ts @@ -0,0 +1,422 @@ +/** + * ContractService - Servicio de gestión de contratos + * + * Gestión de contratos con workflow de aprobación. + * + * @module Contracts + */ + +import { Repository, FindOptionsWhere, LessThan } from 'typeorm'; +import { Contract, ContractStatus, ContractType } from '../entities/contract.entity'; +import { ContractAddendum } from '../entities/contract-addendum.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateContractDto { + projectId?: string; + fraccionamientoId?: string; + contractType: ContractType; + clientContractType?: string; + name: string; + description?: string; + clientName?: string; + clientRfc?: string; + clientAddress?: string; + subcontractorId?: string; + specialty?: string; + startDate: Date; + endDate: Date; + contractAmount: number; + currency?: string; + paymentTerms?: string; + retentionPercentage?: number; + advancePercentage?: number; + notes?: string; +} + +export interface CreateAddendumDto { + addendumType: string; + title: string; + description: string; + effectiveDate: Date; + newEndDate?: Date; + amountChange?: number; + scopeChanges?: string; + notes?: string; +} + +export interface ContractFilters { + projectId?: string; + fraccionamientoId?: string; + contractType?: ContractType; + subcontractorId?: string; + status?: ContractStatus; + expiringInDays?: number; +} + +export class ContractService { + constructor( + private readonly contractRepository: Repository, + private readonly addendumRepository: Repository + ) {} + + private generateContractNumber(type: ContractType): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + const prefix = type === 'client' ? 'CTR' : 'SUB'; + return `${prefix}-${year}${month}-${random}`; + } + + private generateAddendumNumber(contractNumber: string, sequence: number): string { + return `${contractNumber}-ADD${sequence.toString().padStart(2, '0')}`; + } + + async findWithFilters( + ctx: ServiceContext, + filters: ContractFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.contractRepository + .createQueryBuilder('c') + .leftJoinAndSelect('c.createdBy', 'createdBy') + .leftJoinAndSelect('c.approvedBy', 'approvedBy') + .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('c.deleted_at IS NULL'); + + if (filters.projectId) { + queryBuilder.andWhere('c.project_id = :projectId', { projectId: filters.projectId }); + } + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.contractType) { + queryBuilder.andWhere('c.contract_type = :contractType', { contractType: filters.contractType }); + } + + if (filters.subcontractorId) { + queryBuilder.andWhere('c.subcontractor_id = :subcontractorId', { + subcontractorId: filters.subcontractorId, + }); + } + + if (filters.status) { + queryBuilder.andWhere('c.status = :status', { status: filters.status }); + } + + if (filters.expiringInDays) { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + filters.expiringInDays); + queryBuilder.andWhere('c.end_date <= :futureDate', { futureDate }); + queryBuilder.andWhere('c.end_date >= :today', { today: new Date() }); + queryBuilder.andWhere('c.status = :activeStatus', { activeStatus: 'active' }); + } + + queryBuilder + .orderBy('c.created_at', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.contractRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + }); + } + + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.contractRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + relations: ['createdBy', 'approvedBy', 'addendums'], + }); + } + + async create(ctx: ServiceContext, dto: CreateContractDto): Promise { + const contract = this.contractRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + contractNumber: this.generateContractNumber(dto.contractType), + projectId: dto.projectId, + fraccionamientoId: dto.fraccionamientoId, + contractType: dto.contractType, + clientContractType: dto.clientContractType as any, + name: dto.name, + description: dto.description, + clientName: dto.clientName, + clientRfc: dto.clientRfc?.toUpperCase(), + clientAddress: dto.clientAddress, + subcontractorId: dto.subcontractorId, + specialty: dto.specialty, + startDate: dto.startDate, + endDate: dto.endDate, + contractAmount: dto.contractAmount, + currency: dto.currency || 'MXN', + paymentTerms: dto.paymentTerms, + retentionPercentage: dto.retentionPercentage || 5, + advancePercentage: dto.advancePercentage || 0, + notes: dto.notes, + status: 'draft', + }); + + return this.contractRepository.save(contract); + } + + async submitForReview(ctx: ServiceContext, id: string): Promise { + const contract = await this.findById(ctx, id); + if (!contract) { + return null; + } + + if (contract.status !== 'draft') { + throw new Error('Can only submit draft contracts for review'); + } + + contract.status = 'review'; + contract.submittedAt = new Date(); + contract.submittedById = ctx.userId || ''; + contract.updatedById = ctx.userId || ''; + + return this.contractRepository.save(contract); + } + + async approveLegal(ctx: ServiceContext, id: string): Promise { + const contract = await this.findById(ctx, id); + if (!contract) { + return null; + } + + if (contract.status !== 'review') { + throw new Error('Can only approve contracts in review'); + } + + contract.legalApprovedAt = new Date(); + contract.legalApprovedById = ctx.userId || ''; + contract.updatedById = ctx.userId || ''; + + return this.contractRepository.save(contract); + } + + async approve(ctx: ServiceContext, id: string): Promise { + const contract = await this.findById(ctx, id); + if (!contract) { + return null; + } + + if (contract.status !== 'review') { + throw new Error('Can only approve contracts in review'); + } + + contract.status = 'approved'; + contract.approvedAt = new Date(); + contract.approvedById = ctx.userId || ''; + contract.updatedById = ctx.userId || ''; + + return this.contractRepository.save(contract); + } + + async activate(ctx: ServiceContext, id: string, signedDocumentUrl?: string): Promise { + const contract = await this.findById(ctx, id); + if (!contract) { + return null; + } + + if (contract.status !== 'approved') { + throw new Error('Can only activate approved contracts'); + } + + contract.status = 'active'; + contract.signedAt = new Date(); + if (signedDocumentUrl) { + contract.signedDocumentUrl = signedDocumentUrl; + } + contract.updatedById = ctx.userId || ''; + + return this.contractRepository.save(contract); + } + + async complete(ctx: ServiceContext, id: string): Promise { + const contract = await this.findById(ctx, id); + if (!contract) { + return null; + } + + if (contract.status !== 'active') { + throw new Error('Can only complete active contracts'); + } + + contract.status = 'completed'; + contract.updatedById = ctx.userId || ''; + + return this.contractRepository.save(contract); + } + + async terminate(ctx: ServiceContext, id: string, reason: string): Promise { + const contract = await this.findById(ctx, id); + if (!contract) { + return null; + } + + if (contract.status !== 'active') { + throw new Error('Can only terminate active contracts'); + } + + contract.status = 'terminated'; + contract.terminatedAt = new Date(); + contract.terminationReason = reason; + contract.updatedById = ctx.userId || ''; + + return this.contractRepository.save(contract); + } + + async updateProgress( + ctx: ServiceContext, + id: string, + progressPercentage: number, + invoicedAmount?: number, + paidAmount?: number + ): Promise { + const contract = await this.findById(ctx, id); + if (!contract) { + return null; + } + + contract.progressPercentage = progressPercentage; + if (invoicedAmount !== undefined) { + contract.invoicedAmount = invoicedAmount; + } + if (paidAmount !== undefined) { + contract.paidAmount = paidAmount; + } + contract.updatedById = ctx.userId || ''; + + return this.contractRepository.save(contract); + } + + async createAddendum(ctx: ServiceContext, contractId: string, dto: CreateAddendumDto): Promise { + const contract = await this.findWithDetails(ctx, contractId); + if (!contract) { + throw new Error('Contract not found'); + } + + if (contract.status !== 'active') { + throw new Error('Can only add addendums to active contracts'); + } + + const sequence = (contract.addendums?.length || 0) + 1; + + const addendum = this.addendumRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + contractId, + addendumNumber: this.generateAddendumNumber(contract.contractNumber, sequence), + addendumType: dto.addendumType as any, + title: dto.title, + description: dto.description, + effectiveDate: dto.effectiveDate, + newEndDate: dto.newEndDate, + amountChange: dto.amountChange || 0, + newContractAmount: dto.amountChange + ? Number(contract.contractAmount) + Number(dto.amountChange) + : undefined, + scopeChanges: dto.scopeChanges, + notes: dto.notes, + status: 'draft', + }); + + return this.addendumRepository.save(addendum); + } + + async approveAddendum(ctx: ServiceContext, addendumId: string): Promise { + const addendum = await this.addendumRepository.findOne({ + where: { + id: addendumId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + relations: ['contract'], + }); + + if (!addendum) { + return null; + } + + if (addendum.status !== 'draft' && addendum.status !== 'review') { + throw new Error('Can only approve draft or review addendums'); + } + + addendum.status = 'approved'; + addendum.approvedAt = new Date(); + addendum.approvedById = ctx.userId || ''; + addendum.updatedById = ctx.userId || ''; + + // Apply changes to contract + if (addendum.newEndDate) { + addendum.contract.endDate = addendum.newEndDate; + } + if (addendum.newContractAmount) { + addendum.contract.contractAmount = addendum.newContractAmount; + } + await this.contractRepository.save(addendum.contract); + + return this.addendumRepository.save(addendum); + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const contract = await this.findById(ctx, id); + if (!contract) { + return false; + } + + if (contract.status === 'active') { + throw new Error('Cannot delete active contracts'); + } + + await this.contractRepository.update( + { id, tenantId: ctx.tenantId } as FindOptionsWhere, + { deletedAt: new Date(), deletedById: ctx.userId || '' } + ); + + return true; + } + + async getExpiringContracts(ctx: ServiceContext, days: number = 30): Promise { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + days); + + return this.contractRepository.find({ + where: { + tenantId: ctx.tenantId, + status: 'active' as ContractStatus, + endDate: LessThan(futureDate), + deletedAt: null, + } as unknown as FindOptionsWhere, + order: { endDate: 'ASC' }, + }); + } +} diff --git a/src/modules/contracts/services/index.ts b/src/modules/contracts/services/index.ts new file mode 100644 index 0000000..1905ca0 --- /dev/null +++ b/src/modules/contracts/services/index.ts @@ -0,0 +1,7 @@ +/** + * Contracts Services Index + * @module Contracts + */ + +export * from './contract.service'; +export * from './subcontractor.service'; diff --git a/src/modules/contracts/services/subcontractor.service.ts b/src/modules/contracts/services/subcontractor.service.ts new file mode 100644 index 0000000..eea8d86 --- /dev/null +++ b/src/modules/contracts/services/subcontractor.service.ts @@ -0,0 +1,270 @@ +/** + * SubcontractorService - Servicio de gestión de subcontratistas + * + * Catálogo de subcontratistas con evaluaciones. + * + * @module Contracts + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Subcontractor, SubcontractorStatus, SubcontractorSpecialty } from '../entities/subcontractor.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateSubcontractorDto { + businessName: string; + tradeName?: string; + rfc: string; + address?: string; + phone?: string; + email?: string; + contactName?: string; + contactPhone?: string; + primarySpecialty: SubcontractorSpecialty; + secondarySpecialties?: string[]; + bankName?: string; + bankAccount?: string; + clabe?: string; + notes?: string; +} + +export interface UpdateSubcontractorDto { + tradeName?: string; + address?: string; + phone?: string; + email?: string; + contactName?: string; + contactPhone?: string; + secondarySpecialties?: string[]; + bankName?: string; + bankAccount?: string; + clabe?: string; + notes?: string; +} + +export interface SubcontractorFilters { + specialty?: SubcontractorSpecialty; + status?: SubcontractorStatus; + search?: string; + minRating?: number; +} + +export class SubcontractorService { + constructor(private readonly subcontractorRepository: Repository) {} + + private generateCode(): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `SC-${year}-${random}`; + } + + async findWithFilters( + ctx: ServiceContext, + filters: SubcontractorFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.subcontractorRepository + .createQueryBuilder('sc') + .leftJoinAndSelect('sc.createdBy', 'createdBy') + .where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('sc.deleted_at IS NULL'); + + if (filters.specialty) { + queryBuilder.andWhere('sc.primary_specialty = :specialty', { specialty: filters.specialty }); + } + + if (filters.status) { + queryBuilder.andWhere('sc.status = :status', { status: filters.status }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(sc.business_name ILIKE :search OR sc.trade_name ILIKE :search OR sc.rfc ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + if (filters.minRating !== undefined) { + queryBuilder.andWhere('sc.average_rating >= :minRating', { minRating: filters.minRating }); + } + + queryBuilder + .orderBy('sc.business_name', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.subcontractorRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + }); + } + + async findByRfc(ctx: ServiceContext, rfc: string): Promise { + return this.subcontractorRepository.findOne({ + where: { + rfc: rfc.toUpperCase(), + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + }); + } + + async create(ctx: ServiceContext, dto: CreateSubcontractorDto): Promise { + // Check for existing RFC + const existing = await this.findByRfc(ctx, dto.rfc); + if (existing) { + throw new Error('A subcontractor with this RFC already exists'); + } + + const subcontractor = this.subcontractorRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + code: this.generateCode(), + businessName: dto.businessName, + tradeName: dto.tradeName, + rfc: dto.rfc.toUpperCase(), + address: dto.address, + phone: dto.phone, + email: dto.email, + contactName: dto.contactName, + contactPhone: dto.contactPhone, + primarySpecialty: dto.primarySpecialty, + secondarySpecialties: dto.secondarySpecialties, + bankName: dto.bankName, + bankAccount: dto.bankAccount, + clabe: dto.clabe, + notes: dto.notes, + status: 'active', + }); + + return this.subcontractorRepository.save(subcontractor); + } + + async update(ctx: ServiceContext, id: string, dto: UpdateSubcontractorDto): Promise { + const subcontractor = await this.findById(ctx, id); + if (!subcontractor) { + return null; + } + + Object.assign(subcontractor, { + ...dto, + updatedById: ctx.userId || '', + }); + + return this.subcontractorRepository.save(subcontractor); + } + + async updateRating(ctx: ServiceContext, id: string, rating: number): Promise { + const subcontractor = await this.findById(ctx, id); + if (!subcontractor) { + return null; + } + + // Calculate new average rating + const totalRatings = subcontractor.completedContracts; + const currentTotal = subcontractor.averageRating * totalRatings; + const newTotal = currentTotal + rating; + subcontractor.averageRating = newTotal / (totalRatings + 1); + subcontractor.updatedById = ctx.userId || ''; + + return this.subcontractorRepository.save(subcontractor); + } + + async incrementContracts(ctx: ServiceContext, id: string, completed: boolean = false): Promise { + const subcontractor = await this.findById(ctx, id); + if (!subcontractor) { + return null; + } + + subcontractor.totalContracts += 1; + if (completed) { + subcontractor.completedContracts += 1; + } + subcontractor.updatedById = ctx.userId || ''; + + return this.subcontractorRepository.save(subcontractor); + } + + async incrementIncidents(ctx: ServiceContext, id: string): Promise { + const subcontractor = await this.findById(ctx, id); + if (!subcontractor) { + return null; + } + + subcontractor.totalIncidents += 1; + subcontractor.updatedById = ctx.userId || ''; + + return this.subcontractorRepository.save(subcontractor); + } + + async deactivate(ctx: ServiceContext, id: string): Promise { + const subcontractor = await this.findById(ctx, id); + if (!subcontractor) { + return null; + } + + subcontractor.status = 'inactive'; + subcontractor.updatedById = ctx.userId || ''; + + return this.subcontractorRepository.save(subcontractor); + } + + async blacklist(ctx: ServiceContext, id: string, reason: string): Promise { + const subcontractor = await this.findById(ctx, id); + if (!subcontractor) { + return null; + } + + subcontractor.status = 'blacklisted'; + subcontractor.notes = `${subcontractor.notes || ''}\n[BLACKLISTED] ${reason}`; + subcontractor.updatedById = ctx.userId || ''; + + return this.subcontractorRepository.save(subcontractor); + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const subcontractor = await this.findById(ctx, id); + if (!subcontractor) { + return false; + } + + await this.subcontractorRepository.update( + { id, tenantId: ctx.tenantId } as FindOptionsWhere, + { deletedAt: new Date(), deletedById: ctx.userId || '' } + ); + + return true; + } + + async getBySpecialty(ctx: ServiceContext, specialty: SubcontractorSpecialty): Promise { + return this.subcontractorRepository.find({ + where: { + tenantId: ctx.tenantId, + primarySpecialty: specialty, + status: 'active' as SubcontractorStatus, + deletedAt: null, + } as unknown as FindOptionsWhere, + order: { averageRating: 'DESC' }, + }); + } +} diff --git a/src/modules/core/entities/index.ts b/src/modules/core/entities/index.ts new file mode 100644 index 0000000..e828c0e --- /dev/null +++ b/src/modules/core/entities/index.ts @@ -0,0 +1,6 @@ +/** + * Core Entities Index + */ + +export { Tenant } from './tenant.entity'; +export { User } from './user.entity'; diff --git a/src/modules/core/entities/tenant.entity.ts b/src/modules/core/entities/tenant.entity.ts new file mode 100644 index 0000000..ccb8d0e --- /dev/null +++ b/src/modules/core/entities/tenant.entity.ts @@ -0,0 +1,50 @@ +/** + * Tenant Entity + * Entidad para multi-tenancy + * + * @module Core + * @table core.tenants + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity({ schema: 'auth', name: 'tenants' }) +export class Tenant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + @Index() + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'jsonb', default: {} }) + settings: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relations + @OneToMany(() => User, (user) => user.tenant) + users: User[]; +} diff --git a/src/modules/core/entities/user.entity.ts b/src/modules/core/entities/user.entity.ts new file mode 100644 index 0000000..9ebe843 --- /dev/null +++ b/src/modules/core/entities/user.entity.ts @@ -0,0 +1,78 @@ +/** + * User Entity + * Entidad de usuarios del sistema + * + * @module Core + * @table core.users + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from './tenant.entity'; + +@Entity({ schema: 'auth', name: 'users' }) +@Index(['tenantId', 'email'], { unique: true }) +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Column({ type: 'varchar', length: 255 }) + email: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + username: string; + + @Column({ name: 'password_hash', type: 'varchar', length: 255, select: false }) + passwordHash: string; + + @Column({ name: 'first_name', type: 'varchar', length: 100, nullable: true }) + firstName: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100, nullable: true }) + lastName: string; + + @Column({ type: 'varchar', array: true, default: ['viewer'] }) + roles: string[]; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'last_login_at', type: 'timestamptz', nullable: true }) + lastLoginAt: Date; + + @Column({ name: 'default_tenant_id', type: 'uuid', nullable: true }) + defaultTenantId: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Placeholder para relación de roles (se implementará en ST-004) + userRoles?: { role: { code: string } }[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant, (tenant) => tenant.users) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + // Computed property + get fullName(): string { + return [this.firstName, this.lastName].filter(Boolean).join(' ') || this.email; + } +} diff --git a/src/modules/estimates/controllers/anticipo.controller.ts b/src/modules/estimates/controllers/anticipo.controller.ts new file mode 100644 index 0000000..b599d83 --- /dev/null +++ b/src/modules/estimates/controllers/anticipo.controller.ts @@ -0,0 +1,324 @@ +/** + * AnticipoController - Controller de anticipos de obra + * + * Endpoints REST para gestión de anticipos de contratos de construcción. + * + * @module Estimates + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AnticipoService, CreateAnticipoDto, AnticipoFilters } from '../services/anticipo.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Anticipo } from '../entities/anticipo.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createAnticipoController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const anticipoRepository = dataSource.getRepository(Anticipo); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const anticipoService = new AnticipoService(anticipoRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /anticipos + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: AnticipoFilters = {}; + if (req.query.contratoId) filters.contratoId = req.query.contratoId as string; + if (req.query.advanceType) filters.advanceType = req.query.advanceType as any; + if (req.query.isFullyAmortized !== undefined) { + filters.isFullyAmortized = req.query.isFullyAmortized === 'true'; + } + if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string); + if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string); + + const result = await anticipoService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /anticipos/stats + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: AnticipoFilters = {}; + if (req.query.contratoId) filters.contratoId = req.query.contratoId as string; + if (req.query.advanceType) filters.advanceType = req.query.advanceType as any; + + const stats = await anticipoService.getStats(getContext(req), filters); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /anticipos/contrato/:contratoId + */ + router.get('/contrato/:contratoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await anticipoService.findByContrato( + getContext(req), + req.params.contratoId, + page, + limit + ); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /anticipos/:id + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const anticipo = await anticipoService.findById(getContext(req), req.params.id); + if (!anticipo) { + res.status(404).json({ error: 'Not Found', message: 'Anticipo not found' }); + return; + } + + res.status(200).json({ success: true, data: anticipo }); + } catch (error) { + next(error); + } + }); + + /** + * POST /anticipos + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateAnticipoDto = req.body; + + if (!dto.contratoId || !dto.advanceType || !dto.advanceNumber) { + res.status(400).json({ + error: 'Bad Request', + message: 'contratoId, advanceType, and advanceNumber are required', + }); + return; + } + + const anticipo = await anticipoService.createAnticipo(getContext(req), dto); + res.status(201).json({ success: true, data: anticipo }); + } catch (error) { + if (error instanceof Error && error.message.includes('no coincide')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /anticipos/:id/approve + */ + router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const approvedById = req.user?.sub; + if (!approvedById) { + res.status(400).json({ error: 'Bad Request', message: 'User ID required' }); + return; + } + + const anticipo = await anticipoService.approveAnticipo( + getContext(req), + req.params.id, + approvedById, + req.body.notes + ); + + res.status(200).json({ success: true, data: anticipo }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Anticipo no encontrado') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message.includes('aprobado')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * POST /anticipos/:id/pay + */ + router.post('/:id/pay', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'accountant'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { paymentReference, paidAt } = req.body; + if (!paymentReference) { + res.status(400).json({ error: 'Bad Request', message: 'paymentReference is required' }); + return; + } + + const anticipo = await anticipoService.markPaid( + getContext(req), + req.params.id, + paymentReference, + paidAt ? new Date(paidAt) : undefined + ); + + res.status(200).json({ success: true, data: anticipo }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Anticipo no encontrado') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message.includes('aprobado') || error.message.includes('pagado')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * POST /anticipos/:id/amortize + */ + router.post('/:id/amortize', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { amount } = req.body; + if (amount === undefined || amount <= 0) { + res.status(400).json({ error: 'Bad Request', message: 'Valid amount is required' }); + return; + } + + const anticipo = await anticipoService.updateAmortization( + getContext(req), + req.params.id, + amount + ); + + res.status(200).json({ success: true, data: anticipo }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Anticipo no encontrado') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message.includes('amortizado') || error.message.includes('excede')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * DELETE /anticipos/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await anticipoService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Anticipo not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Anticipo deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createAnticipoController; diff --git a/src/modules/estimates/controllers/estimacion.controller.ts b/src/modules/estimates/controllers/estimacion.controller.ts new file mode 100644 index 0000000..dd59cf7 --- /dev/null +++ b/src/modules/estimates/controllers/estimacion.controller.ts @@ -0,0 +1,408 @@ +/** + * EstimacionController - Controller de estimaciones de obra + * + * Endpoints REST para gestión de estimaciones periódicas. + * Incluye workflow de aprobación: draft -> submitted -> reviewed -> approved -> invoiced -> paid + * + * @module Estimates + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + EstimacionService, + CreateEstimacionDto, + AddConceptoDto, + AddGeneradorDto, + EstimacionFilters, +} from '../services/estimacion.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Estimacion } from '../entities/estimacion.entity'; +import { EstimacionConcepto } from '../entities/estimacion-concepto.entity'; +import { Generador } from '../entities/generador.entity'; +import { EstimacionWorkflow } from '../entities/estimacion-workflow.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de estimaciones + */ +export function createEstimacionController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const estimacionRepository = dataSource.getRepository(Estimacion); + const conceptoRepository = dataSource.getRepository(EstimacionConcepto); + const generadorRepository = dataSource.getRepository(Generador); + const workflowRepository = dataSource.getRepository(EstimacionWorkflow); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const estimacionService = new EstimacionService( + estimacionRepository, + conceptoRepository, + generadorRepository, + workflowRepository, + dataSource + ); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /estimaciones + * Listar estimaciones con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: EstimacionFilters = { + contratoId: req.query.contratoId as string, + fraccionamientoId: req.query.fraccionamientoId as string, + status: req.query.status as any, + periodFrom: req.query.periodFrom ? new Date(req.query.periodFrom as string) : undefined, + periodTo: req.query.periodTo ? new Date(req.query.periodTo as string) : undefined, + }; + + const result = await estimacionService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /estimaciones/summary/:contratoId + * Obtener resumen de estimaciones por contrato + */ + router.get('/summary/:contratoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const summary = await estimacionService.getContractSummary(getContext(req), req.params.contratoId); + res.status(200).json({ success: true, data: summary }); + } catch (error) { + next(error); + } + }); + + /** + * GET /estimaciones/:id + * Obtener estimación con detalles completos + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const estimacion = await estimacionService.findWithDetails(getContext(req), req.params.id); + if (!estimacion) { + res.status(404).json({ error: 'Not Found', message: 'Estimate not found' }); + return; + } + + res.status(200).json({ success: true, data: estimacion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /estimaciones + * Crear estimación + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateEstimacionDto = req.body; + + if (!dto.contratoId || !dto.fraccionamientoId || !dto.periodStart || !dto.periodEnd) { + res.status(400).json({ error: 'Bad Request', message: 'contratoId, fraccionamientoId, periodStart and periodEnd are required' }); + return; + } + + const estimacion = await estimacionService.createEstimacion(getContext(req), dto); + res.status(201).json({ success: true, data: estimacion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /estimaciones/:id/conceptos + * Agregar concepto a estimación + */ + router.post('/:id/conceptos', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: AddConceptoDto = req.body; + + if (!dto.conceptoId || dto.quantityCurrent === undefined || dto.unitPrice === undefined) { + res.status(400).json({ error: 'Bad Request', message: 'conceptoId, quantityCurrent and unitPrice are required' }); + return; + } + + const concepto = await estimacionService.addConcepto(getContext(req), req.params.id, dto); + res.status(201).json({ success: true, data: concepto }); + } catch (error) { + if (error instanceof Error && error.message.includes('non-draft')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /estimaciones/conceptos/:conceptoId/generadores + * Agregar generador a concepto de estimación + */ + router.post('/conceptos/:conceptoId/generadores', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: AddGeneradorDto = req.body; + + if (!dto.generatorNumber || dto.quantity === undefined) { + res.status(400).json({ error: 'Bad Request', message: 'generatorNumber and quantity are required' }); + return; + } + + const generador = await estimacionService.addGenerador(getContext(req), req.params.conceptoId, dto); + res.status(201).json({ success: true, data: generador }); + } catch (error) { + if (error instanceof Error && error.message === 'Concepto not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /estimaciones/:id/submit + * Enviar estimación para revisión + */ + router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const estimacion = await estimacionService.submit(getContext(req), req.params.id); + if (!estimacion) { + res.status(400).json({ error: 'Bad Request', message: 'Cannot submit this estimate' }); + return; + } + + res.status(200).json({ success: true, data: estimacion, message: 'Estimate submitted for review' }); + } catch (error) { + if (error instanceof Error && error.message.includes('Invalid status')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /estimaciones/:id/review + * Revisar estimación + */ + router.post('/:id/review', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const estimacion = await estimacionService.review(getContext(req), req.params.id); + if (!estimacion) { + res.status(400).json({ error: 'Bad Request', message: 'Cannot review this estimate' }); + return; + } + + res.status(200).json({ success: true, data: estimacion, message: 'Estimate reviewed' }); + } catch (error) { + if (error instanceof Error && error.message.includes('Invalid status')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /estimaciones/:id/approve + * Aprobar estimación + */ + router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const estimacion = await estimacionService.approve(getContext(req), req.params.id); + if (!estimacion) { + res.status(400).json({ error: 'Bad Request', message: 'Cannot approve this estimate' }); + return; + } + + res.status(200).json({ success: true, data: estimacion, message: 'Estimate approved' }); + } catch (error) { + if (error instanceof Error && error.message.includes('Invalid status')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /estimaciones/:id/reject + * Rechazar estimación + */ + router.post('/:id/reject', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { reason } = req.body; + if (!reason) { + res.status(400).json({ error: 'Bad Request', message: 'reason is required' }); + return; + } + + const estimacion = await estimacionService.reject(getContext(req), req.params.id, reason); + if (!estimacion) { + res.status(400).json({ error: 'Bad Request', message: 'Cannot reject this estimate' }); + return; + } + + res.status(200).json({ success: true, data: estimacion, message: 'Estimate rejected' }); + } catch (error) { + if (error instanceof Error && error.message.includes('Invalid status')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /estimaciones/:id/recalculate + * Recalcular totales de estimación + */ + router.post('/:id/recalculate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + await estimacionService.recalculateTotals(getContext(req), req.params.id); + const estimacion = await estimacionService.findWithDetails(getContext(req), req.params.id); + + res.status(200).json({ success: true, data: estimacion, message: 'Totals recalculated' }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /estimaciones/:id + * Eliminar estimación (solo draft) + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const estimacion = await estimacionService.findById(getContext(req), req.params.id); + if (!estimacion) { + res.status(404).json({ error: 'Not Found', message: 'Estimate not found' }); + return; + } + + if (estimacion.status !== 'draft') { + res.status(400).json({ error: 'Bad Request', message: 'Only draft estimates can be deleted' }); + return; + } + + const deleted = await estimacionService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Estimate not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Estimate deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createEstimacionController; diff --git a/src/modules/estimates/controllers/fondo-garantia.controller.ts b/src/modules/estimates/controllers/fondo-garantia.controller.ts new file mode 100644 index 0000000..b0f98f4 --- /dev/null +++ b/src/modules/estimates/controllers/fondo-garantia.controller.ts @@ -0,0 +1,299 @@ +/** + * FondoGarantiaController - Controller de fondos de garantía + * + * Endpoints REST para gestión de fondos de garantía de contratos. + * + * @module Estimates + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { FondoGarantiaService, CreateFondoGarantiaDto, ReleaseFondoDto } from '../services/fondo-garantia.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { FondoGarantia } from '../entities/fondo-garantia.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createFondoGarantiaController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const fondoGarantiaRepository = dataSource.getRepository(FondoGarantia); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const fondoGarantiaService = new FondoGarantiaService(fondoGarantiaRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /fondos-garantia + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await fondoGarantiaService.findAll(getContext(req), { page, limit }); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /fondos-garantia/stats + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await fondoGarantiaService.getStats(getContext(req)); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /fondos-garantia/contrato/:contratoId + */ + router.get('/contrato/:contratoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fondo = await fondoGarantiaService.findByContrato(getContext(req), req.params.contratoId); + if (!fondo) { + res.status(404).json({ error: 'Not Found', message: 'Guarantee fund not found' }); + return; + } + + res.status(200).json({ success: true, data: fondo }); + } catch (error) { + next(error); + } + }); + + /** + * GET /fondos-garantia/:id + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fondo = await fondoGarantiaService.findById(getContext(req), req.params.id); + if (!fondo) { + res.status(404).json({ error: 'Not Found', message: 'Guarantee fund not found' }); + return; + } + + res.status(200).json({ success: true, data: fondo }); + } catch (error) { + next(error); + } + }); + + /** + * POST /fondos-garantia + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateFondoGarantiaDto = req.body; + if (!dto.contratoId) { + res.status(400).json({ error: 'Bad Request', message: 'contratoId is required' }); + return; + } + + const fondo = await fondoGarantiaService.createOrUpdate(getContext(req), dto); + res.status(201).json({ success: true, data: fondo }); + } catch (error) { + next(error); + } + }); + + /** + * POST /fondos-garantia/:contratoId/accumulate + */ + router.post('/:contratoId/accumulate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { amount } = req.body; + if (amount === undefined || amount <= 0) { + res.status(400).json({ error: 'Bad Request', message: 'Valid amount is required' }); + return; + } + + const fondo = await fondoGarantiaService.addAccumulation( + getContext(req), + req.params.contratoId, + amount + ); + + res.status(200).json({ success: true, data: fondo }); + } catch (error) { + next(error); + } + }); + + /** + * POST /fondos-garantia/:contratoId/release + */ + router.post('/:contratoId/release', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const releasedById = req.user?.sub; + if (!releasedById) { + res.status(400).json({ error: 'Bad Request', message: 'User ID required' }); + return; + } + + const { amount, releaseDate, notes } = req.body; + if (amount === undefined || amount <= 0) { + res.status(400).json({ error: 'Bad Request', message: 'Valid amount is required' }); + return; + } + + const dto: ReleaseFondoDto = { + amount, + releasedById, + releaseDate: releaseDate ? new Date(releaseDate) : undefined, + notes, + }; + + const fondo = await fondoGarantiaService.releasePartial( + getContext(req), + req.params.contratoId, + dto + ); + + res.status(200).json({ success: true, data: fondo }); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('no encontrado')) { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message.includes('excede')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * POST /fondos-garantia/:contratoId/release-full + */ + router.post('/:contratoId/release-full', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const releasedById = req.user?.sub; + if (!releasedById) { + res.status(400).json({ error: 'Bad Request', message: 'User ID required' }); + return; + } + + const { releaseDate } = req.body; + + const fondo = await fondoGarantiaService.releaseFull( + getContext(req), + req.params.contratoId, + releasedById, + releaseDate ? new Date(releaseDate) : undefined + ); + + res.status(200).json({ success: true, data: fondo }); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('no encontrado')) { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message.includes('pendiente')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * DELETE /fondos-garantia/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await fondoGarantiaService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Guarantee fund not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Guarantee fund deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createFondoGarantiaController; diff --git a/src/modules/estimates/controllers/index.ts b/src/modules/estimates/controllers/index.ts new file mode 100644 index 0000000..888567b --- /dev/null +++ b/src/modules/estimates/controllers/index.ts @@ -0,0 +1,9 @@ +/** + * Estimates Controllers Index + * @module Estimates + */ + +export { createEstimacionController } from './estimacion.controller'; +export { createAnticipoController } from './anticipo.controller'; +export { createFondoGarantiaController } from './fondo-garantia.controller'; +export { createRetencionController } from './retencion.controller'; diff --git a/src/modules/estimates/controllers/retencion.controller.ts b/src/modules/estimates/controllers/retencion.controller.ts new file mode 100644 index 0000000..b82e076 --- /dev/null +++ b/src/modules/estimates/controllers/retencion.controller.ts @@ -0,0 +1,241 @@ +/** + * RetencionController - Controller de retenciones + * + * Endpoints REST para gestión de retenciones de estimaciones. + * + * @module Estimates + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { RetencionService, CreateRetencionDto, ReleaseRetencionDto, RetencionFilters } from '../services/retencion.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Retencion } from '../entities/retencion.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createRetencionController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const retencionRepository = dataSource.getRepository(Retencion); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const retencionService = new RetencionService(retencionRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /retenciones + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: RetencionFilters = {}; + if (req.query.estimacionId) filters.estimacionId = req.query.estimacionId as string; + if (req.query.contratoId) filters.contratoId = req.query.contratoId as string; + if (req.query.retentionType) filters.retentionType = req.query.retentionType as any; + if (req.query.isReleased !== undefined) { + filters.isReleased = req.query.isReleased === 'true'; + } + if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string); + if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string); + + const result = await retencionService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /retenciones/stats + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: RetencionFilters = {}; + if (req.query.contratoId) filters.contratoId = req.query.contratoId as string; + + const stats = await retencionService.getStats(getContext(req), filters); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /retenciones/contrato/:contratoId/totals + */ + router.get('/contrato/:contratoId/totals', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const totals = await retencionService.getTotalByContrato(getContext(req), req.params.contratoId); + res.status(200).json({ success: true, data: totals }); + } catch (error) { + next(error); + } + }); + + /** + * GET /retenciones/estimacion/:estimacionId + */ + router.get('/estimacion/:estimacionId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const retenciones = await retencionService.findByEstimacion(getContext(req), req.params.estimacionId); + res.status(200).json({ success: true, data: retenciones }); + } catch (error) { + next(error); + } + }); + + /** + * GET /retenciones/:id + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const retencion = await retencionService.findById(getContext(req), req.params.id); + if (!retencion) { + res.status(404).json({ error: 'Not Found', message: 'Retention not found' }); + return; + } + + res.status(200).json({ success: true, data: retencion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /retenciones + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateRetencionDto = req.body; + if (!dto.estimacionId || !dto.retentionType || !dto.description || dto.amount === undefined) { + res.status(400).json({ + error: 'Bad Request', + message: 'estimacionId, retentionType, description, and amount are required', + }); + return; + } + + const retencion = await retencionService.createRetencion(getContext(req), dto); + res.status(201).json({ success: true, data: retencion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /retenciones/:id/release + */ + router.post('/:id/release', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: ReleaseRetencionDto = req.body; + if (dto.releasedAmount === undefined || dto.releasedAmount <= 0) { + res.status(400).json({ error: 'Bad Request', message: 'Valid releasedAmount is required' }); + return; + } + + const retencion = await retencionService.releaseRetencion(getContext(req), req.params.id, dto); + if (!retencion) { + res.status(404).json({ error: 'Not Found', message: 'Retention not found' }); + return; + } + + res.status(200).json({ success: true, data: retencion }); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('already released') || error.message.includes('exceed')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * DELETE /retenciones/:id + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await retencionService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Retention not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Retention deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createRetencionController; diff --git a/src/modules/estimates/entities/amortizacion.entity.ts b/src/modules/estimates/entities/amortizacion.entity.ts new file mode 100644 index 0000000..d413c5f --- /dev/null +++ b/src/modules/estimates/entities/amortizacion.entity.ts @@ -0,0 +1,86 @@ +/** + * Amortizacion Entity + * Amortizaciones de anticipos por estimacion + * + * @module Estimates + * @table estimates.amortizaciones + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Anticipo } from './anticipo.entity'; +import { Estimacion } from './estimacion.entity'; + +@Entity({ schema: 'estimates', name: 'amortizaciones' }) +@Index(['anticipoId', 'estimacionId'], { unique: true }) +@Index(['tenantId']) +@Index(['anticipoId']) +@Index(['estimacionId']) +export class Amortizacion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'anticipo_id', type: 'uuid' }) + anticipoId: string; + + @Column({ name: 'estimacion_id', type: 'uuid' }) + estimacionId: string; + + @Column({ type: 'decimal', precision: 16, scale: 2 }) + amount: number; + + @Column({ name: 'amortization_date', type: 'date' }) + amortizationDate: Date; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Anticipo, (a) => a.amortizaciones) + @JoinColumn({ name: 'anticipo_id' }) + anticipo: Anticipo; + + @ManyToOne(() => Estimacion, (e) => e.amortizaciones) + @JoinColumn({ name: 'estimacion_id' }) + estimacion: Estimacion; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/src/modules/estimates/entities/anticipo.entity.ts b/src/modules/estimates/entities/anticipo.entity.ts new file mode 100644 index 0000000..0938f0a --- /dev/null +++ b/src/modules/estimates/entities/anticipo.entity.ts @@ -0,0 +1,134 @@ +/** + * Anticipo Entity + * Anticipos otorgados a subcontratistas + * + * @module Estimates + * @table estimates.anticipos + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Amortizacion } from './amortizacion.entity'; + +export type AdvanceType = 'initial' | 'progress' | 'materials'; + +@Entity({ schema: 'estimates', name: 'anticipos' }) +@Index(['tenantId', 'advanceNumber'], { unique: true }) +@Index(['tenantId']) +@Index(['contratoId']) +@Index(['advanceType']) +export class Anticipo { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'contrato_id', type: 'uuid' }) + contratoId: string; + + @Column({ + name: 'advance_type', + type: 'enum', + enum: ['initial', 'progress', 'materials'], + enumName: 'estimates.advance_type', + default: 'initial', + }) + advanceType: AdvanceType; + + @Column({ name: 'advance_number', type: 'varchar', length: 30 }) + advanceNumber: string; + + @Column({ name: 'advance_date', type: 'date' }) + advanceDate: Date; + + @Column({ name: 'gross_amount', type: 'decimal', precision: 16, scale: 2 }) + grossAmount: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'net_amount', type: 'decimal', precision: 16, scale: 2 }) + netAmount: number; + + @Column({ name: 'amortization_percentage', type: 'decimal', precision: 5, scale: 2, default: 0 }) + amortizationPercentage: number; + + @Column({ name: 'amortized_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + amortizedAmount: number; + + // Columna calculada (GENERATED ALWAYS AS) - solo lectura + @Column({ + name: 'pending_amount', + type: 'decimal', + precision: 16, + scale: 2, + insert: false, + update: false, + }) + pendingAmount: number; + + @Column({ name: 'is_fully_amortized', type: 'boolean', default: false }) + isFullyAmortized: boolean; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date | null; + + @Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true }) + paymentReference: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => Amortizacion, (a) => a.anticipo) + amortizaciones: Amortizacion[]; +} diff --git a/src/modules/estimates/entities/estimacion-concepto.entity.ts b/src/modules/estimates/entities/estimacion-concepto.entity.ts new file mode 100644 index 0000000..0d2aaa7 --- /dev/null +++ b/src/modules/estimates/entities/estimacion-concepto.entity.ts @@ -0,0 +1,122 @@ +/** + * EstimacionConcepto Entity + * Lineas de concepto por estimacion + * + * @module Estimates + * @table estimates.estimacion_conceptos + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Concepto } from '../../budgets/entities/concepto.entity'; +import { Estimacion } from './estimacion.entity'; +import { Generador } from './generador.entity'; + +@Entity({ schema: 'estimates', name: 'estimacion_conceptos' }) +@Index(['estimacionId', 'conceptoId'], { unique: true }) +@Index(['tenantId']) +@Index(['estimacionId']) +@Index(['conceptoId']) +export class EstimacionConcepto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'estimacion_id', type: 'uuid' }) + estimacionId: string; + + @Column({ name: 'concepto_id', type: 'uuid' }) + conceptoId: string; + + @Column({ name: 'contrato_partida_id', type: 'uuid', nullable: true }) + contratoPartidaId: string | null; + + @Column({ name: 'quantity_contract', type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantityContract: number; + + @Column({ name: 'quantity_previous', type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantityPrevious: number; + + @Column({ name: 'quantity_current', type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantityCurrent: number; + + // Columna calculada (GENERATED ALWAYS AS) - solo lectura + @Column({ + name: 'quantity_accumulated', + type: 'decimal', + precision: 12, + scale: 4, + insert: false, + update: false, + }) + quantityAccumulated: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 4, default: 0 }) + unitPrice: number; + + // Columna calculada (GENERATED ALWAYS AS) - solo lectura + @Column({ + name: 'amount_current', + type: 'decimal', + precision: 14, + scale: 2, + insert: false, + update: false, + }) + amountCurrent: number; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Estimacion, (e) => e.conceptos) + @JoinColumn({ name: 'estimacion_id' }) + estimacion: Estimacion; + + @ManyToOne(() => Concepto) + @JoinColumn({ name: 'concepto_id' }) + concepto: Concepto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => Generador, (g) => g.estimacionConcepto) + generadores: Generador[]; +} diff --git a/src/modules/estimates/entities/estimacion-workflow.entity.ts b/src/modules/estimates/entities/estimacion-workflow.entity.ts new file mode 100644 index 0000000..20c59f3 --- /dev/null +++ b/src/modules/estimates/entities/estimacion-workflow.entity.ts @@ -0,0 +1,87 @@ +/** + * EstimacionWorkflow Entity + * Historial de workflow de estimaciones + * + * @module Estimates + * @table estimates.estimacion_workflow + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Estimacion, EstimateStatus } from './estimacion.entity'; + +@Entity({ schema: 'estimates', name: 'estimacion_workflow' }) +@Index(['tenantId']) +@Index(['estimacionId']) +export class EstimacionWorkflow { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'estimacion_id', type: 'uuid' }) + estimacionId: string; + + @Column({ + name: 'from_status', + type: 'enum', + enum: ['draft', 'submitted', 'reviewed', 'approved', 'invoiced', 'paid', 'rejected', 'cancelled'], + enumName: 'estimates.estimate_status', + nullable: true, + }) + fromStatus: EstimateStatus | null; + + @Column({ + name: 'to_status', + type: 'enum', + enum: ['draft', 'submitted', 'reviewed', 'approved', 'invoiced', 'paid', 'rejected', 'cancelled'], + enumName: 'estimates.estimate_status', + }) + toStatus: EstimateStatus; + + @Column({ type: 'varchar', length: 50 }) + action: string; + + @Column({ type: 'text', nullable: true }) + comments: string | null; + + @Column({ name: 'performed_by', type: 'uuid' }) + performedById: string; + + @Column({ name: 'performed_at', type: 'timestamptz', default: () => 'NOW()' }) + performedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Estimacion, (e) => e.workflow) + @JoinColumn({ name: 'estimacion_id' }) + estimacion: Estimacion; + + @ManyToOne(() => User) + @JoinColumn({ name: 'performed_by' }) + performedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/src/modules/estimates/entities/estimacion.entity.ts b/src/modules/estimates/entities/estimacion.entity.ts new file mode 100644 index 0000000..132353f --- /dev/null +++ b/src/modules/estimates/entities/estimacion.entity.ts @@ -0,0 +1,173 @@ +/** + * Estimacion Entity + * Estimaciones de obra periodicas para subcontratistas + * + * @module Estimates + * @table estimates.estimaciones + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, + Check, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { EstimacionConcepto } from './estimacion-concepto.entity'; +import { Amortizacion } from './amortizacion.entity'; +import { Retencion } from './retencion.entity'; +import { EstimacionWorkflow } from './estimacion-workflow.entity'; + +export type EstimateStatus = 'draft' | 'submitted' | 'reviewed' | 'approved' | 'invoiced' | 'paid' | 'rejected' | 'cancelled'; + +@Entity({ schema: 'estimates', name: 'estimaciones' }) +@Index(['tenantId', 'estimateNumber'], { unique: true }) +@Index(['contratoId', 'sequenceNumber'], { unique: true }) +@Index(['tenantId']) +@Index(['contratoId']) +@Index(['fraccionamientoId']) +@Index(['status']) +@Index(['periodStart', 'periodEnd']) +@Check(`"period_end" >= "period_start"`) +export class Estimacion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'contrato_id', type: 'uuid' }) + contratoId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'estimate_number', type: 'varchar', length: 30 }) + estimateNumber: string; + + @Column({ name: 'period_start', type: 'date' }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd: Date; + + @Column({ name: 'sequence_number', type: 'integer' }) + sequenceNumber: number; + + @Column({ + type: 'enum', + enum: ['draft', 'submitted', 'reviewed', 'approved', 'invoiced', 'paid', 'rejected', 'cancelled'], + enumName: 'estimates.estimate_status', + default: 'draft', + }) + status: EstimateStatus; + + @Column({ type: 'decimal', precision: 16, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'advance_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + advanceAmount: number; + + @Column({ name: 'retention_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + retentionAmount: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'total_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + totalAmount: number; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt: Date | null; + + @Column({ name: 'submitted_by', type: 'uuid', nullable: true }) + submittedById: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt: Date | null; + + @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) + reviewedById: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string | null; + + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId: string | null; + + @Column({ name: 'invoiced_at', type: 'timestamptz', nullable: true }) + invoicedAt: Date | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => User) + @JoinColumn({ name: 'submitted_by' }) + submittedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'reviewed_by' }) + reviewedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => EstimacionConcepto, (c) => c.estimacion) + conceptos: EstimacionConcepto[]; + + @OneToMany(() => Amortizacion, (a) => a.estimacion) + amortizaciones: Amortizacion[]; + + @OneToMany(() => Retencion, (r) => r.estimacion) + retenciones: Retencion[]; + + @OneToMany(() => EstimacionWorkflow, (w) => w.estimacion) + workflow: EstimacionWorkflow[]; +} diff --git a/src/modules/estimates/entities/fondo-garantia.entity.ts b/src/modules/estimates/entities/fondo-garantia.entity.ts new file mode 100644 index 0000000..31d2739 --- /dev/null +++ b/src/modules/estimates/entities/fondo-garantia.entity.ts @@ -0,0 +1,92 @@ +/** + * FondoGarantia Entity + * Fondo de garantia acumulado por contrato + * + * @module Estimates + * @table estimates.fondo_garantia + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +@Entity({ schema: 'estimates', name: 'fondo_garantia' }) +@Index(['contratoId'], { unique: true }) +@Index(['tenantId']) +export class FondoGarantia { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'contrato_id', type: 'uuid' }) + contratoId: string; + + @Column({ name: 'accumulated_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + accumulatedAmount: number; + + @Column({ name: 'released_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + releasedAmount: number; + + // Columna calculada (GENERATED ALWAYS AS) - solo lectura + @Column({ + name: 'pending_amount', + type: 'decimal', + precision: 16, + scale: 2, + insert: false, + update: false, + }) + pendingAmount: number; + + @Column({ name: 'release_date', type: 'date', nullable: true }) + releaseDate: Date | null; + + @Column({ name: 'released_at', type: 'timestamptz', nullable: true }) + releasedAt: Date | null; + + @Column({ name: 'released_by', type: 'uuid', nullable: true }) + releasedById: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'released_by' }) + releasedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/src/modules/estimates/entities/generador.entity.ts b/src/modules/estimates/entities/generador.entity.ts new file mode 100644 index 0000000..5ca305f --- /dev/null +++ b/src/modules/estimates/entities/generador.entity.ts @@ -0,0 +1,125 @@ +/** + * Generador Entity + * Numeros generadores (soporte de cantidades para estimaciones) + * + * @module Estimates + * @table estimates.generadores + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { EstimacionConcepto } from './estimacion-concepto.entity'; + +export type GeneratorStatus = 'draft' | 'in_progress' | 'completed' | 'approved'; + +@Entity({ schema: 'estimates', name: 'generadores' }) +@Index(['tenantId']) +@Index(['estimacionConceptoId']) +@Index(['status']) +export class Generador { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'estimacion_concepto_id', type: 'uuid' }) + estimacionConceptoId: string; + + @Column({ name: 'generator_number', type: 'varchar', length: 30 }) + generatorNumber: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + type: 'enum', + enum: ['draft', 'in_progress', 'completed', 'approved'], + enumName: 'estimates.generator_status', + default: 'draft', + }) + status: GeneratorStatus; + + @Column({ name: 'lote_id', type: 'uuid', nullable: true }) + loteId: string | null; + + @Column({ name: 'departamento_id', type: 'uuid', nullable: true }) + departamentoId: string | null; + + @Column({ name: 'location_description', type: 'varchar', length: 255, nullable: true }) + locationDescription: string | null; + + @Column({ type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantity: number; + + @Column({ type: 'text', nullable: true }) + formula: string | null; + + @Column({ name: 'photo_url', type: 'varchar', length: 500, nullable: true }) + photoUrl: string | null; + + @Column({ name: 'sketch_url', type: 'varchar', length: 500, nullable: true }) + sketchUrl: string | null; + + @Column({ name: 'captured_by', type: 'uuid' }) + capturedById: string; + + @Column({ name: 'captured_at', type: 'timestamptz', default: () => 'NOW()' }) + capturedAt: Date; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => EstimacionConcepto, (ec) => ec.generadores) + @JoinColumn({ name: 'estimacion_concepto_id' }) + estimacionConcepto: EstimacionConcepto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'captured_by' }) + capturedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/src/modules/estimates/entities/index.ts b/src/modules/estimates/entities/index.ts new file mode 100644 index 0000000..76096aa --- /dev/null +++ b/src/modules/estimates/entities/index.ts @@ -0,0 +1,13 @@ +/** + * Estimates Module - Entity Exports + * MAI-008: Estimaciones y Facturacion + */ + +export * from './estimacion.entity'; +export * from './estimacion-concepto.entity'; +export * from './generador.entity'; +export * from './anticipo.entity'; +export * from './amortizacion.entity'; +export * from './retencion.entity'; +export * from './fondo-garantia.entity'; +export * from './estimacion-workflow.entity'; diff --git a/src/modules/estimates/entities/retencion.entity.ts b/src/modules/estimates/entities/retencion.entity.ts new file mode 100644 index 0000000..1bfc70c --- /dev/null +++ b/src/modules/estimates/entities/retencion.entity.ts @@ -0,0 +1,99 @@ +/** + * Retencion Entity + * Retenciones aplicadas a estimaciones + * + * @module Estimates + * @table estimates.retenciones + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Estimacion } from './estimacion.entity'; + +export type RetentionType = 'guarantee' | 'tax' | 'penalty' | 'other'; + +@Entity({ schema: 'estimates', name: 'retenciones' }) +@Index(['tenantId']) +@Index(['estimacionId']) +@Index(['retentionType']) +export class Retencion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'estimacion_id', type: 'uuid' }) + estimacionId: string; + + @Column({ + name: 'retention_type', + type: 'enum', + enum: ['guarantee', 'tax', 'penalty', 'other'], + enumName: 'estimates.retention_type', + }) + retentionType: RetentionType; + + @Column({ type: 'varchar', length: 255 }) + description: string; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + percentage: number | null; + + @Column({ type: 'decimal', precision: 16, scale: 2 }) + amount: number; + + @Column({ name: 'release_date', type: 'date', nullable: true }) + releaseDate: Date | null; + + @Column({ name: 'released_at', type: 'timestamptz', nullable: true }) + releasedAt: Date | null; + + @Column({ name: 'released_amount', type: 'decimal', precision: 16, scale: 2, nullable: true }) + releasedAmount: number | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Estimacion, (e) => e.retenciones) + @JoinColumn({ name: 'estimacion_id' }) + estimacion: Estimacion; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/src/modules/estimates/services/anticipo.service.ts b/src/modules/estimates/services/anticipo.service.ts new file mode 100644 index 0000000..691eb0d --- /dev/null +++ b/src/modules/estimates/services/anticipo.service.ts @@ -0,0 +1,351 @@ +/** + * AnticipoService - Gestión de Anticipos de Obra + * + * Gestiona anticipos otorgados a subcontratistas: inicial, avance, materiales. + * + * @module Estimates + */ + +import { Repository } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Anticipo, AdvanceType } from '../entities/anticipo.entity'; + +export interface CreateAnticipoDto { + contratoId: string; + advanceType: AdvanceType; + advanceNumber: string; + advanceDate: Date; + grossAmount: number; + taxAmount: number; + netAmount: number; + amortizationPercentage?: number; + notes?: string; +} + +export interface AnticipoFilters { + contratoId?: string; + advanceType?: AdvanceType; + isFullyAmortized?: boolean; + startDate?: Date; + endDate?: Date; + approvedById?: string; +} + +export interface AnticipoStats { + totalAnticipated: number; + totalAmortized: number; + pendingAmortization: number; + totalApproved: number; + totalPending: number; + totalPaid: number; + byType: { + type: AdvanceType; + count: number; + totalAmount: number; + }[]; +} + +export class AnticipoService extends BaseService { + constructor(repository: Repository) { + super(repository); + } + + /** + * Busca anticipos por contrato + */ + async findByContrato( + ctx: ServiceContext, + contratoId: string, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('a') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.contrato_id = :contratoId', { contratoId }) + .andWhere('a.deleted_at IS NULL'); + + const skip = (page - 1) * limit; + qb.orderBy('a.advance_date', 'DESC') + .addOrderBy('a.advance_number', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Crea un nuevo anticipo + */ + async createAnticipo( + ctx: ServiceContext, + dto: CreateAnticipoDto + ): Promise { + // Validar que el monto neto sea correcto + const expectedNet = dto.grossAmount - dto.taxAmount; + if (Math.abs(expectedNet - dto.netAmount) > 0.01) { + throw new Error('El monto neto no coincide con el cálculo (bruto - impuestos)'); + } + + return this.create(ctx, { + ...dto, + amortizedAmount: 0, + isFullyAmortized: false, + }); + } + + /** + * Aprueba un anticipo + */ + async approveAnticipo( + ctx: ServiceContext, + id: string, + approvedById: string, + notes?: string + ): Promise { + const anticipo = await this.findById(ctx, id); + if (!anticipo) { + throw new Error('Anticipo no encontrado'); + } + + if (anticipo.approvedAt) { + throw new Error('El anticipo ya está aprobado'); + } + + const updateData: Partial = { + approvedAt: new Date(), + approvedById, + }; + + if (notes) { + updateData.notes = anticipo.notes + ? `${anticipo.notes}\n\nAprobación: ${notes}` + : `Aprobación: ${notes}`; + } + + const updated = await this.update(ctx, id, updateData); + if (!updated) { + throw new Error('Error al aprobar anticipo'); + } + + return updated; + } + + /** + * Marca un anticipo como pagado + */ + async markPaid( + ctx: ServiceContext, + id: string, + paymentReference: string, + paidAt?: Date + ): Promise { + const anticipo = await this.findById(ctx, id); + if (!anticipo) { + throw new Error('Anticipo no encontrado'); + } + + if (!anticipo.approvedAt) { + throw new Error('El anticipo debe estar aprobado antes de marcarse como pagado'); + } + + if (anticipo.paidAt) { + throw new Error('El anticipo ya está marcado como pagado'); + } + + const updated = await this.update(ctx, id, { + paidAt: paidAt || new Date(), + paymentReference, + }); + + if (!updated) { + throw new Error('Error al marcar anticipo como pagado'); + } + + return updated; + } + + /** + * Obtiene estadísticas de anticipos + */ + async getStats( + ctx: ServiceContext, + filters?: AnticipoFilters + ): Promise { + const qb = this.repository + .createQueryBuilder('a') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.deleted_at IS NULL'); + + if (filters?.contratoId) { + qb.andWhere('a.contrato_id = :contratoId', { contratoId: filters.contratoId }); + } + if (filters?.advanceType) { + qb.andWhere('a.advance_type = :advanceType', { advanceType: filters.advanceType }); + } + if (filters?.isFullyAmortized !== undefined) { + qb.andWhere('a.is_fully_amortized = :isFullyAmortized', { + isFullyAmortized: filters.isFullyAmortized, + }); + } + if (filters?.startDate) { + qb.andWhere('a.advance_date >= :startDate', { startDate: filters.startDate }); + } + if (filters?.endDate) { + qb.andWhere('a.advance_date <= :endDate', { endDate: filters.endDate }); + } + if (filters?.approvedById) { + qb.andWhere('a.approved_by = :approvedById', { approvedById: filters.approvedById }); + } + + const anticipos = await qb.getMany(); + + const stats: AnticipoStats = { + totalAnticipated: 0, + totalAmortized: 0, + pendingAmortization: 0, + totalApproved: 0, + totalPending: 0, + totalPaid: 0, + byType: [], + }; + + const typeStats = new Map(); + + anticipos.forEach((anticipo) => { + const netAmount = Number(anticipo.netAmount) || 0; + const amortizedAmount = Number(anticipo.amortizedAmount) || 0; + + stats.totalAnticipated += netAmount; + stats.totalAmortized += amortizedAmount; + stats.pendingAmortization += netAmount - amortizedAmount; + + if (anticipo.approvedAt) { + stats.totalApproved += netAmount; + } else { + stats.totalPending += netAmount; + } + + if (anticipo.paidAt) { + stats.totalPaid += netAmount; + } + + const existing = typeStats.get(anticipo.advanceType) || { count: 0, totalAmount: 0 }; + typeStats.set(anticipo.advanceType, { + count: existing.count + 1, + totalAmount: existing.totalAmount + netAmount, + }); + }); + + stats.byType = Array.from(typeStats.entries()).map(([type, data]) => ({ + type, + count: data.count, + totalAmount: data.totalAmount, + })); + + return stats; + } + + /** + * Busca anticipos con filtros y paginación + */ + async findWithFilters( + ctx: ServiceContext, + filters: AnticipoFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('a') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.deleted_at IS NULL'); + + if (filters.contratoId) { + qb.andWhere('a.contrato_id = :contratoId', { contratoId: filters.contratoId }); + } + if (filters.advanceType) { + qb.andWhere('a.advance_type = :advanceType', { advanceType: filters.advanceType }); + } + if (filters.isFullyAmortized !== undefined) { + qb.andWhere('a.is_fully_amortized = :isFullyAmortized', { + isFullyAmortized: filters.isFullyAmortized, + }); + } + if (filters.startDate) { + qb.andWhere('a.advance_date >= :startDate', { startDate: filters.startDate }); + } + if (filters.endDate) { + qb.andWhere('a.advance_date <= :endDate', { endDate: filters.endDate }); + } + if (filters.approvedById) { + qb.andWhere('a.approved_by = :approvedById', { approvedById: filters.approvedById }); + } + + const skip = (page - 1) * limit; + qb.orderBy('a.advance_date', 'DESC') + .addOrderBy('a.advance_number', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Actualiza el monto amortizado de un anticipo + */ + async updateAmortization( + ctx: ServiceContext, + id: string, + amountToAmortize: number + ): Promise { + const anticipo = await this.findById(ctx, id); + if (!anticipo) { + throw new Error('Anticipo no encontrado'); + } + + if (anticipo.isFullyAmortized) { + throw new Error('El anticipo ya está completamente amortizado'); + } + + const currentAmortized = Number(anticipo.amortizedAmount) || 0; + const netAmount = Number(anticipo.netAmount) || 0; + const newAmortizedAmount = currentAmortized + amountToAmortize; + + if (newAmortizedAmount > netAmount) { + throw new Error('El monto a amortizar excede el saldo del anticipo'); + } + + const isFullyAmortized = Math.abs(newAmortizedAmount - netAmount) < 0.01; + + const updated = await this.update(ctx, id, { + amortizedAmount: newAmortizedAmount, + isFullyAmortized, + }); + + if (!updated) { + throw new Error('Error al actualizar amortización'); + } + + return updated; + } +} diff --git a/src/modules/estimates/services/estimacion.service.ts b/src/modules/estimates/services/estimacion.service.ts new file mode 100644 index 0000000..0512294 --- /dev/null +++ b/src/modules/estimates/services/estimacion.service.ts @@ -0,0 +1,424 @@ +/** + * EstimacionService - Gestión de Estimaciones de Obra + * + * Gestiona estimaciones periódicas con workflow de aprobación. + * Incluye cálculo de anticipos, retenciones e IVA. + * + * @module Estimates + */ + +import { Repository, DataSource } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Estimacion, EstimateStatus } from '../entities/estimacion.entity'; +import { EstimacionConcepto } from '../entities/estimacion-concepto.entity'; +import { Generador } from '../entities/generador.entity'; +import { EstimacionWorkflow } from '../entities/estimacion-workflow.entity'; + +export interface CreateEstimacionDto { + contratoId: string; + fraccionamientoId: string; + periodStart: Date; + periodEnd: Date; + notes?: string; +} + +export interface AddConceptoDto { + conceptoId: string; + contratoPartidaId?: string; + quantityContract?: number; + quantityPrevious?: number; + quantityCurrent: number; + unitPrice: number; + notes?: string; +} + +export interface AddGeneradorDto { + generatorNumber: string; + description?: string; + loteId?: string; + departamentoId?: string; + locationDescription?: string; + quantity: number; + formula?: string; + photoUrl?: string; + sketchUrl?: string; +} + +export interface EstimacionFilters { + contratoId?: string; + fraccionamientoId?: string; + status?: EstimateStatus; + periodFrom?: Date; + periodTo?: Date; +} + +export class EstimacionService extends BaseService { + constructor( + repository: Repository, + private readonly conceptoRepository: Repository, + private readonly generadorRepository: Repository, + private readonly workflowRepository: Repository, + private readonly dataSource: DataSource + ) { + super(repository); + } + + /** + * Crear nueva estimación + */ + async createEstimacion( + ctx: ServiceContext, + data: CreateEstimacionDto + ): Promise { + const sequenceNumber = await this.getNextSequenceNumber(ctx, data.contratoId); + const estimateNumber = await this.generateEstimateNumber(ctx, data.contratoId, sequenceNumber); + + const estimacion = await this.create(ctx, { + ...data, + estimateNumber, + sequenceNumber, + status: 'draft', + }); + + // Registrar en workflow + await this.addWorkflowEntry(ctx, estimacion.id, null, 'draft', 'create', 'Estimación creada'); + + return estimacion; + } + + /** + * Obtener siguiente número de secuencia + */ + private async getNextSequenceNumber( + ctx: ServiceContext, + contratoId: string + ): Promise { + const result = await this.repository + .createQueryBuilder('e') + .select('MAX(e.sequence_number)', 'maxNumber') + .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('e.contrato_id = :contratoId', { contratoId }) + .getRawOne(); + + return (result?.maxNumber || 0) + 1; + } + + /** + * Generar número de estimación + */ + private async generateEstimateNumber( + _ctx: ServiceContext, + contratoId: string, + sequenceNumber: number + ): Promise { + const year = new Date().getFullYear(); + return `EST-${year}-${contratoId.substring(0, 8).toUpperCase()}-${sequenceNumber.toString().padStart(3, '0')}`; + } + + /** + * Obtener estimaciones por contrato + */ + async findByContrato( + ctx: ServiceContext, + contratoId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { contratoId } as any, + }); + } + + /** + * Obtener estimaciones con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: EstimacionFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('e') + .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('e.deleted_at IS NULL'); + + if (filters.contratoId) { + qb.andWhere('e.contrato_id = :contratoId', { contratoId: filters.contratoId }); + } + if (filters.fraccionamientoId) { + qb.andWhere('e.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId: filters.fraccionamientoId }); + } + if (filters.status) { + qb.andWhere('e.status = :status', { status: filters.status }); + } + if (filters.periodFrom) { + qb.andWhere('e.period_start >= :periodFrom', { periodFrom: filters.periodFrom }); + } + if (filters.periodTo) { + qb.andWhere('e.period_end <= :periodTo', { periodTo: filters.periodTo }); + } + + const skip = (page - 1) * limit; + qb.orderBy('e.sequence_number', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Obtener estimación con detalles completos + */ + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + relations: [ + 'conceptos', + 'conceptos.concepto', + 'conceptos.generadores', + 'amortizaciones', + 'retenciones', + 'workflow', + ], + }); + } + + /** + * Agregar concepto a estimación + */ + async addConcepto( + ctx: ServiceContext, + estimacionId: string, + data: AddConceptoDto + ): Promise { + const estimacion = await this.findById(ctx, estimacionId); + if (!estimacion || estimacion.status !== 'draft') { + throw new Error('Cannot modify non-draft estimation'); + } + + const concepto = this.conceptoRepository.create({ + tenantId: ctx.tenantId, + estimacionId, + conceptoId: data.conceptoId, + contratoPartidaId: data.contratoPartidaId, + quantityContract: data.quantityContract || 0, + quantityPrevious: data.quantityPrevious || 0, + quantityCurrent: data.quantityCurrent, + unitPrice: data.unitPrice, + notes: data.notes, + createdById: ctx.userId, + }); + + const savedConcepto = await this.conceptoRepository.save(concepto); + await this.recalculateTotals(ctx, estimacionId); + + return savedConcepto; + } + + /** + * Agregar generador a concepto de estimación + */ + async addGenerador( + ctx: ServiceContext, + estimacionConceptoId: string, + data: AddGeneradorDto + ): Promise { + const concepto = await this.conceptoRepository.findOne({ + where: { id: estimacionConceptoId, tenantId: ctx.tenantId } as any, + relations: ['estimacion'], + }); + + if (!concepto) { + throw new Error('Concepto not found'); + } + + const generador = this.generadorRepository.create({ + tenantId: ctx.tenantId, + estimacionConceptoId, + generatorNumber: data.generatorNumber, + description: data.description, + loteId: data.loteId, + departamentoId: data.departamentoId, + locationDescription: data.locationDescription, + quantity: data.quantity, + formula: data.formula, + photoUrl: data.photoUrl, + sketchUrl: data.sketchUrl, + status: 'draft', + capturedById: ctx.userId, + createdById: ctx.userId, + }); + + return this.generadorRepository.save(generador); + } + + /** + * Recalcular totales de estimación + */ + async recalculateTotals(_ctx: ServiceContext, estimacionId: string): Promise { + // Ejecutar función de PostgreSQL + await this.dataSource.query('SELECT estimates.calculate_estimate_totals($1)', [estimacionId]); + } + + /** + * Cambiar estado de estimación + */ + async changeStatus( + ctx: ServiceContext, + estimacionId: string, + newStatus: EstimateStatus, + action: string, + comments?: string + ): Promise { + const estimacion = await this.findById(ctx, estimacionId); + if (!estimacion) { + return null; + } + + const validTransitions: Record = { + draft: ['submitted'], + submitted: ['reviewed', 'rejected'], + reviewed: ['approved', 'rejected'], + approved: ['invoiced'], + invoiced: ['paid'], + paid: [], + rejected: ['draft'], + cancelled: [], + }; + + if (!validTransitions[estimacion.status]?.includes(newStatus)) { + throw new Error(`Invalid status transition from ${estimacion.status} to ${newStatus}`); + } + + const updateData: Partial = { status: newStatus }; + + switch (newStatus) { + case 'submitted': + updateData.submittedAt = new Date(); + updateData.submittedById = ctx.userId; + break; + case 'reviewed': + updateData.reviewedAt = new Date(); + updateData.reviewedById = ctx.userId; + break; + case 'approved': + updateData.approvedAt = new Date(); + updateData.approvedById = ctx.userId; + break; + case 'invoiced': + updateData.invoicedAt = new Date(); + break; + case 'paid': + updateData.paidAt = new Date(); + break; + } + + const updated = await this.update(ctx, estimacionId, updateData); + + // Registrar en workflow + await this.addWorkflowEntry(ctx, estimacionId, estimacion.status, newStatus, action, comments); + + return updated; + } + + /** + * Agregar entrada al workflow + */ + private async addWorkflowEntry( + ctx: ServiceContext, + estimacionId: string, + fromStatus: EstimateStatus | null, + toStatus: EstimateStatus, + action: string, + comments?: string + ): Promise { + await this.workflowRepository.save( + this.workflowRepository.create({ + tenantId: ctx.tenantId, + estimacionId, + fromStatus, + toStatus, + action, + comments, + performedById: ctx.userId, + createdById: ctx.userId, + }) + ); + } + + /** + * Enviar estimación para revisión + */ + async submit(ctx: ServiceContext, estimacionId: string): Promise { + return this.changeStatus(ctx, estimacionId, 'submitted', 'submit', 'Enviada para revisión'); + } + + /** + * Revisar estimación + */ + async review(ctx: ServiceContext, estimacionId: string): Promise { + return this.changeStatus(ctx, estimacionId, 'reviewed', 'review', 'Revisión completada'); + } + + /** + * Aprobar estimación + */ + async approve(ctx: ServiceContext, estimacionId: string): Promise { + return this.changeStatus(ctx, estimacionId, 'approved', 'approve', 'Aprobada'); + } + + /** + * Rechazar estimación + */ + async reject( + ctx: ServiceContext, + estimacionId: string, + reason: string + ): Promise { + return this.changeStatus(ctx, estimacionId, 'rejected', 'reject', reason); + } + + /** + * Obtener resumen de estimaciones por contrato + */ + async getContractSummary(ctx: ServiceContext, contratoId: string): Promise { + const result = await this.repository + .createQueryBuilder('e') + .select([ + 'COUNT(*) as total_estimates', + 'SUM(CASE WHEN e.status = \'approved\' THEN e.total_amount ELSE 0 END) as total_approved', + 'SUM(CASE WHEN e.status = \'paid\' THEN e.total_amount ELSE 0 END) as total_paid', + ]) + .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('e.contrato_id = :contratoId', { contratoId }) + .andWhere('e.deleted_at IS NULL') + .getRawOne(); + + return { + totalEstimates: parseInt(result?.total_estimates || '0'), + totalApproved: parseFloat(result?.total_approved || '0'), + totalPaid: parseFloat(result?.total_paid || '0'), + }; + } +} + +interface ContractEstimateSummary { + totalEstimates: number; + totalApproved: number; + totalPaid: number; +} diff --git a/src/modules/estimates/services/fondo-garantia.service.ts b/src/modules/estimates/services/fondo-garantia.service.ts new file mode 100644 index 0000000..87ca697 --- /dev/null +++ b/src/modules/estimates/services/fondo-garantia.service.ts @@ -0,0 +1,275 @@ +/** + * FondoGarantiaService - Gestión de Fondos de Garantía + * + * Gestiona fondos de garantía acumulados por contrato: retención y liberación. + * + * @module Estimates + */ + +import { Repository } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { FondoGarantia } from '../entities/fondo-garantia.entity'; + +export interface CreateFondoGarantiaDto { + contratoId: string; + accumulatedAmount?: number; + releasedAmount?: number; + releaseDate?: Date; +} + +export interface ReleaseFondoDto { + amount: number; + releasedById: string; + releaseDate?: Date; + notes?: string; +} + +export interface FondoGarantiaFilters { + contratoId?: string; + hasPending?: boolean; + fullyReleased?: boolean; +} + +export interface FondoGarantiaStats { + totalAccumulated: number; + totalReleased: number; + totalPending: number; + fondosCount: number; + fondosPendingRelease: number; + fondosFullyReleased: number; +} + +export class FondoGarantiaService extends BaseService { + constructor(repository: Repository) { + super(repository); + } + + /** + * Encuentra fondo de garantía por contrato + */ + async findByContrato( + ctx: ServiceContext, + contratoId: string + ): Promise { + return this.repository.findOne({ + where: { + tenantId: ctx.tenantId, + contratoId, + deletedAt: null, + } as any, + }); + } + + /** + * Crea o actualiza un fondo de garantía + */ + async createOrUpdate( + ctx: ServiceContext, + dto: CreateFondoGarantiaDto + ): Promise { + const existing = await this.findByContrato(ctx, dto.contratoId); + + if (existing) { + const updated = await this.update(ctx, existing.id, { + accumulatedAmount: dto.accumulatedAmount ?? existing.accumulatedAmount, + releasedAmount: dto.releasedAmount ?? existing.releasedAmount, + releaseDate: dto.releaseDate ?? existing.releaseDate, + }); + return updated!; + } + + return this.create(ctx, { + contratoId: dto.contratoId, + accumulatedAmount: dto.accumulatedAmount ?? 0, + releasedAmount: dto.releasedAmount ?? 0, + releaseDate: dto.releaseDate ?? null, + }); + } + + /** + * Añade monto al fondo acumulado + */ + async addAccumulation( + ctx: ServiceContext, + contratoId: string, + amount: number + ): Promise { + let fondo = await this.findByContrato(ctx, contratoId); + + if (!fondo) { + fondo = await this.create(ctx, { + contratoId, + accumulatedAmount: amount, + releasedAmount: 0, + }); + return fondo; + } + + const currentAccumulated = Number(fondo.accumulatedAmount) || 0; + const updated = await this.update(ctx, fondo.id, { + accumulatedAmount: currentAccumulated + amount, + }); + + return updated!; + } + + /** + * Libera parcialmente el fondo de garantía + */ + async releasePartial( + ctx: ServiceContext, + contratoId: string, + dto: ReleaseFondoDto + ): Promise { + const fondo = await this.findByContrato(ctx, contratoId); + + if (!fondo) { + throw new Error('Fondo de garantía no encontrado'); + } + + const accumulated = Number(fondo.accumulatedAmount) || 0; + const released = Number(fondo.releasedAmount) || 0; + const pending = accumulated - released; + + if (dto.amount > pending) { + throw new Error(`Monto a liberar (${dto.amount}) excede el monto pendiente (${pending})`); + } + + const updated = await this.update(ctx, fondo.id, { + releasedAmount: released + dto.amount, + releasedAt: dto.releaseDate || new Date(), + releasedById: dto.releasedById, + }); + + return updated!; + } + + /** + * Libera completamente el fondo de garantía + */ + async releaseFull( + ctx: ServiceContext, + contratoId: string, + releasedById: string, + releaseDate?: Date + ): Promise { + const fondo = await this.findByContrato(ctx, contratoId); + + if (!fondo) { + throw new Error('Fondo de garantía no encontrado'); + } + + const accumulated = Number(fondo.accumulatedAmount) || 0; + const released = Number(fondo.releasedAmount) || 0; + const pending = accumulated - released; + + if (pending <= 0) { + throw new Error('No hay monto pendiente para liberar'); + } + + const updated = await this.update(ctx, fondo.id, { + releasedAmount: accumulated, + releasedAt: releaseDate || new Date(), + releasedById, + }); + + return updated!; + } + + /** + * Obtiene estadísticas de fondos de garantía + */ + async getStats(ctx: ServiceContext): Promise { + const fondos = await this.repository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + }); + + const stats: FondoGarantiaStats = { + totalAccumulated: 0, + totalReleased: 0, + totalPending: 0, + fondosCount: fondos.length, + fondosPendingRelease: 0, + fondosFullyReleased: 0, + }; + + fondos.forEach((fondo) => { + const accumulated = Number(fondo.accumulatedAmount) || 0; + const released = Number(fondo.releasedAmount) || 0; + const pending = accumulated - released; + + stats.totalAccumulated += accumulated; + stats.totalReleased += released; + stats.totalPending += pending; + + if (pending > 0) { + stats.fondosPendingRelease++; + } else if (released > 0 && pending === 0) { + stats.fondosFullyReleased++; + } + }); + + return stats; + } + + /** + * Encuentra fondos con filtros y paginación + */ + async findWithFilters( + ctx: ServiceContext, + filters: FondoGarantiaFilters = {}, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('f') + .where('f.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('f.deleted_at IS NULL'); + + if (filters.contratoId) { + qb.andWhere('f.contrato_id = :contratoId', { contratoId: filters.contratoId }); + } + + const skip = (page - 1) * limit; + qb.orderBy('f.created_at', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + // Aplicar filtros post-query si es necesario + let filteredData = data; + + if (filters.hasPending !== undefined) { + filteredData = filteredData.filter((fondo) => { + const pending = (Number(fondo.accumulatedAmount) || 0) - (Number(fondo.releasedAmount) || 0); + return filters.hasPending ? pending > 0 : pending === 0; + }); + } + + if (filters.fullyReleased !== undefined) { + filteredData = filteredData.filter((fondo) => { + const accumulated = Number(fondo.accumulatedAmount) || 0; + const released = Number(fondo.releasedAmount) || 0; + return filters.fullyReleased + ? released > 0 && released >= accumulated + : released < accumulated; + }); + } + + return { + data: filteredData, + meta: { + total: filteredData.length !== data.length ? filteredData.length : total, + page, + limit, + totalPages: Math.ceil( + (filteredData.length !== data.length ? filteredData.length : total) / limit + ), + }, + }; + } +} diff --git a/src/modules/estimates/services/index.ts b/src/modules/estimates/services/index.ts new file mode 100644 index 0000000..0e9df8d --- /dev/null +++ b/src/modules/estimates/services/index.ts @@ -0,0 +1,9 @@ +/** + * Estimates Module - Service Exports + * MAI-008: Estimaciones y Facturación + */ + +export * from './estimacion.service'; +export * from './anticipo.service'; +export * from './fondo-garantia.service'; +export * from './retencion.service'; diff --git a/src/modules/estimates/services/retencion.service.ts b/src/modules/estimates/services/retencion.service.ts new file mode 100644 index 0000000..cb4ec6d --- /dev/null +++ b/src/modules/estimates/services/retencion.service.ts @@ -0,0 +1,258 @@ +/** + * RetencionService - Gestión de Retenciones de Estimaciones + * + * Gestiona retenciones aplicadas a estimaciones: garantía, impuestos, penalizaciones. + * + * @module Estimates + */ + +import { Repository } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Retencion, RetentionType } from '../entities/retencion.entity'; + +export interface CreateRetencionDto { + estimacionId: string; + retentionType: RetentionType; + description: string; + percentage?: number; + amount: number; + releaseDate?: Date; + notes?: string; +} + +export interface ReleaseRetencionDto { + releasedAmount: number; + notes?: string; +} + +export interface RetencionFilters { + estimacionId?: string; + contratoId?: string; + retentionType?: RetentionType; + isReleased?: boolean; + dateFrom?: Date; + dateTo?: Date; +} + +export class RetencionService extends BaseService { + constructor(repository: Repository) { + super(repository); + } + + /** + * Obtener retenciones por estimación + */ + async findByEstimacion( + ctx: ServiceContext, + estimacionId: string + ): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + estimacionId, + deletedAt: null, + } as any, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Crear retención + */ + async createRetencion( + ctx: ServiceContext, + data: CreateRetencionDto + ): Promise { + return this.create(ctx, { + ...data, + releasedAmount: null, + releasedAt: null, + }); + } + + /** + * Obtener retenciones con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: RetencionFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('r') + .leftJoin('r.estimacion', 'e') + .where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('r.deleted_at IS NULL'); + + if (filters.estimacionId) { + qb.andWhere('r.estimacion_id = :estimacionId', { estimacionId: filters.estimacionId }); + } + if (filters.contratoId) { + qb.andWhere('e.contrato_id = :contratoId', { contratoId: filters.contratoId }); + } + if (filters.retentionType) { + qb.andWhere('r.retention_type = :retentionType', { retentionType: filters.retentionType }); + } + if (filters.isReleased !== undefined) { + if (filters.isReleased) { + qb.andWhere('r.released_at IS NOT NULL'); + } else { + qb.andWhere('r.released_at IS NULL'); + } + } + if (filters.dateFrom) { + qb.andWhere('r.created_at >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('r.created_at <= :dateTo', { dateTo: filters.dateTo }); + } + + const skip = (page - 1) * limit; + qb.orderBy('r.created_at', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Liberar retención + */ + async releaseRetencion( + ctx: ServiceContext, + id: string, + data: ReleaseRetencionDto + ): Promise { + const retencion = await this.findById(ctx, id); + if (!retencion) { + return null; + } + + if (retencion.releasedAt) { + throw new Error('Retention already released'); + } + + if (data.releasedAmount > retencion.amount) { + throw new Error('Released amount cannot exceed retention amount'); + } + + return this.update(ctx, id, { + releasedAmount: data.releasedAmount, + releasedAt: new Date(), + notes: data.notes ? `${retencion.notes || ''}\nLiberación: ${data.notes}` : retencion.notes, + }); + } + + /** + * Obtener totales por contrato + */ + async getTotalByContrato(ctx: ServiceContext, contratoId: string): Promise { + const result = await this.repository + .createQueryBuilder('r') + .leftJoin('r.estimacion', 'e') + .select([ + 'SUM(r.amount) as total_retained', + 'SUM(COALESCE(r.released_amount, 0)) as total_released', + ]) + .where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('e.contrato_id = :contratoId', { contratoId }) + .andWhere('r.deleted_at IS NULL') + .getRawOne(); + + const totalRetained = parseFloat(result?.total_retained || '0'); + const totalReleased = parseFloat(result?.total_released || '0'); + + return { + totalRetained, + totalReleased, + totalPending: totalRetained - totalReleased, + }; + } + + /** + * Estadísticas de retenciones + */ + async getStats(ctx: ServiceContext, filters?: RetencionFilters): Promise { + const qb = this.repository + .createQueryBuilder('r') + .where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('r.deleted_at IS NULL'); + + if (filters?.contratoId) { + qb.leftJoin('r.estimacion', 'e') + .andWhere('e.contrato_id = :contratoId', { contratoId: filters.contratoId }); + } + + const retenciones = await qb.getMany(); + + const byType = new Map(); + + let totalRetained = 0; + let totalReleased = 0; + let pendingCount = 0; + let releasedCount = 0; + + retenciones.forEach((r) => { + totalRetained += r.amount; + totalReleased += r.releasedAmount || 0; + + if (r.releasedAt) { + releasedCount++; + } else { + pendingCount++; + } + + const existing = byType.get(r.retentionType) || { count: 0, amount: 0, released: 0 }; + byType.set(r.retentionType, { + count: existing.count + 1, + amount: existing.amount + r.amount, + released: existing.released + (r.releasedAmount || 0), + }); + }); + + return { + totalRetained, + totalReleased, + totalPending: totalRetained - totalReleased, + totalCount: retenciones.length, + pendingCount, + releasedCount, + byType: Array.from(byType.entries()).map(([type, data]) => ({ + type, + ...data, + pending: data.amount - data.released, + })), + }; + } +} + +export interface RetencionTotals { + totalRetained: number; + totalReleased: number; + totalPending: number; +} + +export interface RetencionStats { + totalRetained: number; + totalReleased: number; + totalPending: number; + totalCount: number; + pendingCount: number; + releasedCount: number; + byType: { + type: RetentionType; + count: number; + amount: number; + released: number; + pending: number; + }[]; +} diff --git a/src/modules/finance/controllers/accounting.controller.ts b/src/modules/finance/controllers/accounting.controller.ts new file mode 100644 index 0000000..4608a84 --- /dev/null +++ b/src/modules/finance/controllers/accounting.controller.ts @@ -0,0 +1,385 @@ +/** + * AccountingController - Controlador de Contabilidad + * + * Endpoints para catálogo de cuentas y pólizas. + * + * @module Finance + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { AccountingService } from '../services'; + +export function createAccountingController(dataSource: DataSource): Router { + const router = Router(); + const service = new AccountingService(dataSource); + + // ==================== CATÁLOGO DE CUENTAS ==================== + + /** + * GET /accounts + * Lista cuentas contables con filtros y paginación + */ + router.get('/accounts', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const options = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 50, + accountType: req.query.accountType as any, + status: req.query.status as any, + parentId: req.query.parentId as string, + search: req.query.search as string, + acceptsMovements: req.query.acceptsMovements === 'true' ? true : req.query.acceptsMovements === 'false' ? false : undefined, + }; + + const result = await service.findAllAccounts(ctx, options); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /accounts/tree + * Obtiene árbol jerárquico de cuentas + */ + router.get('/accounts/tree', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const tree = await service.getAccountTree(ctx); + res.json(tree); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /accounts/:id + * Obtiene una cuenta por ID + */ + router.get('/accounts/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const account = await service.findAccountById(ctx, req.params.id); + if (!account) { + return res.status(404).json({ error: 'Cuenta no encontrada' }); + } + + res.json(account); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /accounts/code/:code + * Obtiene una cuenta por código + */ + router.get('/accounts/code/:code', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const account = await service.findAccountByCode(ctx, req.params.code); + if (!account) { + return res.status(404).json({ error: 'Cuenta no encontrada' }); + } + + res.json(account); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * POST /accounts + * Crea una nueva cuenta + */ + router.post('/accounts', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const account = await service.createAccount(ctx, req.body); + res.status(201).json(account); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * PUT /accounts/:id + * Actualiza una cuenta + */ + router.put('/accounts/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const account = await service.updateAccount(ctx, req.params.id, req.body); + res.json(account); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * DELETE /accounts/:id + * Elimina una cuenta (soft delete) + */ + router.delete('/accounts/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + await service.deleteAccount(ctx, req.params.id); + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== PÓLIZAS CONTABLES ==================== + + /** + * GET /entries + * Lista pólizas con filtros y paginación + */ + router.get('/entries', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const options = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 20, + entryType: req.query.entryType as any, + status: req.query.status as any, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + projectId: req.query.projectId as string, + fiscalYear: req.query.fiscalYear ? parseInt(req.query.fiscalYear as string) : undefined, + fiscalPeriod: req.query.fiscalPeriod ? parseInt(req.query.fiscalPeriod as string) : undefined, + search: req.query.search as string, + }; + + const result = await service.findAllEntries(ctx, options); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /entries/:id + * Obtiene una póliza por ID + */ + router.get('/entries/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const entry = await service.findEntryById(ctx, req.params.id); + if (!entry) { + return res.status(404).json({ error: 'Póliza no encontrada' }); + } + + res.json(entry); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * POST /entries + * Crea una nueva póliza + */ + router.post('/entries', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const entry = await service.createEntry(ctx, req.body); + res.status(201).json(entry); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /entries/:id/submit + * Envía póliza a aprobación + */ + router.post('/entries/:id/submit', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const entry = await service.submitForApproval(ctx, req.params.id); + res.json(entry); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /entries/:id/approve + * Aprueba una póliza + */ + router.post('/entries/:id/approve', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const entry = await service.approveEntry(ctx, req.params.id); + res.json(entry); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /entries/:id/post + * Contabiliza una póliza + */ + router.post('/entries/:id/post', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const entry = await service.postEntry(ctx, req.params.id); + res.json(entry); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /entries/:id/cancel + * Cancela una póliza + */ + router.post('/entries/:id/cancel', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { reason } = req.body; + if (!reason) { + return res.status(400).json({ error: 'Se requiere motivo de cancelación' }); + } + + const entry = await service.cancelEntry(ctx, req.params.id, reason); + res.json(entry); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /entries/:id/reverse + * Reversa una póliza + */ + router.post('/entries/:id/reverse', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { reason } = req.body; + if (!reason) { + return res.status(400).json({ error: 'Se requiere motivo de reverso' }); + } + + const entry = await service.reverseEntry(ctx, req.params.id, reason); + res.json(entry); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== REPORTES ==================== + + /** + * GET /reports/trial-balance + * Obtiene balanza de comprobación + */ + router.get('/reports/trial-balance', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const fiscalYear = parseInt(req.query.fiscalYear as string) || new Date().getFullYear(); + const fiscalPeriod = req.query.fiscalPeriod ? parseInt(req.query.fiscalPeriod as string) : undefined; + + const result = await service.getTrialBalance(ctx, fiscalYear, fiscalPeriod); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /reports/account-ledger/:accountId + * Obtiene mayor de una cuenta + */ + router.get('/reports/account-ledger/:accountId', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const startDate = new Date(req.query.startDate as string); + const endDate = new Date(req.query.endDate as string); + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const result = await service.getAccountLedger(ctx, req.params.accountId, startDate, endDate); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/finance/controllers/ap.controller.ts b/src/modules/finance/controllers/ap.controller.ts new file mode 100644 index 0000000..e7356aa --- /dev/null +++ b/src/modules/finance/controllers/ap.controller.ts @@ -0,0 +1,264 @@ +/** + * APController - Controlador de Cuentas por Pagar + * + * Endpoints para cuentas por pagar y pagos a proveedores. + * + * @module Finance + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { APService } from '../services'; + +export function createAPController(dataSource: DataSource): Router { + const router = Router(); + const service = new APService(dataSource); + + // ==================== CUENTAS POR PAGAR ==================== + + /** + * GET / + * Lista cuentas por pagar con filtros + */ + router.get('/', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const options = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 20, + status: req.query.status as any, + partnerId: req.query.partnerId as string, + projectId: req.query.projectId as string, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + overdue: req.query.overdue === 'true', + search: req.query.search as string, + }; + + const result = await service.findAll(ctx, options); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /dashboard + * Obtiene estadísticas del dashboard + */ + router.get('/dashboard', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const stats = await service.getDashboardStats(ctx); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /aging + * Obtiene reporte de antigüedad + */ + router.get('/aging', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const options = { + partnerId: req.query.partnerId as string, + projectId: req.query.projectId as string, + asOfDate: req.query.asOfDate ? new Date(req.query.asOfDate as string) : undefined, + }; + + const report = await service.getAgingReport(ctx, options); + res.json(report); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /payment-schedule + * Obtiene calendario de pagos + */ + router.get('/payment-schedule', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const startDate = new Date(req.query.startDate as string); + const endDate = new Date(req.query.endDate as string); + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const options = { + partnerId: req.query.partnerId as string, + projectId: req.query.projectId as string, + }; + + const schedule = await service.getPaymentSchedule(ctx, startDate, endDate, options); + res.json(schedule); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /:id + * Obtiene una cuenta por pagar por ID + */ + router.get('/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const ap = await service.findById(ctx, req.params.id); + if (!ap) { + return res.status(404).json({ error: 'Cuenta por pagar no encontrada' }); + } + + res.json(ap); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * POST / + * Crea una nueva cuenta por pagar + */ + router.post('/', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const ap = await service.create(ctx, req.body); + res.status(201).json(ap); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * PUT /:id + * Actualiza una cuenta por pagar + */ + router.put('/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const ap = await service.update(ctx, req.params.id, req.body); + res.json(ap); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/cancel + * Cancela una cuenta por pagar + */ + router.post('/:id/cancel', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { reason } = req.body; + if (!reason) { + return res.status(400).json({ error: 'Se requiere motivo de cancelación' }); + } + + const ap = await service.cancel(ctx, req.params.id, reason); + res.json(ap); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== PAGOS ==================== + + /** + * POST /payments + * Registra un pago a proveedores + */ + router.post('/payments', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const payment = await service.createPayment(ctx, req.body); + res.status(201).json(payment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /payments/:id/confirm + * Confirma un pago + */ + router.post('/payments/:id/confirm', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const payment = await service.confirmPayment(ctx, req.params.id); + res.json(payment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /payments/:id/cancel + * Cancela un pago + */ + router.post('/payments/:id/cancel', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { reason } = req.body; + if (!reason) { + return res.status(400).json({ error: 'Se requiere motivo de cancelación' }); + } + + const payment = await service.cancelPayment(ctx, req.params.id, reason); + res.json(payment); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/finance/controllers/ar.controller.ts b/src/modules/finance/controllers/ar.controller.ts new file mode 100644 index 0000000..3679800 --- /dev/null +++ b/src/modules/finance/controllers/ar.controller.ts @@ -0,0 +1,310 @@ +/** + * ARController - Controlador de Cuentas por Cobrar + * + * Endpoints para cuentas por cobrar y cobranza. + * + * @module Finance + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { ARService } from '../services'; + +export function createARController(dataSource: DataSource): Router { + const router = Router(); + const service = new ARService(dataSource); + + // ==================== CUENTAS POR COBRAR ==================== + + /** + * GET / + * Lista cuentas por cobrar con filtros + */ + router.get('/', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const options = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 20, + status: req.query.status as any, + partnerId: req.query.partnerId as string, + projectId: req.query.projectId as string, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + overdue: req.query.overdue === 'true', + search: req.query.search as string, + }; + + const result = await service.findAll(ctx, options); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /dashboard + * Obtiene estadísticas del dashboard + */ + router.get('/dashboard', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const stats = await service.getDashboardStats(ctx); + res.json(stats); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /aging + * Obtiene reporte de antigüedad + */ + router.get('/aging', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const options = { + partnerId: req.query.partnerId as string, + projectId: req.query.projectId as string, + asOfDate: req.query.asOfDate ? new Date(req.query.asOfDate as string) : undefined, + }; + + const report = await service.getAgingReport(ctx, options); + res.json(report); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /collection-forecast + * Obtiene pronóstico de cobranza + */ + router.get('/collection-forecast', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const startDate = new Date(req.query.startDate as string); + const endDate = new Date(req.query.endDate as string); + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const options = { + partnerId: req.query.partnerId as string, + projectId: req.query.projectId as string, + }; + + const forecast = await service.getCollectionForecast(ctx, startDate, endDate, options); + res.json(forecast); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /:id + * Obtiene una cuenta por cobrar por ID + */ + router.get('/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const ar = await service.findById(ctx, req.params.id); + if (!ar) { + return res.status(404).json({ error: 'Cuenta por cobrar no encontrada' }); + } + + res.json(ar); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * POST / + * Crea una nueva cuenta por cobrar + */ + router.post('/', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const ar = await service.create(ctx, req.body); + res.status(201).json(ar); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * PUT /:id + * Actualiza una cuenta por cobrar + */ + router.put('/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const ar = await service.update(ctx, req.params.id, req.body); + res.json(ar); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/cancel + * Cancela una cuenta por cobrar + */ + router.post('/:id/cancel', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { reason } = req.body; + if (!reason) { + return res.status(400).json({ error: 'Se requiere motivo de cancelación' }); + } + + const ar = await service.cancel(ctx, req.params.id, reason); + res.json(ar); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/write-off + * Castiga una cuenta por cobrar + */ + router.post('/:id/write-off', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { reason } = req.body; + if (!reason) { + return res.status(400).json({ error: 'Se requiere motivo de castigo' }); + } + + const ar = await service.writeOff(ctx, req.params.id, reason); + res.json(ar); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/collection-attempt + * Registra intento de cobranza + */ + router.post('/:id/collection-attempt', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { notes } = req.body; + if (!notes) { + return res.status(400).json({ error: 'Se requieren notas de la gestión' }); + } + + const ar = await service.recordCollectionAttempt(ctx, req.params.id, notes); + res.json(ar); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== COBROS ==================== + + /** + * POST /collections + * Registra un cobro + */ + router.post('/collections', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const collection = await service.createCollection(ctx, req.body); + res.status(201).json(collection); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /collections/:id/confirm + * Confirma un cobro + */ + router.post('/collections/:id/confirm', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const collection = await service.confirmCollection(ctx, req.params.id); + res.json(collection); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /collections/:id/cancel + * Cancela un cobro + */ + router.post('/collections/:id/cancel', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { reason } = req.body; + if (!reason) { + return res.status(400).json({ error: 'Se requiere motivo de cancelación' }); + } + + const collection = await service.cancelCollection(ctx, req.params.id, reason); + res.json(collection); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/finance/controllers/bank-reconciliation.controller.ts b/src/modules/finance/controllers/bank-reconciliation.controller.ts new file mode 100644 index 0000000..ec4ad75 --- /dev/null +++ b/src/modules/finance/controllers/bank-reconciliation.controller.ts @@ -0,0 +1,434 @@ +/** + * BankReconciliationController - Controlador de Conciliación Bancaria + * + * Endpoints para cuentas bancarias, movimientos y conciliación. + * + * @module Finance + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { BankReconciliationService } from '../services'; + +export function createBankReconciliationController(dataSource: DataSource): Router { + const router = Router(); + const service = new BankReconciliationService(dataSource); + + // ==================== CUENTAS BANCARIAS ==================== + + /** + * GET /accounts + * Lista cuentas bancarias + */ + router.get('/accounts', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const options = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 20, + accountType: req.query.accountType as any, + status: req.query.status as any, + projectId: req.query.projectId as string, + search: req.query.search as string, + }; + + const result = await service.findAllAccounts(ctx, options); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /accounts/summary + * Obtiene resumen de saldos + */ + router.get('/accounts/summary', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const summary = await service.getBankAccountSummary(ctx); + res.json(summary); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /accounts/:id + * Obtiene una cuenta bancaria por ID + */ + router.get('/accounts/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const account = await service.findAccountById(ctx, req.params.id); + if (!account) { + return res.status(404).json({ error: 'Cuenta bancaria no encontrada' }); + } + + res.json(account); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * POST /accounts + * Crea una nueva cuenta bancaria + */ + router.post('/accounts', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const account = await service.createAccount(ctx, req.body); + res.status(201).json(account); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * PUT /accounts/:id + * Actualiza una cuenta bancaria + */ + router.put('/accounts/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const account = await service.updateAccount(ctx, req.params.id, req.body); + res.json(account); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * PUT /accounts/:id/balance + * Actualiza saldo de cuenta + */ + router.put('/accounts/:id/balance', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { currentBalance, availableBalance } = req.body; + const account = await service.updateAccountBalance( + ctx, + req.params.id, + currentBalance, + availableBalance + ); + res.json(account); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== MOVIMIENTOS ==================== + + /** + * GET /movements + * Lista movimientos bancarios + */ + router.get('/movements', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const options = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 50, + bankAccountId: req.query.bankAccountId as string, + movementType: req.query.movementType as any, + status: req.query.status as any, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + search: req.query.search as string, + }; + + const result = await service.findAllMovements(ctx, options); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /movements/:id + * Obtiene un movimiento por ID + */ + router.get('/movements/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const movement = await service.findMovementById(ctx, req.params.id); + if (!movement) { + return res.status(404).json({ error: 'Movimiento no encontrado' }); + } + + res.json(movement); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * POST /movements + * Crea un nuevo movimiento + */ + router.post('/movements', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const movement = await service.createMovement(ctx, req.body); + res.status(201).json(movement); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /movements/import + * Importa estado de cuenta + */ + router.post('/movements/import', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const result = await service.importBankStatement(ctx, req.body); + res.status(201).json(result); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /movements/:id/match + * Asocia movimiento con pago/cobro + */ + router.post('/movements/:id/match', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const movement = await service.matchMovement(ctx, req.params.id, req.body); + res.json(movement); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /movements/:id/ignore + * Marca movimiento como ignorado + */ + router.post('/movements/:id/ignore', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const movement = await service.ignoreMovement(ctx, req.params.id); + res.json(movement); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== CONCILIACIÓN ==================== + + /** + * GET /reconciliations + * Lista conciliaciones + */ + router.get('/reconciliations', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const options = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 20, + bankAccountId: req.query.bankAccountId as string, + status: req.query.status as any, + }; + + const result = await service.findAllReconciliations(ctx, options); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /reconciliations/status + * Obtiene estado general de conciliaciones + */ + router.get('/reconciliations/status', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const status = await service.getReconciliationStatus(ctx); + res.json(status); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /reconciliations/:id + * Obtiene una conciliación por ID + */ + router.get('/reconciliations/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const reconciliation = await service.findReconciliationById(ctx, req.params.id); + if (!reconciliation) { + return res.status(404).json({ error: 'Conciliación no encontrada' }); + } + + res.json(reconciliation); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * POST /reconciliations + * Crea una nueva conciliación + */ + router.post('/reconciliations', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const reconciliation = await service.createReconciliation(ctx, req.body); + res.status(201).json(reconciliation); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /reconciliations/:id/start + * Inicia proceso de conciliación + */ + router.post('/reconciliations/:id/start', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const reconciliation = await service.startReconciliation(ctx, req.params.id); + res.json(reconciliation); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /reconciliations/:id/reconcile-movement + * Concilia un movimiento + */ + router.post('/reconciliations/:id/reconcile-movement', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { movementId } = req.body; + if (!movementId) { + return res.status(400).json({ error: 'Se requiere ID del movimiento' }); + } + + const reconciliation = await service.reconcileMovement(ctx, req.params.id, movementId); + res.json(reconciliation); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /reconciliations/:id/complete + * Completa una conciliación + */ + router.post('/reconciliations/:id/complete', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const reconciliation = await service.completeReconciliation(ctx, req.params.id); + res.json(reconciliation); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /reconciliations/:id/approve + * Aprueba una conciliación + */ + router.post('/reconciliations/:id/approve', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const reconciliation = await service.approveReconciliation(ctx, req.params.id); + res.json(reconciliation); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/finance/controllers/cash-flow.controller.ts b/src/modules/finance/controllers/cash-flow.controller.ts new file mode 100644 index 0000000..5b72ab0 --- /dev/null +++ b/src/modules/finance/controllers/cash-flow.controller.ts @@ -0,0 +1,295 @@ +/** + * CashFlowController - Controlador de Flujo de Efectivo + * + * Endpoints para proyecciones y análisis de flujo de efectivo. + * + * @module Finance + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { CashFlowService } from '../services'; + +export function createCashFlowController(dataSource: DataSource): Router { + const router = Router(); + const service = new CashFlowService(dataSource); + + // ==================== PROYECCIONES ==================== + + /** + * GET / + * Lista proyecciones con filtros + */ + router.get('/', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const options = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 20, + flowType: req.query.flowType as any, + periodType: req.query.periodType as any, + projectId: req.query.projectId as string, + fiscalYear: req.query.fiscalYear ? parseInt(req.query.fiscalYear as string) : undefined, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const result = await service.findAll(ctx, options); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /dashboard + * Obtiene datos del dashboard + */ + router.get('/dashboard', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const data = await service.getDashboardData(ctx); + res.json(data); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /summary + * Obtiene resumen de flujo de efectivo + */ + router.get('/summary', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const startDate = new Date(req.query.startDate as string); + const endDate = new Date(req.query.endDate as string); + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const options = { + periodType: req.query.periodType as any, + flowType: req.query.flowType as any, + projectId: req.query.projectId as string, + }; + + const summary = await service.getCashFlowSummary(ctx, startDate, endDate, options); + res.json(summary); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /variance + * Obtiene análisis de varianza + */ + router.get('/variance', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const fiscalYear = parseInt(req.query.fiscalYear as string) || new Date().getFullYear(); + const options = { + projectId: req.query.projectId as string, + }; + + const analysis = await service.getVarianceAnalysis(ctx, fiscalYear, options); + res.json(analysis); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /:id + * Obtiene una proyección por ID + */ + router.get('/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const projection = await service.findById(ctx, req.params.id); + if (!projection) { + return res.status(404).json({ error: 'Proyección no encontrada' }); + } + + res.json(projection); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * POST / + * Crea una nueva proyección + */ + router.post('/', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const projection = await service.create(ctx, req.body); + res.status(201).json(projection); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * PUT /:id + * Actualiza una proyección + */ + router.put('/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const projection = await service.update(ctx, req.params.id, req.body); + res.json(projection); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/lock + * Bloquea una proyección + */ + router.post('/:id/lock', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const projection = await service.lock(ctx, req.params.id); + res.json(projection); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * DELETE /:id + * Elimina una proyección + */ + router.delete('/:id', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + await service.delete(ctx, req.params.id); + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ==================== GENERACIÓN AUTOMÁTICA ==================== + + /** + * POST /generate + * Genera proyección automática para un periodo + */ + router.post('/generate', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { periodStart, periodEnd, periodType, projectId, projectCode } = req.body; + + if (!periodStart || !periodEnd) { + return res.status(400).json({ error: 'Se requieren fechas de inicio y fin' }); + } + + const projection = await service.generateProjection( + ctx, + new Date(periodStart), + new Date(periodEnd), + { periodType, projectId, projectCode } + ); + + res.status(201).json(projection); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /generate-multiple + * Genera múltiples proyecciones (ej: 4 semanas) + */ + router.post('/generate-multiple', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { startDate, weeks, projectId, projectCode } = req.body; + + if (!startDate || !weeks) { + return res.status(400).json({ error: 'Se requiere fecha de inicio y número de semanas' }); + } + + const projections = await service.generateMultiplePeriods( + ctx, + new Date(startDate), + parseInt(weeks), + { projectId, projectCode } + ); + + res.status(201).json(projections); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * POST /:id/compare + * Crea comparación proyectado vs real + */ + router.post('/:id/compare', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const comparison = await service.createComparison(ctx, req.params.id); + res.status(201).json(comparison); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/finance/controllers/index.ts b/src/modules/finance/controllers/index.ts new file mode 100644 index 0000000..c4f6d86 --- /dev/null +++ b/src/modules/finance/controllers/index.ts @@ -0,0 +1,11 @@ +/** + * Finance Controllers Index + * @module Finance + */ + +export { createAccountingController } from './accounting.controller'; +export { createAPController } from './ap.controller'; +export { createARController } from './ar.controller'; +export { createCashFlowController } from './cash-flow.controller'; +export { createBankReconciliationController } from './bank-reconciliation.controller'; +export { createReportsController } from './reports.controller'; diff --git a/src/modules/finance/controllers/reports.controller.ts b/src/modules/finance/controllers/reports.controller.ts new file mode 100644 index 0000000..8208235 --- /dev/null +++ b/src/modules/finance/controllers/reports.controller.ts @@ -0,0 +1,410 @@ +/** + * ReportsController - Controlador de Reportes Financieros + * + * Endpoints para estados financieros y exportación. + * + * @module Finance + */ + +import { Router, Request, Response } from 'express'; +import { DataSource } from 'typeorm'; +import { FinancialReportsService, ERPIntegrationService } from '../services'; + +export function createReportsController(dataSource: DataSource): Router { + const router = Router(); + const reportsService = new FinancialReportsService(dataSource); + const integrationService = new ERPIntegrationService(dataSource); + + // ==================== ESTADOS FINANCIEROS ==================== + + /** + * GET /balance-sheet + * Genera balance general + */ + router.get('/balance-sheet', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const asOfDate = req.query.asOfDate + ? new Date(req.query.asOfDate as string) + : new Date(); + + const options = { + projectId: req.query.projectId as string, + }; + + const report = await reportsService.generateBalanceSheet(ctx, asOfDate, options); + res.json(report); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /income-statement + * Genera estado de resultados + */ + router.get('/income-statement', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const periodStart = new Date(req.query.periodStart as string); + const periodEnd = new Date(req.query.periodEnd as string); + + if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const options = { + projectId: req.query.projectId as string, + }; + + const report = await reportsService.generateIncomeStatement( + ctx, + periodStart, + periodEnd, + options + ); + res.json(report); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /cash-flow-statement + * Genera estado de flujo de efectivo + */ + router.get('/cash-flow-statement', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const periodStart = new Date(req.query.periodStart as string); + const periodEnd = new Date(req.query.periodEnd as string); + + if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const options = { + projectId: req.query.projectId as string, + }; + + const report = await reportsService.generateCashFlowStatement( + ctx, + periodStart, + periodEnd, + options + ); + res.json(report); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /trial-balance + * Genera balanza de comprobación + */ + router.get('/trial-balance', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const periodStart = new Date(req.query.periodStart as string); + const periodEnd = new Date(req.query.periodEnd as string); + + if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const options = { + projectId: req.query.projectId as string, + includeZeroBalances: req.query.includeZeroBalances === 'true', + }; + + const report = await reportsService.generateTrialBalance( + ctx, + periodStart, + periodEnd, + options + ); + res.json(report); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /account-statement/:accountId + * Genera estado de cuenta + */ + router.get('/account-statement/:accountId', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const periodStart = new Date(req.query.periodStart as string); + const periodEnd = new Date(req.query.periodEnd as string); + + if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const report = await reportsService.generateAccountStatement( + ctx, + req.params.accountId, + periodStart, + periodEnd + ); + res.json(report); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /summary + * Obtiene resumen financiero + */ + router.get('/summary', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const summary = await reportsService.getFinancialSummary(ctx); + res.json(summary); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // ==================== EXPORTACIÓN ==================== + + /** + * GET /export/sap + * Exporta pólizas para SAP + */ + router.get('/export/sap', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const periodStart = new Date(req.query.periodStart as string); + const periodEnd = new Date(req.query.periodEnd as string); + + if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const options = { + companyCode: req.query.companyCode as string, + documentType: req.query.documentType as string, + journalNumber: req.query.journalNumber + ? parseInt(req.query.journalNumber as string) + : undefined, + }; + + const result = await integrationService.exportToSAP(ctx, periodStart, periodEnd, options); + + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.send(result.data); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /export/contpaqi + * Exporta pólizas para CONTPAQi + */ + router.get('/export/contpaqi', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const periodStart = new Date(req.query.periodStart as string); + const periodEnd = new Date(req.query.periodEnd as string); + + if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const options = { + polizaTipo: req.query.polizaTipo + ? parseInt(req.query.polizaTipo as string) + : undefined, + diario: req.query.diario ? parseInt(req.query.diario as string) : undefined, + }; + + const result = await integrationService.exportToCONTPAQi( + ctx, + periodStart, + periodEnd, + options + ); + + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.send(result.data); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /export/cfdi-polizas + * Exporta pólizas en formato CFDI + */ + router.get('/export/cfdi-polizas', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const periodStart = new Date(req.query.periodStart as string); + const periodEnd = new Date(req.query.periodEnd as string); + + if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const options = { + tipoSolicitud: req.query.tipoSolicitud as string, + numOrden: req.query.numOrden as string, + numTramite: req.query.numTramite as string, + }; + + const result = await integrationService.exportCFDIPolizas( + ctx, + periodStart, + periodEnd, + options + ); + + res.setHeader('Content-Type', 'application/xml'); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.send(result.data); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /export/chart-of-accounts + * Exporta catálogo de cuentas + */ + router.get('/export/chart-of-accounts', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const format = (req.query.format as 'csv' | 'xml' | 'json') || 'csv'; + const result = await integrationService.exportChartOfAccounts(ctx, format); + + const contentType = + format === 'xml' + ? 'application/xml' + : format === 'json' + ? 'application/json' + : 'text/csv'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.send(typeof result.data === 'object' ? JSON.stringify(result.data, null, 2) : result.data); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * GET /export/trial-balance + * Exporta balanza de comprobación + */ + router.get('/export/trial-balance', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const periodStart = new Date(req.query.periodStart as string); + const periodEnd = new Date(req.query.periodEnd as string); + + if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) { + return res.status(400).json({ error: 'Fechas inválidas' }); + } + + const format = (req.query.format as 'csv' | 'xml' | 'json') || 'csv'; + const result = await integrationService.exportTrialBalance( + ctx, + periodStart, + periodEnd, + format + ); + + const contentType = + format === 'xml' + ? 'application/xml' + : format === 'json' + ? 'application/json' + : 'text/csv'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.send(typeof result.data === 'object' ? JSON.stringify(result.data, null, 2) : result.data); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + // ==================== IMPORTACIÓN ==================== + + /** + * POST /import/chart-of-accounts + * Importa catálogo de cuentas + */ + router.post('/import/chart-of-accounts', async (req: Request, res: Response) => { + try { + const ctx = { + tenantId: req.headers['x-tenant-id'] as string, + userId: (req as any).user?.id, + }; + + const { data, format } = req.body; + if (!data || !format) { + return res.status(400).json({ error: 'Se requieren datos y formato' }); + } + + const result = await integrationService.importChartOfAccounts(ctx, data, format); + res.json(result); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/finance/entities/account-payable.entity.ts b/src/modules/finance/entities/account-payable.entity.ts new file mode 100644 index 0000000..c1a6a99 --- /dev/null +++ b/src/modules/finance/entities/account-payable.entity.ts @@ -0,0 +1,233 @@ +/** + * AccountPayable Entity - Cuentas por Pagar + * + * Registro de obligaciones con proveedores y subcontratistas. + * + * @module Finance + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { APPayment } from './ap-payment.entity'; + +export type APStatus = 'pending' | 'partial' | 'paid' | 'overdue' | 'cancelled' | 'disputed'; +export type APDocumentType = 'invoice' | 'credit_note' | 'debit_note' | 'advance' | 'retention'; + +@Entity('accounts_payable', { schema: 'finance' }) +@Index(['tenantId', 'supplierId']) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'dueDate']) +@Index(['tenantId', 'projectId']) +export class AccountPayable { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Número de documento + @Column({ name: 'document_number', length: 100 }) + documentNumber!: string; + + @Column({ + name: 'document_type', + type: 'enum', + enum: ['invoice', 'credit_note', 'debit_note', 'advance', 'retention'], + enumName: 'ap_document_type', + default: 'invoice', + }) + documentType!: APDocumentType; + + // Proveedor + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId!: string; + + @Column({ name: 'supplier_name', length: 255 }) + supplierName!: string; + + @Column({ name: 'supplier_rfc', length: 13, nullable: true }) + supplierRfc?: string; + + // Estado + @Column({ + type: 'enum', + enum: ['pending', 'partial', 'paid', 'overdue', 'cancelled', 'disputed'], + enumName: 'ap_status', + default: 'pending', + }) + status!: APStatus; + + // Fechas + @Column({ name: 'invoice_date', type: 'date' }) + invoiceDate!: Date; + + @Column({ name: 'received_date', type: 'date', nullable: true }) + receivedDate?: Date; + + @Column({ name: 'due_date', type: 'date' }) + dueDate!: Date; + + @Column({ name: 'payment_date', type: 'date', nullable: true }) + paymentDate?: Date; + + // Montos + @Column({ + type: 'decimal', + precision: 18, + scale: 2, + }) + subtotal!: number; + + @Column({ + name: 'tax_amount', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + taxAmount!: number; + + @Column({ + name: 'retention_amount', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + retentionAmount!: number; + + @Column({ + name: 'total_amount', + type: 'decimal', + precision: 18, + scale: 2, + }) + totalAmount!: number; + + @Column({ + name: 'paid_amount', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + paidAmount!: number; + + @Column({ + name: 'balance', + type: 'decimal', + precision: 18, + scale: 2, + }) + balance!: number; + + // Moneda + @Column({ length: 3, default: 'MXN' }) + currency!: string; + + @Column({ + name: 'exchange_rate', + type: 'decimal', + precision: 12, + scale: 6, + default: 1, + }) + exchangeRate!: number; + + // Origen (orden de compra, contrato) + @Column({ name: 'source_module', length: 50, nullable: true }) + sourceModule?: string; + + @Column({ name: 'source_id', type: 'uuid', nullable: true }) + sourceId?: string; + + @Column({ name: 'purchase_order_number', length: 50, nullable: true }) + purchaseOrderNumber?: string; + + // Proyecto asociado + @Column({ name: 'project_id', type: 'uuid', nullable: true }) + projectId?: string; + + @Column({ name: 'project_code', length: 50, nullable: true }) + projectCode?: string; + + // Centro de costo + @Column({ name: 'cost_center_id', type: 'uuid', nullable: true }) + costCenterId?: string; + + // Cuenta contable de contrapartida + @Column({ name: 'expense_account_id', type: 'uuid', nullable: true }) + expenseAccountId?: string; + + // Condiciones de pago + @Column({ name: 'payment_terms', length: 100, nullable: true }) + paymentTerms?: string; + + @Column({ name: 'payment_days', type: 'int', default: 30 }) + paymentDays!: number; + + // Días de atraso (calculado) + @Column({ name: 'days_overdue', type: 'int', default: 0 }) + daysOverdue!: number; + + // CFDI (facturación electrónica México) + @Column({ name: 'cfdi_uuid', length: 36, nullable: true }) + cfdiUuid?: string; + + @Column({ name: 'cfdi_xml_path', length: 500, nullable: true }) + cfdiXmlPath?: string; + + @Column({ name: 'cfdi_pdf_path', length: 500, nullable: true }) + cfdiPdfPath?: string; + + // Aprobación + @Column({ name: 'approved_for_payment', type: 'boolean', default: false }) + approvedForPayment!: boolean; + + @Column({ name: 'approved_by_id', type: 'uuid', nullable: true }) + approvedById?: string; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt?: Date; + + // Póliza contable generada + @Column({ name: 'accounting_entry_id', type: 'uuid', nullable: true }) + accountingEntryId?: string; + + // Pagos asociados + @OneToMany(() => APPayment, (payment) => payment.accountPayable) + payments?: APPayment[]; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/finance/entities/account-receivable.entity.ts b/src/modules/finance/entities/account-receivable.entity.ts new file mode 100644 index 0000000..a76e5fa --- /dev/null +++ b/src/modules/finance/entities/account-receivable.entity.ts @@ -0,0 +1,224 @@ +/** + * AccountReceivable Entity - Cuentas por Cobrar + * + * Registro de derechos de cobro a clientes. + * + * @module Finance + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { ARPayment } from './ar-payment.entity'; + +export type ARStatus = 'pending' | 'partial' | 'collected' | 'overdue' | 'cancelled' | 'written_off'; +export type ARDocumentType = 'invoice' | 'credit_note' | 'debit_note' | 'advance' | 'estimation'; + +@Entity('accounts_receivable', { schema: 'finance' }) +@Index(['tenantId', 'customerId']) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'dueDate']) +@Index(['tenantId', 'projectId']) +export class AccountReceivable { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Número de documento + @Column({ name: 'document_number', length: 100 }) + documentNumber!: string; + + @Column({ + name: 'document_type', + type: 'enum', + enum: ['invoice', 'credit_note', 'debit_note', 'advance', 'estimation'], + enumName: 'ar_document_type', + default: 'invoice', + }) + documentType!: ARDocumentType; + + // Cliente + @Column({ name: 'customer_id', type: 'uuid' }) + customerId!: string; + + @Column({ name: 'customer_name', length: 255 }) + customerName!: string; + + @Column({ name: 'customer_rfc', length: 13, nullable: true }) + customerRfc?: string; + + // Estado + @Column({ + type: 'enum', + enum: ['pending', 'partial', 'collected', 'overdue', 'cancelled', 'written_off'], + enumName: 'ar_status', + default: 'pending', + }) + status!: ARStatus; + + // Fechas + @Column({ name: 'invoice_date', type: 'date' }) + invoiceDate!: Date; + + @Column({ name: 'due_date', type: 'date' }) + dueDate!: Date; + + @Column({ name: 'collection_date', type: 'date', nullable: true }) + collectionDate?: Date; + + // Montos + @Column({ + type: 'decimal', + precision: 18, + scale: 2, + }) + subtotal!: number; + + @Column({ + name: 'tax_amount', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + taxAmount!: number; + + @Column({ + name: 'retention_amount', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + retentionAmount!: number; + + @Column({ + name: 'total_amount', + type: 'decimal', + precision: 18, + scale: 2, + }) + totalAmount!: number; + + @Column({ + name: 'collected_amount', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + collectedAmount!: number; + + @Column({ + name: 'balance', + type: 'decimal', + precision: 18, + scale: 2, + }) + balance!: number; + + // Moneda + @Column({ length: 3, default: 'MXN' }) + currency!: string; + + @Column({ + name: 'exchange_rate', + type: 'decimal', + precision: 12, + scale: 6, + default: 1, + }) + exchangeRate!: number; + + // Origen (estimación, venta) + @Column({ name: 'source_module', length: 50, nullable: true }) + sourceModule?: string; + + @Column({ name: 'source_id', type: 'uuid', nullable: true }) + sourceId?: string; + + @Column({ name: 'estimation_number', length: 50, nullable: true }) + estimationNumber?: string; + + // Proyecto asociado + @Column({ name: 'project_id', type: 'uuid', nullable: true }) + projectId?: string; + + @Column({ name: 'project_code', length: 50, nullable: true }) + projectCode?: string; + + // Condiciones de cobro + @Column({ name: 'payment_terms', length: 100, nullable: true }) + paymentTerms?: string; + + @Column({ name: 'payment_days', type: 'int', default: 30 }) + paymentDays!: number; + + // Días de atraso (calculado) + @Column({ name: 'days_overdue', type: 'int', default: 0 }) + daysOverdue!: number; + + // CFDI (facturación electrónica México) + @Column({ name: 'cfdi_uuid', length: 36, nullable: true }) + cfdiUuid?: string; + + @Column({ name: 'cfdi_xml_path', length: 500, nullable: true }) + cfdiXmlPath?: string; + + @Column({ name: 'cfdi_pdf_path', length: 500, nullable: true }) + cfdiPdfPath?: string; + + // Cuenta contable + @Column({ name: 'income_account_id', type: 'uuid', nullable: true }) + incomeAccountId?: string; + + // Póliza contable generada + @Column({ name: 'accounting_entry_id', type: 'uuid', nullable: true }) + accountingEntryId?: string; + + // Seguimiento de cobranza + @Column({ name: 'last_collection_attempt', type: 'date', nullable: true }) + lastCollectionAttempt?: Date; + + @Column({ name: 'collection_attempts', type: 'int', default: 0 }) + collectionAttempts!: number; + + @Column({ name: 'collection_notes', type: 'text', nullable: true }) + collectionNotes?: string; + + // Cobros asociados + @OneToMany(() => ARPayment, (payment) => payment.accountReceivable) + payments?: ARPayment[]; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/finance/entities/accounting-entry-line.entity.ts b/src/modules/finance/entities/accounting-entry-line.entity.ts new file mode 100644 index 0000000..d45ca80 --- /dev/null +++ b/src/modules/finance/entities/accounting-entry-line.entity.ts @@ -0,0 +1,131 @@ +/** + * AccountingEntryLine Entity - Líneas de Póliza Contable + * + * Detalle de movimientos (debe/haber) de una póliza. + * + * @module Finance + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { AccountingEntry } from './accounting-entry.entity'; +import { ChartOfAccounts } from './chart-of-accounts.entity'; + +@Entity('accounting_entry_lines', { schema: 'finance' }) +@Index(['tenantId', 'entryId']) +@Index(['tenantId', 'accountId']) +export class AccountingEntryLine { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Referencia a la póliza + @Column({ name: 'entry_id', type: 'uuid' }) + entryId!: string; + + @ManyToOne(() => AccountingEntry, (entry) => entry.lines, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'entry_id' }) + entry?: AccountingEntry; + + // Número de línea + @Column({ name: 'line_number', type: 'int' }) + lineNumber!: number; + + // Cuenta contable + @Column({ name: 'account_id', type: 'uuid' }) + accountId!: string; + + @ManyToOne(() => ChartOfAccounts) + @JoinColumn({ name: 'account_id' }) + account?: ChartOfAccounts; + + @Column({ name: 'account_code', length: 50 }) + accountCode!: string; + + // Descripción de la línea + @Column({ type: 'text' }) + description!: string; + + // Montos + @Column({ + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + debit!: number; + + @Column({ + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + credit!: number; + + // Centro de costo (opcional) + @Column({ name: 'cost_center_id', type: 'uuid', nullable: true }) + costCenterId?: string; + + @Column({ name: 'cost_center_code', length: 50, nullable: true }) + costCenterCode?: string; + + // Proyecto (opcional) + @Column({ name: 'project_id', type: 'uuid', nullable: true }) + projectId?: string; + + @Column({ name: 'project_code', length: 50, nullable: true }) + projectCode?: string; + + // Tercero (proveedor/cliente) + @Column({ name: 'partner_id', type: 'uuid', nullable: true }) + partnerId?: string; + + @Column({ name: 'partner_name', length: 255, nullable: true }) + partnerName?: string; + + // Documento de referencia + @Column({ name: 'document_type', length: 50, nullable: true }) + documentType?: string; + + @Column({ name: 'document_number', length: 100, nullable: true }) + documentNumber?: string; + + // Moneda original (si es diferente) + @Column({ name: 'original_currency', length: 3, nullable: true }) + originalCurrency?: string; + + @Column({ + name: 'original_amount', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + originalAmount?: number; + + // Metadatos + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/modules/finance/entities/accounting-entry.entity.ts b/src/modules/finance/entities/accounting-entry.entity.ts new file mode 100644 index 0000000..17e43b9 --- /dev/null +++ b/src/modules/finance/entities/accounting-entry.entity.ts @@ -0,0 +1,185 @@ +/** + * AccountingEntry Entity - Pólizas Contables + * + * Registro de asientos contables con partida doble. + * + * @module Finance + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; +import { AccountingEntryLine } from './accounting-entry-line.entity'; + +export type EntryType = + | 'purchase' + | 'sale' + | 'payment' + | 'collection' + | 'payroll' + | 'adjustment' + | 'depreciation' + | 'transfer' + | 'opening' + | 'closing'; + +export type EntryStatus = 'draft' | 'pending_approval' | 'approved' | 'posted' | 'cancelled' | 'reversed'; + +@Entity('accounting_entries', { schema: 'finance' }) +@Index(['tenantId', 'entryNumber'], { unique: true }) +@Index(['tenantId', 'entryDate']) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'sourceModule', 'sourceId']) +export class AccountingEntry { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Número de póliza + @Column({ name: 'entry_number', length: 50 }) + entryNumber!: string; + + // Tipo y fecha + @Column({ + name: 'entry_type', + type: 'enum', + enum: ['purchase', 'sale', 'payment', 'collection', 'payroll', 'adjustment', 'depreciation', 'transfer', 'opening', 'closing'], + enumName: 'entry_type', + }) + entryType!: EntryType; + + @Column({ name: 'entry_date', type: 'date' }) + entryDate!: Date; + + @Column({ + type: 'enum', + enum: ['draft', 'pending_approval', 'approved', 'posted', 'cancelled', 'reversed'], + enumName: 'entry_status', + default: 'draft', + }) + status!: EntryStatus; + + // Descripción + @Column({ type: 'text' }) + description!: string; + + @Column({ length: 255, nullable: true }) + reference?: string; + + // Origen (módulo que generó la póliza) + @Column({ name: 'source_module', length: 50, nullable: true }) + sourceModule?: string; + + @Column({ name: 'source_id', type: 'uuid', nullable: true }) + sourceId?: string; + + // Proyecto asociado + @Column({ name: 'project_id', type: 'uuid', nullable: true }) + projectId?: string; + + // Periodo contable + @Column({ name: 'fiscal_year', type: 'int' }) + fiscalYear!: number; + + @Column({ name: 'fiscal_period', type: 'int' }) + fiscalPeriod!: number; + + // Totales (calculados) + @Column({ + name: 'total_debit', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + totalDebit!: number; + + @Column({ + name: 'total_credit', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + totalCredit!: number; + + @Column({ name: 'is_balanced', type: 'boolean', default: false }) + isBalanced!: boolean; + + // Moneda + @Column({ length: 3, default: 'MXN' }) + currency!: string; + + @Column({ + name: 'exchange_rate', + type: 'decimal', + precision: 12, + scale: 6, + default: 1, + }) + exchangeRate!: number; + + // Aprobación + @Column({ name: 'approved_by_id', type: 'uuid', nullable: true }) + approvedById?: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'approved_by_id' }) + approvedBy?: User; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt?: Date; + + // Contabilización + @Column({ name: 'posted_at', type: 'timestamptz', nullable: true }) + postedAt?: Date; + + @Column({ name: 'posted_by_id', type: 'uuid', nullable: true }) + postedById?: string; + + // Reversión + @Column({ name: 'reversed_entry_id', type: 'uuid', nullable: true }) + reversedEntryId?: string; + + @Column({ name: 'reversal_reason', type: 'text', nullable: true }) + reversalReason?: string; + + // Líneas de la póliza + @OneToMany(() => AccountingEntryLine, (line) => line.entry, { cascade: true }) + lines?: AccountingEntryLine[]; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/finance/entities/ap-payment.entity.ts b/src/modules/finance/entities/ap-payment.entity.ts new file mode 100644 index 0000000..0c3398f --- /dev/null +++ b/src/modules/finance/entities/ap-payment.entity.ts @@ -0,0 +1,154 @@ +/** + * APPayment Entity - Pagos a Proveedores + * + * Registro de pagos realizados a cuentas por pagar. + * + * @module Finance + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { AccountPayable } from './account-payable.entity'; +import { BankAccount } from './bank-account.entity'; + +export type PaymentMethod = 'cash' | 'check' | 'transfer' | 'card' | 'compensation' | 'other'; +export type PaymentStatus = 'pending' | 'processed' | 'reconciled' | 'cancelled' | 'returned'; + +@Entity('ap_payments', { schema: 'finance' }) +@Index(['tenantId', 'accountPayableId']) +@Index(['tenantId', 'paymentDate']) +@Index(['tenantId', 'bankAccountId']) +export class APPayment { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Número de pago + @Column({ name: 'payment_number', length: 50 }) + paymentNumber!: string; + + // Cuenta por pagar asociada + @Column({ name: 'account_payable_id', type: 'uuid' }) + accountPayableId!: string; + + @ManyToOne(() => AccountPayable, (ap) => ap.payments) + @JoinColumn({ name: 'account_payable_id' }) + accountPayable?: AccountPayable; + + // Método de pago + @Column({ + name: 'payment_method', + type: 'enum', + enum: ['cash', 'check', 'transfer', 'card', 'compensation', 'other'], + enumName: 'payment_method', + }) + paymentMethod!: PaymentMethod; + + @Column({ + type: 'enum', + enum: ['pending', 'processed', 'reconciled', 'cancelled', 'returned'], + enumName: 'payment_status', + default: 'pending', + }) + status!: PaymentStatus; + + // Fecha de pago + @Column({ name: 'payment_date', type: 'date' }) + paymentDate!: Date; + + // Monto + @Column({ + type: 'decimal', + precision: 18, + scale: 2, + }) + amount!: number; + + @Column({ length: 3, default: 'MXN' }) + currency!: string; + + @Column({ + name: 'exchange_rate', + type: 'decimal', + precision: 12, + scale: 6, + default: 1, + }) + exchangeRate!: number; + + // Cuenta bancaria de origen + @Column({ name: 'bank_account_id', type: 'uuid', nullable: true }) + bankAccountId?: string; + + @ManyToOne(() => BankAccount, { nullable: true }) + @JoinColumn({ name: 'bank_account_id' }) + bankAccount?: BankAccount; + + // Detalles del instrumento de pago + @Column({ name: 'check_number', length: 50, nullable: true }) + checkNumber?: string; + + @Column({ name: 'transfer_reference', length: 100, nullable: true }) + transferReference?: string; + + @Column({ name: 'authorization_code', length: 50, nullable: true }) + authorizationCode?: string; + + // Beneficiario + @Column({ name: 'beneficiary_name', length: 255, nullable: true }) + beneficiaryName?: string; + + @Column({ name: 'beneficiary_bank', length: 100, nullable: true }) + beneficiaryBank?: string; + + @Column({ name: 'beneficiary_account', length: 50, nullable: true }) + beneficiaryAccount?: string; + + @Column({ name: 'beneficiary_clabe', length: 18, nullable: true }) + beneficiaryClabe?: string; + + // Póliza contable generada + @Column({ name: 'accounting_entry_id', type: 'uuid', nullable: true }) + accountingEntryId?: string; + + // Conciliación bancaria + @Column({ name: 'bank_movement_id', type: 'uuid', nullable: true }) + bankMovementId?: string; + + @Column({ name: 'reconciled_at', type: 'timestamptz', nullable: true }) + reconciledAt?: Date; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/finance/entities/ar-payment.entity.ts b/src/modules/finance/entities/ar-payment.entity.ts new file mode 100644 index 0000000..8b26b47 --- /dev/null +++ b/src/modules/finance/entities/ar-payment.entity.ts @@ -0,0 +1,154 @@ +/** + * ARPayment Entity - Cobros de Clientes + * + * Registro de cobros recibidos de cuentas por cobrar. + * + * @module Finance + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { AccountReceivable } from './account-receivable.entity'; +import { BankAccount } from './bank-account.entity'; + +export type CollectionMethod = 'cash' | 'check' | 'transfer' | 'card' | 'compensation' | 'other'; +export type CollectionStatus = 'pending' | 'deposited' | 'reconciled' | 'cancelled' | 'returned'; + +@Entity('ar_payments', { schema: 'finance' }) +@Index(['tenantId', 'accountReceivableId']) +@Index(['tenantId', 'collectionDate']) +@Index(['tenantId', 'bankAccountId']) +export class ARPayment { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Número de cobro + @Column({ name: 'collection_number', length: 50 }) + collectionNumber!: string; + + // Cuenta por cobrar asociada + @Column({ name: 'account_receivable_id', type: 'uuid' }) + accountReceivableId!: string; + + @ManyToOne(() => AccountReceivable, (ar) => ar.payments) + @JoinColumn({ name: 'account_receivable_id' }) + accountReceivable?: AccountReceivable; + + // Método de cobro + @Column({ + name: 'collection_method', + type: 'enum', + enum: ['cash', 'check', 'transfer', 'card', 'compensation', 'other'], + enumName: 'collection_method', + }) + collectionMethod!: CollectionMethod; + + @Column({ + type: 'enum', + enum: ['pending', 'deposited', 'reconciled', 'cancelled', 'returned'], + enumName: 'collection_status', + default: 'pending', + }) + status!: CollectionStatus; + + // Fecha de cobro + @Column({ name: 'collection_date', type: 'date' }) + collectionDate!: Date; + + @Column({ name: 'deposit_date', type: 'date', nullable: true }) + depositDate?: Date; + + // Monto + @Column({ + type: 'decimal', + precision: 18, + scale: 2, + }) + amount!: number; + + @Column({ length: 3, default: 'MXN' }) + currency!: string; + + @Column({ + name: 'exchange_rate', + type: 'decimal', + precision: 12, + scale: 6, + default: 1, + }) + exchangeRate!: number; + + // Cuenta bancaria de destino + @Column({ name: 'bank_account_id', type: 'uuid', nullable: true }) + bankAccountId?: string; + + @ManyToOne(() => BankAccount, { nullable: true }) + @JoinColumn({ name: 'bank_account_id' }) + bankAccount?: BankAccount; + + // Detalles del instrumento de cobro + @Column({ name: 'check_number', length: 50, nullable: true }) + checkNumber?: string; + + @Column({ name: 'check_bank', length: 100, nullable: true }) + checkBank?: string; + + @Column({ name: 'transfer_reference', length: 100, nullable: true }) + transferReference?: string; + + // Pagador (si es diferente al cliente) + @Column({ name: 'payer_name', length: 255, nullable: true }) + payerName?: string; + + @Column({ name: 'payer_bank', length: 100, nullable: true }) + payerBank?: string; + + @Column({ name: 'payer_account', length: 50, nullable: true }) + payerAccount?: string; + + // Póliza contable generada + @Column({ name: 'accounting_entry_id', type: 'uuid', nullable: true }) + accountingEntryId?: string; + + // Conciliación bancaria + @Column({ name: 'bank_movement_id', type: 'uuid', nullable: true }) + bankMovementId?: string; + + @Column({ name: 'reconciled_at', type: 'timestamptz', nullable: true }) + reconciledAt?: Date; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/finance/entities/bank-account.entity.ts b/src/modules/finance/entities/bank-account.entity.ts new file mode 100644 index 0000000..95ff2a6 --- /dev/null +++ b/src/modules/finance/entities/bank-account.entity.ts @@ -0,0 +1,199 @@ +/** + * BankAccount Entity - Cuentas Bancarias + * + * Registro de cuentas bancarias de la empresa. + * + * @module Finance + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type BankAccountType = 'checking' | 'savings' | 'investment' | 'credit_line' | 'other'; +export type BankAccountStatus = 'active' | 'inactive' | 'blocked' | 'closed'; + +@Entity('bank_accounts', { schema: 'finance' }) +@Index(['tenantId', 'accountNumber'], { unique: true }) +@Index(['tenantId', 'status']) +export class BankAccount { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Información de la cuenta + @Column({ length: 100 }) + name!: string; + + @Column({ name: 'account_number', length: 50 }) + accountNumber!: string; + + @Column({ length: 18, nullable: true }) + clabe?: string; + + @Column({ + name: 'account_type', + type: 'enum', + enum: ['checking', 'savings', 'investment', 'credit_line', 'other'], + enumName: 'bank_account_type', + default: 'checking', + }) + accountType!: BankAccountType; + + @Column({ + type: 'enum', + enum: ['active', 'inactive', 'blocked', 'closed'], + enumName: 'bank_account_status', + default: 'active', + }) + status!: BankAccountStatus; + + // Banco + @Column({ name: 'bank_name', length: 100 }) + bankName!: string; + + @Column({ name: 'bank_code', length: 10, nullable: true }) + bankCode?: string; + + @Column({ name: 'branch_name', length: 100, nullable: true }) + branchName?: string; + + @Column({ name: 'branch_code', length: 20, nullable: true }) + branchCode?: string; + + // Moneda + @Column({ length: 3, default: 'MXN' }) + currency!: string; + + // Saldos + @Column({ + name: 'initial_balance', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + initialBalance!: number; + + @Column({ + name: 'current_balance', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + currentBalance!: number; + + @Column({ + name: 'available_balance', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + availableBalance!: number; + + @Column({ name: 'balance_updated_at', type: 'timestamptz', nullable: true }) + balanceUpdatedAt?: Date; + + // Límites (para líneas de crédito) + @Column({ + name: 'credit_limit', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + creditLimit?: number; + + @Column({ + name: 'minimum_balance', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + minimumBalance?: number; + + // Proyecto asociado (si es cuenta específica de proyecto) + @Column({ name: 'project_id', type: 'uuid', nullable: true }) + projectId?: string; + + @Column({ name: 'project_code', length: 50, nullable: true }) + projectCode?: string; + + // Cuenta contable vinculada + @Column({ name: 'ledger_account_id', type: 'uuid', nullable: true }) + ledgerAccountId?: string; + + // Contacto del banco + @Column({ name: 'bank_contact_name', length: 255, nullable: true }) + bankContactName?: string; + + @Column({ name: 'bank_contact_phone', length: 50, nullable: true }) + bankContactPhone?: string; + + @Column({ name: 'bank_contact_email', length: 255, nullable: true }) + bankContactEmail?: string; + + // Conciliación + @Column({ name: 'last_reconciliation_date', type: 'date', nullable: true }) + lastReconciliationDate?: Date; + + @Column({ + name: 'last_reconciled_balance', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + lastReconciledBalance?: number; + + // Acceso banca en línea + @Column({ name: 'online_banking_user', length: 100, nullable: true }) + onlineBankingUser?: string; + + @Column({ name: 'supports_api', type: 'boolean', default: false }) + supportsApi!: boolean; + + // Flags + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault!: boolean; + + @Column({ name: 'allows_payments', type: 'boolean', default: true }) + allowsPayments!: boolean; + + @Column({ name: 'allows_collections', type: 'boolean', default: true }) + allowsCollections!: boolean; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/finance/entities/bank-movement.entity.ts b/src/modules/finance/entities/bank-movement.entity.ts new file mode 100644 index 0000000..ff17b2c --- /dev/null +++ b/src/modules/finance/entities/bank-movement.entity.ts @@ -0,0 +1,189 @@ +/** + * BankMovement Entity - Movimientos Bancarios + * + * Registro de movimientos importados de estados de cuenta. + * + * @module Finance + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { BankAccount } from './bank-account.entity'; + +export type MovementType = 'debit' | 'credit'; +export type MovementStatus = 'pending' | 'matched' | 'reconciled' | 'unreconciled' | 'ignored'; +export type MovementSource = 'manual' | 'import_file' | 'api' | 'system'; + +@Entity('bank_movements', { schema: 'finance' }) +@Index(['tenantId', 'bankAccountId']) +@Index(['tenantId', 'movementDate']) +@Index(['tenantId', 'status']) +export class BankMovement { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Cuenta bancaria + @Column({ name: 'bank_account_id', type: 'uuid' }) + bankAccountId!: string; + + @ManyToOne(() => BankAccount) + @JoinColumn({ name: 'bank_account_id' }) + bankAccount?: BankAccount; + + // Referencia del movimiento + @Column({ name: 'movement_reference', length: 100, nullable: true }) + movementReference?: string; + + @Column({ name: 'bank_reference', length: 100, nullable: true }) + bankReference?: string; + + // Tipo y fecha + @Column({ + name: 'movement_type', + type: 'enum', + enum: ['debit', 'credit'], + enumName: 'bank_movement_type', + }) + movementType!: MovementType; + + @Column({ name: 'movement_date', type: 'date' }) + movementDate!: Date; + + @Column({ name: 'value_date', type: 'date', nullable: true }) + valueDate?: Date; + + // Descripción del banco + @Column({ type: 'text' }) + description!: string; + + @Column({ name: 'bank_description', type: 'text', nullable: true }) + bankDescription?: string; + + // Monto + @Column({ + type: 'decimal', + precision: 18, + scale: 2, + }) + amount!: number; + + @Column({ length: 3, default: 'MXN' }) + currency!: string; + + // Saldo después del movimiento + @Column({ + name: 'balance_after', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + balanceAfter?: number; + + // Estado de conciliación + @Column({ + type: 'enum', + enum: ['pending', 'matched', 'reconciled', 'unreconciled', 'ignored'], + enumName: 'bank_movement_status', + default: 'pending', + }) + status!: MovementStatus; + + // Origen del movimiento + @Column({ + type: 'enum', + enum: ['manual', 'import_file', 'api', 'system'], + enumName: 'bank_movement_source', + default: 'manual', + }) + source!: MovementSource; + + @Column({ name: 'import_batch_id', type: 'uuid', nullable: true }) + importBatchId?: string; + + // Coincidencia automática + @Column({ name: 'matched_payment_id', type: 'uuid', nullable: true }) + matchedPaymentId?: string; + + @Column({ name: 'matched_collection_id', type: 'uuid', nullable: true }) + matchedCollectionId?: string; + + @Column({ name: 'matched_entry_id', type: 'uuid', nullable: true }) + matchedEntryId?: string; + + @Column({ + name: 'match_confidence', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + }) + matchConfidence?: number; + + // Conciliación + @Column({ name: 'reconciliation_id', type: 'uuid', nullable: true }) + reconciliationId?: string; + + @Column({ name: 'reconciled_at', type: 'timestamptz', nullable: true }) + reconciledAt?: Date; + + @Column({ name: 'reconciled_by_id', type: 'uuid', nullable: true }) + reconciledById?: string; + + // Categorización + @Column({ name: 'category', length: 100, nullable: true }) + category?: string; + + @Column({ name: 'subcategory', length: 100, nullable: true }) + subcategory?: string; + + // Tercero identificado + @Column({ name: 'partner_id', type: 'uuid', nullable: true }) + partnerId?: string; + + @Column({ name: 'partner_name', length: 255, nullable: true }) + partnerName?: string; + + // Flags + @Column({ name: 'is_duplicate', type: 'boolean', default: false }) + isDuplicate!: boolean; + + @Column({ name: 'requires_review', type: 'boolean', default: false }) + requiresReview!: boolean; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Datos originales del banco + @Column({ name: 'raw_data', type: 'jsonb', nullable: true }) + rawData?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/modules/finance/entities/bank-reconciliation.entity.ts b/src/modules/finance/entities/bank-reconciliation.entity.ts new file mode 100644 index 0000000..80e7b90 --- /dev/null +++ b/src/modules/finance/entities/bank-reconciliation.entity.ts @@ -0,0 +1,225 @@ +/** + * BankReconciliation Entity - Conciliaciones Bancarias + * + * Registro de procesos de conciliación bancaria. + * + * @module Finance + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../core/entities/user.entity'; +import { BankAccount } from './bank-account.entity'; + +export type ReconciliationStatus = 'draft' | 'in_progress' | 'completed' | 'approved' | 'cancelled'; + +@Entity('bank_reconciliations', { schema: 'finance' }) +@Index(['tenantId', 'bankAccountId']) +@Index(['tenantId', 'periodEnd']) +@Index(['tenantId', 'status']) +export class BankReconciliation { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Cuenta bancaria + @Column({ name: 'bank_account_id', type: 'uuid' }) + bankAccountId!: string; + + @ManyToOne(() => BankAccount) + @JoinColumn({ name: 'bank_account_id' }) + bankAccount?: BankAccount; + + // Periodo de conciliación + @Column({ name: 'period_start', type: 'date' }) + periodStart!: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd!: Date; + + // Estado + @Column({ + type: 'enum', + enum: ['draft', 'in_progress', 'completed', 'approved', 'cancelled'], + enumName: 'reconciliation_status', + default: 'draft', + }) + status!: ReconciliationStatus; + + // Saldos según banco + @Column({ + name: 'bank_opening_balance', + type: 'decimal', + precision: 18, + scale: 2, + }) + bankOpeningBalance!: number; + + @Column({ + name: 'bank_closing_balance', + type: 'decimal', + precision: 18, + scale: 2, + }) + bankClosingBalance!: number; + + // Saldos según libros + @Column({ + name: 'book_opening_balance', + type: 'decimal', + precision: 18, + scale: 2, + }) + bookOpeningBalance!: number; + + @Column({ + name: 'book_closing_balance', + type: 'decimal', + precision: 18, + scale: 2, + }) + bookClosingBalance!: number; + + // Partidas de conciliación + @Column({ + name: 'deposits_in_transit', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + depositsInTransit!: number; + + @Column({ + name: 'checks_in_transit', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + checksInTransit!: number; + + @Column({ + name: 'bank_charges_not_recorded', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + bankChargesNotRecorded!: number; + + @Column({ + name: 'interest_not_recorded', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + interestNotRecorded!: number; + + @Column({ + name: 'other_adjustments', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + otherAdjustments!: number; + + // Saldo conciliado + @Column({ + name: 'reconciled_balance', + type: 'decimal', + precision: 18, + scale: 2, + }) + reconciledBalance!: number; + + // Diferencia (debe ser 0 si está conciliado) + @Column({ + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + difference!: number; + + @Column({ name: 'is_balanced', type: 'boolean', default: false }) + isBalanced!: boolean; + + // Contadores + @Column({ name: 'total_movements', type: 'int', default: 0 }) + totalMovements!: number; + + @Column({ name: 'reconciled_movements', type: 'int', default: 0 }) + reconciledMovements!: number; + + @Column({ name: 'pending_movements', type: 'int', default: 0 }) + pendingMovements!: number; + + // Estado de cuenta bancario (archivo importado) + @Column({ name: 'statement_file_path', length: 500, nullable: true }) + statementFilePath?: string; + + @Column({ name: 'statement_import_date', type: 'timestamptz', nullable: true }) + statementImportDate?: Date; + + // Fechas de proceso + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt?: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date; + + // Aprobación + @Column({ name: 'approved_by_id', type: 'uuid', nullable: true }) + approvedById?: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'approved_by_id' }) + approvedBy?: User; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt?: Date; + + // Póliza de ajuste generada + @Column({ name: 'adjustment_entry_id', type: 'uuid', nullable: true }) + adjustmentEntryId?: string; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'reconciliation_items', type: 'jsonb', nullable: true }) + reconciliationItems?: Record[]; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/finance/entities/cash-flow-projection.entity.ts b/src/modules/finance/entities/cash-flow-projection.entity.ts new file mode 100644 index 0000000..e8e8b19 --- /dev/null +++ b/src/modules/finance/entities/cash-flow-projection.entity.ts @@ -0,0 +1,357 @@ +/** + * CashFlowProjection Entity - Proyecciones de Flujo de Efectivo + * + * Registro de proyecciones y flujo real por periodo. + * + * @module Finance + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type CashFlowType = 'projected' | 'actual' | 'comparison'; +export type CashFlowPeriodType = 'daily' | 'weekly' | 'monthly' | 'quarterly'; +export type CashFlowCategory = + | 'operating_income' + | 'operating_expense' + | 'investing_income' + | 'investing_expense' + | 'financing_income' + | 'financing_expense'; + +@Entity('cash_flow_projections', { schema: 'finance' }) +@Index(['tenantId', 'projectId']) +@Index(['tenantId', 'periodStart', 'periodEnd']) +@Index(['tenantId', 'flowType']) +export class CashFlowProjection { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Tipo de flujo + @Column({ + name: 'flow_type', + type: 'enum', + enum: ['projected', 'actual', 'comparison'], + enumName: 'cash_flow_type', + }) + flowType!: CashFlowType; + + @Column({ + name: 'period_type', + type: 'enum', + enum: ['daily', 'weekly', 'monthly', 'quarterly'], + enumName: 'cash_flow_period_type', + default: 'weekly', + }) + periodType!: CashFlowPeriodType; + + // Periodo + @Column({ name: 'period_start', type: 'date' }) + periodStart!: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd!: Date; + + @Column({ name: 'fiscal_year', type: 'int' }) + fiscalYear!: number; + + @Column({ name: 'fiscal_period', type: 'int' }) + fiscalPeriod!: number; + + // Proyecto (opcional, si es por proyecto) + @Column({ name: 'project_id', type: 'uuid', nullable: true }) + projectId?: string; + + @Column({ name: 'project_code', length: 50, nullable: true }) + projectCode?: string; + + // Saldo inicial + @Column({ + name: 'opening_balance', + type: 'decimal', + precision: 18, + scale: 2, + }) + openingBalance!: number; + + // INGRESOS OPERATIVOS + @Column({ + name: 'income_estimations', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + incomeEstimations!: number; + + @Column({ + name: 'income_sales', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + incomeSales!: number; + + @Column({ + name: 'income_advances', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + incomeAdvances!: number; + + @Column({ + name: 'income_other', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + incomeOther!: number; + + @Column({ + name: 'total_income', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + totalIncome!: number; + + // EGRESOS OPERATIVOS + @Column({ + name: 'expense_suppliers', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + expenseSuppliers!: number; + + @Column({ + name: 'expense_subcontractors', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + expenseSubcontractors!: number; + + @Column({ + name: 'expense_payroll', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + expensePayroll!: number; + + @Column({ + name: 'expense_taxes', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + expenseTaxes!: number; + + @Column({ + name: 'expense_operating', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + expenseOperating!: number; + + @Column({ + name: 'expense_other', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + expenseOther!: number; + + @Column({ + name: 'total_expenses', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + totalExpenses!: number; + + // FLUJO NETO OPERATIVO + @Column({ + name: 'net_operating_flow', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + netOperatingFlow!: number; + + // INVERSIÓN + @Column({ + name: 'investing_income', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + investingIncome!: number; + + @Column({ + name: 'investing_expense', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + investingExpense!: number; + + @Column({ + name: 'net_investing_flow', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + netInvestingFlow!: number; + + // FINANCIAMIENTO + @Column({ + name: 'financing_income', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + financingIncome!: number; + + @Column({ + name: 'financing_expense', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + financingExpense!: number; + + @Column({ + name: 'net_financing_flow', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + netFinancingFlow!: number; + + // TOTALES + @Column({ + name: 'net_cash_flow', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + netCashFlow!: number; + + @Column({ + name: 'closing_balance', + type: 'decimal', + precision: 18, + scale: 2, + }) + closingBalance!: number; + + // Varianza (para comparaciones) + @Column({ + name: 'projected_amount', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + projectedAmount?: number; + + @Column({ + name: 'actual_amount', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + actualAmount?: number; + + @Column({ + name: 'variance_amount', + type: 'decimal', + precision: 18, + scale: 2, + nullable: true, + }) + varianceAmount?: number; + + @Column({ + name: 'variance_percentage', + type: 'decimal', + precision: 8, + scale: 2, + nullable: true, + }) + variancePercentage?: number; + + // Desglose detallado (JSON) + @Column({ name: 'income_breakdown', type: 'jsonb', nullable: true }) + incomeBreakdown?: Record[]; + + @Column({ name: 'expense_breakdown', type: 'jsonb', nullable: true }) + expenseBreakdown?: Record[]; + + // Estado + @Column({ name: 'is_locked', type: 'boolean', default: false }) + isLocked!: boolean; + + @Column({ name: 'locked_at', type: 'timestamptz', nullable: true }) + lockedAt?: Date; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'variance_notes', type: 'text', nullable: true }) + varianceNotes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/finance/entities/chart-of-accounts.entity.ts b/src/modules/finance/entities/chart-of-accounts.entity.ts new file mode 100644 index 0000000..5718f13 --- /dev/null +++ b/src/modules/finance/entities/chart-of-accounts.entity.ts @@ -0,0 +1,151 @@ +/** + * ChartOfAccounts Entity - Catálogo de Cuentas Contables + * + * Plan de cuentas configurable por proyecto/empresa. + * + * @module Finance + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, + Tree, + TreeChildren, + TreeParent, +} from 'typeorm'; + +export type AccountType = 'asset' | 'liability' | 'equity' | 'income' | 'expense'; +export type AccountNature = 'debit' | 'credit'; +export type AccountStatus = 'active' | 'inactive' | 'blocked'; + +@Entity('chart_of_accounts', { schema: 'finance' }) +@Tree('closure-table') +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId', 'accountType']) +export class ChartOfAccounts { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId!: string; + + // Código jerárquico de cuenta + @Column({ length: 50 }) + code!: string; + + @Column({ length: 255 }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + // Tipo y naturaleza + @Column({ + name: 'account_type', + type: 'enum', + enum: ['asset', 'liability', 'equity', 'income', 'expense'], + enumName: 'account_type', + }) + accountType!: AccountType; + + @Column({ + type: 'enum', + enum: ['debit', 'credit'], + enumName: 'account_nature', + }) + nature!: AccountNature; + + @Column({ + type: 'enum', + enum: ['active', 'inactive', 'blocked'], + enumName: 'account_status', + default: 'active', + }) + status!: AccountStatus; + + // Jerarquía + @Column({ type: 'int', default: 1 }) + level!: number; + + @TreeParent() + parent?: ChartOfAccounts; + + @TreeChildren() + children?: ChartOfAccounts[]; + + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId?: string; + + // Configuración de imputación + @Column({ name: 'cost_center_required', type: 'boolean', default: false }) + costCenterRequired!: boolean; + + @Column({ name: 'project_required', type: 'boolean', default: false }) + projectRequired!: boolean; + + @Column({ name: 'allows_direct_posting', type: 'boolean', default: true }) + allowsDirectPosting!: boolean; + + // Códigos de integración con ERPs externos + @Column({ name: 'sap_code', length: 50, nullable: true }) + sapCode?: string; + + @Column({ name: 'contpaqi_code', length: 50, nullable: true }) + contpaqiCode?: string; + + @Column({ name: 'aspel_code', length: 50, nullable: true }) + aspelCode?: string; + + // Saldos (actualizados periódicamente) + @Column({ + name: 'initial_balance', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + initialBalance!: number; + + @Column({ + name: 'current_balance', + type: 'decimal', + precision: 18, + scale: 2, + default: 0, + }) + currentBalance!: number; + + @Column({ name: 'balance_updated_at', type: 'timestamptz', nullable: true }) + balanceUpdatedAt?: Date; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoría + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} diff --git a/src/modules/finance/entities/index.ts b/src/modules/finance/entities/index.ts new file mode 100644 index 0000000..40f6cf7 --- /dev/null +++ b/src/modules/finance/entities/index.ts @@ -0,0 +1,16 @@ +/** + * Finance Entities Index + * @module Finance + */ + +export { ChartOfAccounts, AccountType, AccountNature, AccountStatus } from './chart-of-accounts.entity'; +export { AccountingEntry, EntryType, EntryStatus } from './accounting-entry.entity'; +export { AccountingEntryLine } from './accounting-entry-line.entity'; +export { AccountPayable, APStatus, APDocumentType } from './account-payable.entity'; +export { APPayment, PaymentMethod, PaymentStatus } from './ap-payment.entity'; +export { AccountReceivable, ARStatus, ARDocumentType } from './account-receivable.entity'; +export { ARPayment, CollectionMethod, CollectionStatus } from './ar-payment.entity'; +export { BankAccount, BankAccountType, BankAccountStatus } from './bank-account.entity'; +export { BankMovement, MovementType, MovementStatus, MovementSource } from './bank-movement.entity'; +export { BankReconciliation, ReconciliationStatus } from './bank-reconciliation.entity'; +export { CashFlowProjection, CashFlowType, CashFlowPeriodType, CashFlowCategory } from './cash-flow-projection.entity'; diff --git a/src/modules/finance/index.ts b/src/modules/finance/index.ts new file mode 100644 index 0000000..45dda80 --- /dev/null +++ b/src/modules/finance/index.ts @@ -0,0 +1,13 @@ +/** + * Finance Module Index + * @module Finance + */ + +// Entities +export * from './entities'; + +// Services +export * from './services'; + +// Controllers +export * from './controllers'; diff --git a/src/modules/finance/services/accounting.service.ts b/src/modules/finance/services/accounting.service.ts new file mode 100644 index 0000000..65669a6 --- /dev/null +++ b/src/modules/finance/services/accounting.service.ts @@ -0,0 +1,813 @@ +/** + * AccountingService - Servicio de Contabilidad + * + * Gestión del catálogo de cuentas y pólizas contables. + * + * @module Finance + */ + +import { DataSource, Repository, IsNull, Not, In } from 'typeorm'; +import { + ChartOfAccounts, + AccountType, + AccountNature, + AccountStatus, + AccountingEntry, + EntryType, + EntryStatus, + AccountingEntryLine, +} from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +interface CreateAccountDto { + code: string; + name: string; + accountType: AccountType; + nature: AccountNature; + parentId?: string; + description?: string; + level?: number; + isGroupAccount?: boolean; + acceptsMovements?: boolean; + satCode?: string; + satDescription?: string; + currencyCode?: string; + metadata?: Record; +} + +interface UpdateAccountDto { + name?: string; + description?: string; + status?: AccountStatus; + acceptsMovements?: boolean; + satCode?: string; + satDescription?: string; + metadata?: Record; +} + +interface CreateEntryDto { + entryType: EntryType; + entryDate: Date; + reference?: string; + description: string; + projectId?: string; + projectCode?: string; + costCenterId?: string; + fiscalYear: number; + fiscalPeriod: number; + currencyCode?: string; + exchangeRate?: number; + sourceDocument?: string; + sourceDocumentId?: string; + notes?: string; + lines: CreateEntryLineDto[]; +} + +interface CreateEntryLineDto { + accountId: string; + accountCode: string; + description?: string; + debitAmount?: number; + creditAmount?: number; + currencyAmount?: number; + currencyCode?: string; + exchangeRate?: number; + costCenterId?: string; + projectId?: string; + partnerId?: string; + reference?: string; + metadata?: Record; +} + +interface AccountWithChildren extends ChartOfAccounts { + children?: AccountWithChildren[]; +} + +export class AccountingService { + private accountRepository: Repository; + private entryRepository: Repository; + private lineRepository: Repository; + + constructor(private dataSource: DataSource) { + this.accountRepository = dataSource.getRepository(ChartOfAccounts); + this.entryRepository = dataSource.getRepository(AccountingEntry); + this.lineRepository = dataSource.getRepository(AccountingEntryLine); + } + + // ==================== CATÁLOGO DE CUENTAS ==================== + + async findAllAccounts( + ctx: ServiceContext, + options: { + page?: number; + limit?: number; + accountType?: AccountType; + status?: AccountStatus; + parentId?: string; + search?: string; + acceptsMovements?: boolean; + } = {} + ): Promise> { + const { page = 1, limit = 50, accountType, status, parentId, search, acceptsMovements } = options; + + const queryBuilder = this.accountRepository + .createQueryBuilder('account') + .where('account.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('account.deletedAt IS NULL'); + + if (accountType) { + queryBuilder.andWhere('account.accountType = :accountType', { accountType }); + } + + if (status) { + queryBuilder.andWhere('account.status = :status', { status }); + } + + if (parentId !== undefined) { + if (parentId === null || parentId === '') { + queryBuilder.andWhere('account.parentId IS NULL'); + } else { + queryBuilder.andWhere('account.parentId = :parentId', { parentId }); + } + } + + if (search) { + queryBuilder.andWhere( + '(account.code ILIKE :search OR account.name ILIKE :search)', + { search: `%${search}%` } + ); + } + + if (acceptsMovements !== undefined) { + queryBuilder.andWhere('account.acceptsMovements = :acceptsMovements', { acceptsMovements }); + } + + queryBuilder.orderBy('account.code', 'ASC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async getAccountTree(ctx: ServiceContext): Promise { + const accounts = await this.accountRepository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + order: { code: 'ASC' }, + }); + + return this.buildTree(accounts, null); + } + + private buildTree( + items: ChartOfAccounts[], + parentId: string | null + ): AccountWithChildren[] { + const result: AccountWithChildren[] = []; + + for (const item of items) { + if (item.parentId === parentId) { + const children = this.buildTree(items, item.id); + const node: AccountWithChildren = { ...item }; + if (children.length > 0) { + node.children = children; + } + result.push(node); + } + } + + return result; + } + + async findAccountById( + ctx: ServiceContext, + id: string + ): Promise { + return this.accountRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['parent'], + }); + } + + async findAccountByCode( + ctx: ServiceContext, + code: string + ): Promise { + return this.accountRepository.findOne({ + where: { + code, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + } + + async createAccount( + ctx: ServiceContext, + data: CreateAccountDto + ): Promise { + // Verificar código único + const existing = await this.findAccountByCode(ctx, data.code); + if (existing) { + throw new Error(`Ya existe una cuenta con el código ${data.code}`); + } + + // Si tiene padre, verificar que existe + let parentAccount: ChartOfAccounts | null = null; + if (data.parentId) { + parentAccount = await this.findAccountById(ctx, data.parentId); + if (!parentAccount) { + throw new Error('Cuenta padre no encontrada'); + } + } + + // Determinar nivel + const level = data.level ?? (parentAccount ? parentAccount.level + 1 : 1); + + // Generar path completo + let fullPath = data.code; + if (parentAccount) { + fullPath = `${parentAccount.fullPath}/${data.code}`; + } + + const account = this.accountRepository.create({ + tenantId: ctx.tenantId, + code: data.code, + name: data.name, + description: data.description, + accountType: data.accountType, + nature: data.nature, + level, + parentId: data.parentId, + fullPath, + isGroupAccount: data.isGroupAccount ?? false, + acceptsMovements: data.acceptsMovements ?? true, + status: 'active', + satCode: data.satCode, + satDescription: data.satDescription, + currencyCode: data.currencyCode ?? 'MXN', + balance: 0, + metadata: data.metadata, + createdBy: ctx.userId, + }); + + return this.accountRepository.save(account); + } + + async updateAccount( + ctx: ServiceContext, + id: string, + data: UpdateAccountDto + ): Promise { + const account = await this.findAccountById(ctx, id); + if (!account) { + throw new Error('Cuenta no encontrada'); + } + + Object.assign(account, { + ...data, + updatedBy: ctx.userId, + }); + + return this.accountRepository.save(account); + } + + async deleteAccount(ctx: ServiceContext, id: string): Promise { + const account = await this.findAccountById(ctx, id); + if (!account) { + throw new Error('Cuenta no encontrada'); + } + + // Verificar que no tenga hijos + const children = await this.accountRepository.count({ + where: { + tenantId: ctx.tenantId, + parentId: id, + deletedAt: IsNull(), + }, + }); + + if (children > 0) { + throw new Error('No se puede eliminar una cuenta con subcuentas'); + } + + // Verificar que no tenga movimientos + const movements = await this.lineRepository.count({ + where: { + accountId: id, + }, + }); + + if (movements > 0) { + throw new Error('No se puede eliminar una cuenta con movimientos'); + } + + account.deletedAt = new Date(); + account.updatedBy = ctx.userId; + await this.accountRepository.save(account); + } + + // ==================== PÓLIZAS CONTABLES ==================== + + async findAllEntries( + ctx: ServiceContext, + options: { + page?: number; + limit?: number; + entryType?: EntryType; + status?: EntryStatus; + startDate?: Date; + endDate?: Date; + projectId?: string; + fiscalYear?: number; + fiscalPeriod?: number; + search?: string; + } = {} + ): Promise> { + const { + page = 1, + limit = 20, + entryType, + status, + startDate, + endDate, + projectId, + fiscalYear, + fiscalPeriod, + search, + } = options; + + const queryBuilder = this.entryRepository + .createQueryBuilder('entry') + .leftJoinAndSelect('entry.lines', 'lines') + .where('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('entry.deletedAt IS NULL'); + + if (entryType) { + queryBuilder.andWhere('entry.entryType = :entryType', { entryType }); + } + + if (status) { + queryBuilder.andWhere('entry.status = :status', { status }); + } + + if (startDate) { + queryBuilder.andWhere('entry.entryDate >= :startDate', { startDate }); + } + + if (endDate) { + queryBuilder.andWhere('entry.entryDate <= :endDate', { endDate }); + } + + if (projectId) { + queryBuilder.andWhere('entry.projectId = :projectId', { projectId }); + } + + if (fiscalYear) { + queryBuilder.andWhere('entry.fiscalYear = :fiscalYear', { fiscalYear }); + } + + if (fiscalPeriod) { + queryBuilder.andWhere('entry.fiscalPeriod = :fiscalPeriod', { fiscalPeriod }); + } + + if (search) { + queryBuilder.andWhere( + '(entry.entryNumber ILIKE :search OR entry.description ILIKE :search OR entry.reference ILIKE :search)', + { search: `%${search}%` } + ); + } + + queryBuilder.orderBy('entry.entryDate', 'DESC').addOrderBy('entry.entryNumber', 'DESC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findEntryById( + ctx: ServiceContext, + id: string + ): Promise { + return this.entryRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['lines', 'approvedBy', 'postedBy'], + }); + } + + async createEntry( + ctx: ServiceContext, + data: CreateEntryDto + ): Promise { + // Validar que la póliza cuadre + let totalDebit = 0; + let totalCredit = 0; + + for (const line of data.lines) { + totalDebit += line.debitAmount ?? 0; + totalCredit += line.creditAmount ?? 0; + } + + if (Math.abs(totalDebit - totalCredit) > 0.01) { + throw new Error( + `La póliza no cuadra. Débitos: ${totalDebit}, Créditos: ${totalCredit}` + ); + } + + // Generar número de póliza + const entryNumber = await this.generateEntryNumber( + ctx, + data.entryType, + data.fiscalYear, + data.fiscalPeriod + ); + + // Crear póliza + const entry = this.entryRepository.create({ + tenantId: ctx.tenantId, + entryNumber, + entryType: data.entryType, + entryDate: data.entryDate, + reference: data.reference, + description: data.description, + projectId: data.projectId, + projectCode: data.projectCode, + costCenterId: data.costCenterId, + fiscalYear: data.fiscalYear, + fiscalPeriod: data.fiscalPeriod, + currencyCode: data.currencyCode ?? 'MXN', + exchangeRate: data.exchangeRate ?? 1, + totalDebit, + totalCredit, + lineCount: data.lines.length, + sourceDocument: data.sourceDocument, + sourceDocumentId: data.sourceDocumentId, + notes: data.notes, + status: 'draft', + createdBy: ctx.userId, + }); + + const savedEntry = await this.entryRepository.save(entry); + + // Crear líneas + const lines = data.lines.map((lineData, index) => + this.lineRepository.create({ + entryId: savedEntry.id, + lineNumber: index + 1, + accountId: lineData.accountId, + accountCode: lineData.accountCode, + description: lineData.description, + debitAmount: lineData.debitAmount ?? 0, + creditAmount: lineData.creditAmount ?? 0, + currencyAmount: lineData.currencyAmount, + currencyCode: lineData.currencyCode, + exchangeRate: lineData.exchangeRate, + costCenterId: lineData.costCenterId, + projectId: lineData.projectId, + partnerId: lineData.partnerId, + reference: lineData.reference, + metadata: lineData.metadata, + }) + ); + + await this.lineRepository.save(lines); + + return this.findEntryById(ctx, savedEntry.id) as Promise; + } + + private async generateEntryNumber( + ctx: ServiceContext, + entryType: EntryType, + fiscalYear: number, + fiscalPeriod: number + ): Promise { + const typePrefix: Record = { + purchase: 'PC', + sale: 'VT', + payment: 'PG', + collection: 'CB', + payroll: 'NM', + adjustment: 'AJ', + depreciation: 'DP', + transfer: 'TR', + opening: 'AP', + closing: 'CI', + }; + + const prefix = typePrefix[entryType] || 'PL'; + + const lastEntry = await this.entryRepository + .createQueryBuilder('entry') + .where('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('entry.fiscalYear = :fiscalYear', { fiscalYear }) + .andWhere('entry.fiscalPeriod = :fiscalPeriod', { fiscalPeriod }) + .andWhere('entry.entryType = :entryType', { entryType }) + .orderBy('entry.entryNumber', 'DESC') + .getOne(); + + let sequence = 1; + if (lastEntry) { + const match = lastEntry.entryNumber.match(/(\d+)$/); + if (match) { + sequence = parseInt(match[1], 10) + 1; + } + } + + return `${prefix}-${fiscalYear}${String(fiscalPeriod).padStart(2, '0')}-${String(sequence).padStart(5, '0')}`; + } + + async submitForApproval(ctx: ServiceContext, id: string): Promise { + const entry = await this.findEntryById(ctx, id); + if (!entry) { + throw new Error('Póliza no encontrada'); + } + + if (entry.status !== 'draft') { + throw new Error('Solo se pueden enviar a aprobación pólizas en borrador'); + } + + entry.status = 'pending_approval'; + entry.updatedBy = ctx.userId; + + return this.entryRepository.save(entry); + } + + async approveEntry(ctx: ServiceContext, id: string): Promise { + const entry = await this.findEntryById(ctx, id); + if (!entry) { + throw new Error('Póliza no encontrada'); + } + + if (entry.status !== 'pending_approval') { + throw new Error('Solo se pueden aprobar pólizas pendientes de aprobación'); + } + + entry.status = 'approved'; + entry.approvedById = ctx.userId; + entry.approvedAt = new Date(); + entry.updatedBy = ctx.userId; + + return this.entryRepository.save(entry); + } + + async postEntry(ctx: ServiceContext, id: string): Promise { + const entry = await this.findEntryById(ctx, id); + if (!entry) { + throw new Error('Póliza no encontrada'); + } + + if (entry.status !== 'approved') { + throw new Error('Solo se pueden contabilizar pólizas aprobadas'); + } + + // Actualizar saldos de cuentas + for (const line of entry.lines || []) { + await this.updateAccountBalance(line.accountId, line.debitAmount, line.creditAmount); + } + + entry.status = 'posted'; + entry.postedById = ctx.userId; + entry.postedAt = new Date(); + entry.updatedBy = ctx.userId; + + return this.entryRepository.save(entry); + } + + private async updateAccountBalance( + accountId: string, + debitAmount: number, + creditAmount: number + ): Promise { + const account = await this.accountRepository.findOne({ + where: { id: accountId }, + }); + + if (!account) return; + + // Calcular nuevo saldo según naturaleza de la cuenta + let newBalance = account.balance; + if (account.nature === 'debit') { + newBalance += debitAmount - creditAmount; + } else { + newBalance += creditAmount - debitAmount; + } + + await this.accountRepository.update(accountId, { + balance: newBalance, + lastMovementDate: new Date(), + }); + } + + async cancelEntry(ctx: ServiceContext, id: string, reason: string): Promise { + const entry = await this.findEntryById(ctx, id); + if (!entry) { + throw new Error('Póliza no encontrada'); + } + + if (entry.status === 'cancelled' || entry.status === 'reversed') { + throw new Error('La póliza ya está cancelada o reversada'); + } + + // Si está contabilizada, reversar saldos + if (entry.status === 'posted') { + for (const line of entry.lines || []) { + await this.updateAccountBalance(line.accountId, -line.debitAmount, -line.creditAmount); + } + } + + entry.status = 'cancelled'; + entry.notes = `${entry.notes || ''}\n[CANCELADA]: ${reason}`; + entry.updatedBy = ctx.userId; + + return this.entryRepository.save(entry); + } + + async reverseEntry(ctx: ServiceContext, id: string, reason: string): Promise { + const entry = await this.findEntryById(ctx, id); + if (!entry) { + throw new Error('Póliza no encontrada'); + } + + if (entry.status !== 'posted') { + throw new Error('Solo se pueden reversar pólizas contabilizadas'); + } + + // Crear póliza de reverso + const reversalData: CreateEntryDto = { + entryType: entry.entryType, + entryDate: new Date(), + reference: `REV-${entry.entryNumber}`, + description: `Reverso de ${entry.entryNumber}: ${reason}`, + projectId: entry.projectId, + projectCode: entry.projectCode, + costCenterId: entry.costCenterId, + fiscalYear: entry.fiscalYear, + fiscalPeriod: entry.fiscalPeriod, + currencyCode: entry.currencyCode, + exchangeRate: entry.exchangeRate, + notes: `Póliza de reverso automático`, + lines: (entry.lines || []).map((line) => ({ + accountId: line.accountId, + accountCode: line.accountCode, + description: `Reverso: ${line.description || ''}`, + debitAmount: line.creditAmount, // Invertir + creditAmount: line.debitAmount, // Invertir + currencyAmount: line.currencyAmount, + currencyCode: line.currencyCode, + exchangeRate: line.exchangeRate, + costCenterId: line.costCenterId, + projectId: line.projectId, + partnerId: line.partnerId, + reference: line.reference, + })), + }; + + const reversalEntry = await this.createEntry(ctx, reversalData); + + // Aprobar y contabilizar automáticamente + await this.submitForApproval(ctx, reversalEntry.id); + await this.approveEntry(ctx, reversalEntry.id); + await this.postEntry(ctx, reversalEntry.id); + + // Marcar original como reversada + entry.status = 'reversed'; + entry.reversalEntryId = reversalEntry.id; + entry.notes = `${entry.notes || ''}\n[REVERSADA]: ${reason}`; + entry.updatedBy = ctx.userId; + + return this.entryRepository.save(entry); + } + + // ==================== REPORTES ==================== + + async getTrialBalance( + ctx: ServiceContext, + fiscalYear: number, + fiscalPeriod?: number + ): Promise { + const queryBuilder = this.lineRepository + .createQueryBuilder('line') + .innerJoin('line.entry', 'entry') + .innerJoin(ChartOfAccounts, 'account', 'account.id = line.accountId') + .select([ + 'line.accountId as "accountId"', + 'line.accountCode as "accountCode"', + 'account.name as "accountName"', + 'account.accountType as "accountType"', + 'account.nature as "nature"', + 'SUM(line.debitAmount) as "totalDebit"', + 'SUM(line.creditAmount) as "totalCredit"', + ]) + .where('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('entry.status = :status', { status: 'posted' }) + .andWhere('entry.fiscalYear = :fiscalYear', { fiscalYear }); + + if (fiscalPeriod) { + queryBuilder.andWhere('entry.fiscalPeriod <= :fiscalPeriod', { fiscalPeriod }); + } + + queryBuilder.groupBy('line.accountId, line.accountCode, account.name, account.accountType, account.nature'); + queryBuilder.orderBy('line.accountCode', 'ASC'); + + const results = await queryBuilder.getRawMany(); + + return results.map((row) => { + const debit = parseFloat(row.totalDebit) || 0; + const credit = parseFloat(row.totalCredit) || 0; + const balance = row.nature === 'debit' ? debit - credit : credit - debit; + + return { + accountId: row.accountId, + accountCode: row.accountCode, + accountName: row.accountName, + accountType: row.accountType, + totalDebit: debit, + totalCredit: credit, + balance, + debitBalance: balance > 0 ? balance : 0, + creditBalance: balance < 0 ? Math.abs(balance) : 0, + }; + }); + } + + async getAccountLedger( + ctx: ServiceContext, + accountId: string, + startDate: Date, + endDate: Date + ): Promise { + const lines = await this.lineRepository + .createQueryBuilder('line') + .innerJoinAndSelect('line.entry', 'entry') + .where('line.accountId = :accountId', { accountId }) + .andWhere('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('entry.status = :status', { status: 'posted' }) + .andWhere('entry.entryDate >= :startDate', { startDate }) + .andWhere('entry.entryDate <= :endDate', { endDate }) + .orderBy('entry.entryDate', 'ASC') + .addOrderBy('entry.entryNumber', 'ASC') + .getMany(); + + let runningBalance = 0; + return lines.map((line) => { + runningBalance += line.debitAmount - line.creditAmount; + return { + date: line.entry?.entryDate, + entryNumber: line.entry?.entryNumber, + reference: line.entry?.reference, + description: line.description || line.entry?.description, + debitAmount: line.debitAmount, + creditAmount: line.creditAmount, + balance: runningBalance, + }; + }); + } +} diff --git a/src/modules/finance/services/ap.service.ts b/src/modules/finance/services/ap.service.ts new file mode 100644 index 0000000..b3b9fbb --- /dev/null +++ b/src/modules/finance/services/ap.service.ts @@ -0,0 +1,673 @@ +/** + * APService - Servicio de Cuentas por Pagar + * + * Gestión de cuentas por pagar y pagos a proveedores. + * + * @module Finance + */ + +import { DataSource, Repository, IsNull, LessThan, Between } from 'typeorm'; +import { + AccountPayable, + APStatus, + APDocumentType, + APPayment, + PaymentMethod, + PaymentStatus, +} from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +interface CreateAPDto { + documentType: APDocumentType; + documentNumber: string; + documentDate: Date; + dueDate: Date; + partnerId: string; + partnerName: string; + partnerRfc?: string; + projectId?: string; + projectCode?: string; + contractId?: string; + purchaseOrderId?: string; + originalAmount: number; + currencyCode?: string; + exchangeRate?: number; + taxAmount?: number; + retentionIsr?: number; + retentionIva?: number; + guaranteeFund?: number; + cfdiUuid?: string; + cfdiXml?: string; + description?: string; + paymentTermDays?: number; + ledgerAccountId?: string; + notes?: string; + metadata?: Record; +} + +interface CreatePaymentDto { + paymentMethod: PaymentMethod; + paymentDate: Date; + bankAccountId: string; + paymentAmount: number; + currencyCode?: string; + exchangeRate?: number; + reference?: string; + checkNumber?: string; + transferReference?: string; + beneficiaryName?: string; + beneficiaryAccount?: string; + beneficiaryBank?: string; + paymentConcept?: string; + notes?: string; + accountPayableIds: string[]; +} + +interface AgingBucket { + current: number; + days1to30: number; + days31to60: number; + days61to90: number; + over90: number; + total: number; +} + +export class APService { + private apRepository: Repository; + private paymentRepository: Repository; + + constructor(private dataSource: DataSource) { + this.apRepository = dataSource.getRepository(AccountPayable); + this.paymentRepository = dataSource.getRepository(APPayment); + } + + // ==================== CUENTAS POR PAGAR ==================== + + async findAll( + ctx: ServiceContext, + options: { + page?: number; + limit?: number; + status?: APStatus; + partnerId?: string; + projectId?: string; + startDate?: Date; + endDate?: Date; + overdue?: boolean; + search?: string; + } = {} + ): Promise> { + const { + page = 1, + limit = 20, + status, + partnerId, + projectId, + startDate, + endDate, + overdue, + search, + } = options; + + const queryBuilder = this.apRepository + .createQueryBuilder('ap') + .where('ap.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ap.deletedAt IS NULL'); + + if (status) { + queryBuilder.andWhere('ap.status = :status', { status }); + } + + if (partnerId) { + queryBuilder.andWhere('ap.partnerId = :partnerId', { partnerId }); + } + + if (projectId) { + queryBuilder.andWhere('ap.projectId = :projectId', { projectId }); + } + + if (startDate) { + queryBuilder.andWhere('ap.documentDate >= :startDate', { startDate }); + } + + if (endDate) { + queryBuilder.andWhere('ap.documentDate <= :endDate', { endDate }); + } + + if (overdue) { + queryBuilder.andWhere('ap.dueDate < :today', { today: new Date() }); + queryBuilder.andWhere('ap.status IN (:...statuses)', { + statuses: ['pending', 'partial'], + }); + } + + if (search) { + queryBuilder.andWhere( + '(ap.documentNumber ILIKE :search OR ap.partnerName ILIKE :search)', + { search: `%${search}%` } + ); + } + + queryBuilder.orderBy('ap.dueDate', 'ASC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.apRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['payments'], + }); + } + + async create(ctx: ServiceContext, data: CreateAPDto): Promise { + // Calcular monto neto (con retenciones) + const netAmount = + data.originalAmount - + (data.retentionIsr ?? 0) - + (data.retentionIva ?? 0) - + (data.guaranteeFund ?? 0); + + const ap = this.apRepository.create({ + tenantId: ctx.tenantId, + documentType: data.documentType, + documentNumber: data.documentNumber, + documentDate: data.documentDate, + dueDate: data.dueDate, + partnerId: data.partnerId, + partnerName: data.partnerName, + partnerRfc: data.partnerRfc, + projectId: data.projectId, + projectCode: data.projectCode, + contractId: data.contractId, + purchaseOrderId: data.purchaseOrderId, + originalAmount: data.originalAmount, + taxAmount: data.taxAmount ?? 0, + retentionIsr: data.retentionIsr ?? 0, + retentionIva: data.retentionIva ?? 0, + guaranteeFund: data.guaranteeFund ?? 0, + netAmount, + paidAmount: 0, + balanceAmount: netAmount, + currencyCode: data.currencyCode ?? 'MXN', + exchangeRate: data.exchangeRate ?? 1, + cfdiUuid: data.cfdiUuid, + cfdiXml: data.cfdiXml, + description: data.description, + paymentTermDays: data.paymentTermDays, + ledgerAccountId: data.ledgerAccountId, + notes: data.notes, + metadata: data.metadata, + status: 'pending', + createdBy: ctx.userId, + }); + + return this.apRepository.save(ap); + } + + async update( + ctx: ServiceContext, + id: string, + data: Partial + ): Promise { + const ap = await this.findById(ctx, id); + if (!ap) { + throw new Error('Cuenta por pagar no encontrada'); + } + + if (ap.status === 'paid' || ap.status === 'cancelled') { + throw new Error('No se puede modificar una cuenta pagada o cancelada'); + } + + Object.assign(ap, { + ...data, + updatedBy: ctx.userId, + }); + + // Recalcular montos si cambió el monto original + if (data.originalAmount !== undefined) { + ap.netAmount = + ap.originalAmount - + (ap.retentionIsr ?? 0) - + (ap.retentionIva ?? 0) - + (ap.guaranteeFund ?? 0); + ap.balanceAmount = ap.netAmount - ap.paidAmount; + } + + return this.apRepository.save(ap); + } + + async cancel(ctx: ServiceContext, id: string, reason: string): Promise { + const ap = await this.findById(ctx, id); + if (!ap) { + throw new Error('Cuenta por pagar no encontrada'); + } + + if (ap.paidAmount > 0) { + throw new Error('No se puede cancelar una cuenta con pagos aplicados'); + } + + ap.status = 'cancelled'; + ap.notes = `${ap.notes || ''}\n[CANCELADA]: ${reason}`; + ap.updatedBy = ctx.userId; + + return this.apRepository.save(ap); + } + + // ==================== PAGOS ==================== + + async createPayment(ctx: ServiceContext, data: CreatePaymentDto): Promise { + // Validar que las cuentas por pagar existen y calcular total + let totalToApply = 0; + const apRecords: AccountPayable[] = []; + + for (const apId of data.accountPayableIds) { + const ap = await this.findById(ctx, apId); + if (!ap) { + throw new Error(`Cuenta por pagar ${apId} no encontrada`); + } + if (ap.status === 'paid' || ap.status === 'cancelled') { + throw new Error(`Cuenta por pagar ${ap.documentNumber} ya está pagada o cancelada`); + } + apRecords.push(ap); + totalToApply += ap.balanceAmount; + } + + // Validar monto + if (data.paymentAmount > totalToApply) { + throw new Error( + `El monto del pago (${data.paymentAmount}) excede el saldo pendiente (${totalToApply})` + ); + } + + // Generar número de pago + const paymentNumber = await this.generatePaymentNumber(ctx); + + // Crear pago + const payment = this.paymentRepository.create({ + tenantId: ctx.tenantId, + paymentNumber, + paymentMethod: data.paymentMethod, + paymentDate: data.paymentDate, + bankAccountId: data.bankAccountId, + paymentAmount: data.paymentAmount, + currencyCode: data.currencyCode ?? 'MXN', + exchangeRate: data.exchangeRate ?? 1, + reference: data.reference, + checkNumber: data.checkNumber, + transferReference: data.transferReference, + beneficiaryName: data.beneficiaryName, + beneficiaryAccount: data.beneficiaryAccount, + beneficiaryBank: data.beneficiaryBank, + paymentConcept: data.paymentConcept, + notes: data.notes, + status: 'pending', + documentCount: apRecords.length, + createdBy: ctx.userId, + }); + + const savedPayment = await this.paymentRepository.save(payment); + + // Aplicar pago a las cuentas por pagar (FIFO por fecha de vencimiento) + let remainingAmount = data.paymentAmount; + const sortedAP = apRecords.sort( + (a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime() + ); + + const applications: { apId: string; amount: number }[] = []; + + for (const ap of sortedAP) { + if (remainingAmount <= 0) break; + + const amountToApply = Math.min(remainingAmount, ap.balanceAmount); + applications.push({ apId: ap.id, amount: amountToApply }); + + ap.paidAmount += amountToApply; + ap.balanceAmount -= amountToApply; + ap.lastPaymentDate = data.paymentDate; + + if (ap.balanceAmount <= 0.01) { + ap.status = 'paid'; + } else { + ap.status = 'partial'; + } + + ap.updatedBy = ctx.userId; + await this.apRepository.save(ap); + + remainingAmount -= amountToApply; + } + + // Guardar aplicaciones en metadata del pago + savedPayment.metadata = { applications }; + await this.paymentRepository.save(savedPayment); + + return savedPayment; + } + + private async generatePaymentNumber(ctx: ServiceContext): Promise { + const year = new Date().getFullYear(); + const lastPayment = await this.paymentRepository + .createQueryBuilder('payment') + .where('payment.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('payment.paymentNumber LIKE :pattern', { pattern: `PG-${year}%` }) + .orderBy('payment.paymentNumber', 'DESC') + .getOne(); + + let sequence = 1; + if (lastPayment) { + const match = lastPayment.paymentNumber.match(/(\d+)$/); + if (match) { + sequence = parseInt(match[1], 10) + 1; + } + } + + return `PG-${year}-${String(sequence).padStart(6, '0')}`; + } + + async confirmPayment(ctx: ServiceContext, paymentId: string): Promise { + const payment = await this.paymentRepository.findOne({ + where: { + id: paymentId, + tenantId: ctx.tenantId, + }, + }); + + if (!payment) { + throw new Error('Pago no encontrado'); + } + + if (payment.status !== 'pending') { + throw new Error('Solo se pueden confirmar pagos pendientes'); + } + + payment.status = 'confirmed'; + payment.confirmedAt = new Date(); + payment.confirmedById = ctx.userId; + payment.updatedBy = ctx.userId; + + return this.paymentRepository.save(payment); + } + + async cancelPayment( + ctx: ServiceContext, + paymentId: string, + reason: string + ): Promise { + const payment = await this.paymentRepository.findOne({ + where: { + id: paymentId, + tenantId: ctx.tenantId, + }, + }); + + if (!payment) { + throw new Error('Pago no encontrado'); + } + + if (payment.status === 'cancelled' || payment.status === 'reconciled') { + throw new Error('No se puede cancelar este pago'); + } + + // Reversar aplicaciones + const applications = (payment.metadata as any)?.applications || []; + for (const app of applications) { + const ap = await this.apRepository.findOne({ where: { id: app.apId } }); + if (ap) { + ap.paidAmount -= app.amount; + ap.balanceAmount += app.amount; + ap.status = ap.balanceAmount >= ap.netAmount ? 'pending' : 'partial'; + ap.updatedBy = ctx.userId; + await this.apRepository.save(ap); + } + } + + payment.status = 'cancelled'; + payment.notes = `${payment.notes || ''}\n[CANCELADO]: ${reason}`; + payment.updatedBy = ctx.userId; + + return this.paymentRepository.save(payment); + } + + // ==================== REPORTES ==================== + + async getAgingReport( + ctx: ServiceContext, + options: { + partnerId?: string; + projectId?: string; + asOfDate?: Date; + } = {} + ): Promise<{ + summary: AgingBucket; + byPartner: { partnerId: string; partnerName: string; aging: AgingBucket }[]; + }> { + const { partnerId, projectId, asOfDate = new Date() } = options; + + const queryBuilder = this.apRepository + .createQueryBuilder('ap') + .where('ap.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ap.status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .andWhere('ap.deletedAt IS NULL'); + + if (partnerId) { + queryBuilder.andWhere('ap.partnerId = :partnerId', { partnerId }); + } + + if (projectId) { + queryBuilder.andWhere('ap.projectId = :projectId', { projectId }); + } + + const apRecords = await queryBuilder.getMany(); + + const summary: AgingBucket = { + current: 0, + days1to30: 0, + days31to60: 0, + days61to90: 0, + over90: 0, + total: 0, + }; + + const partnerMap = new Map< + string, + { partnerId: string; partnerName: string; aging: AgingBucket } + >(); + + for (const ap of apRecords) { + const daysOverdue = Math.floor( + (asOfDate.getTime() - new Date(ap.dueDate).getTime()) / (1000 * 60 * 60 * 24) + ); + const balance = ap.balanceAmount; + + // Clasificar en bucket + let bucket: keyof AgingBucket; + if (daysOverdue <= 0) { + bucket = 'current'; + } else if (daysOverdue <= 30) { + bucket = 'days1to30'; + } else if (daysOverdue <= 60) { + bucket = 'days31to60'; + } else if (daysOverdue <= 90) { + bucket = 'days61to90'; + } else { + bucket = 'over90'; + } + + summary[bucket] += balance; + summary.total += balance; + + // Por proveedor + if (!partnerMap.has(ap.partnerId)) { + partnerMap.set(ap.partnerId, { + partnerId: ap.partnerId, + partnerName: ap.partnerName, + aging: { + current: 0, + days1to30: 0, + days31to60: 0, + days61to90: 0, + over90: 0, + total: 0, + }, + }); + } + + const partnerData = partnerMap.get(ap.partnerId)!; + partnerData.aging[bucket] += balance; + partnerData.aging.total += balance; + } + + return { + summary, + byPartner: Array.from(partnerMap.values()).sort((a, b) => b.aging.total - a.aging.total), + }; + } + + async getPaymentSchedule( + ctx: ServiceContext, + startDate: Date, + endDate: Date, + options: { partnerId?: string; projectId?: string } = {} + ): Promise<{ date: Date; documents: AccountPayable[]; totalAmount: number }[]> { + const { partnerId, projectId } = options; + + const queryBuilder = this.apRepository + .createQueryBuilder('ap') + .where('ap.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ap.status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .andWhere('ap.dueDate BETWEEN :startDate AND :endDate', { startDate, endDate }) + .andWhere('ap.deletedAt IS NULL'); + + if (partnerId) { + queryBuilder.andWhere('ap.partnerId = :partnerId', { partnerId }); + } + + if (projectId) { + queryBuilder.andWhere('ap.projectId = :projectId', { projectId }); + } + + queryBuilder.orderBy('ap.dueDate', 'ASC'); + + const apRecords = await queryBuilder.getMany(); + + // Agrupar por fecha de vencimiento + const scheduleMap = new Map(); + + for (const ap of apRecords) { + const dateKey = new Date(ap.dueDate).toISOString().split('T')[0]; + + if (!scheduleMap.has(dateKey)) { + scheduleMap.set(dateKey, { + date: new Date(ap.dueDate), + documents: [], + totalAmount: 0, + }); + } + + const entry = scheduleMap.get(dateKey)!; + entry.documents.push(ap); + entry.totalAmount += ap.balanceAmount; + } + + return Array.from(scheduleMap.values()).sort( + (a, b) => a.date.getTime() - b.date.getTime() + ); + } + + async getDashboardStats(ctx: ServiceContext): Promise<{ + totalPending: number; + totalOverdue: number; + dueThisWeek: number; + dueThisMonth: number; + countPending: number; + countOverdue: number; + }> { + const today = new Date(); + const endOfWeek = new Date(today); + endOfWeek.setDate(today.getDate() + (7 - today.getDay())); + const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); + + const baseQuery = this.apRepository + .createQueryBuilder('ap') + .where('ap.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ap.status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .andWhere('ap.deletedAt IS NULL'); + + // Total pendiente + const totalPending = await baseQuery + .clone() + .select('SUM(ap.balanceAmount)', 'total') + .getRawOne(); + + // Vencido + const totalOverdue = await baseQuery + .clone() + .andWhere('ap.dueDate < :today', { today }) + .select('SUM(ap.balanceAmount)', 'total') + .getRawOne(); + + // Por vencer esta semana + const dueThisWeek = await baseQuery + .clone() + .andWhere('ap.dueDate BETWEEN :today AND :endOfWeek', { today, endOfWeek }) + .select('SUM(ap.balanceAmount)', 'total') + .getRawOne(); + + // Por vencer este mes + const dueThisMonth = await baseQuery + .clone() + .andWhere('ap.dueDate BETWEEN :today AND :endOfMonth', { today, endOfMonth }) + .select('SUM(ap.balanceAmount)', 'total') + .getRawOne(); + + // Conteos + const countPending = await baseQuery.clone().getCount(); + + const countOverdue = await baseQuery + .clone() + .andWhere('ap.dueDate < :today', { today }) + .getCount(); + + return { + totalPending: parseFloat(totalPending?.total) || 0, + totalOverdue: parseFloat(totalOverdue?.total) || 0, + dueThisWeek: parseFloat(dueThisWeek?.total) || 0, + dueThisMonth: parseFloat(dueThisMonth?.total) || 0, + countPending, + countOverdue, + }; + } +} diff --git a/src/modules/finance/services/ar.service.ts b/src/modules/finance/services/ar.service.ts new file mode 100644 index 0000000..ee92b9b --- /dev/null +++ b/src/modules/finance/services/ar.service.ts @@ -0,0 +1,728 @@ +/** + * ARService - Servicio de Cuentas por Cobrar + * + * Gestión de cuentas por cobrar y cobranza. + * + * @module Finance + */ + +import { DataSource, Repository, IsNull, LessThan, Between } from 'typeorm'; +import { + AccountReceivable, + ARStatus, + ARDocumentType, + ARPayment, + CollectionMethod, + CollectionStatus, +} from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +interface CreateARDto { + documentType: ARDocumentType; + documentNumber: string; + documentDate: Date; + dueDate: Date; + partnerId: string; + partnerName: string; + partnerRfc?: string; + projectId?: string; + projectCode?: string; + contractId?: string; + estimationId?: string; + originalAmount: number; + currencyCode?: string; + exchangeRate?: number; + taxAmount?: number; + retentionIsr?: number; + retentionIva?: number; + guaranteeFund?: number; + cfdiUuid?: string; + cfdiXml?: string; + description?: string; + paymentTermDays?: number; + ledgerAccountId?: string; + notes?: string; + metadata?: Record; +} + +interface CreateCollectionDto { + collectionMethod: CollectionMethod; + collectionDate: Date; + bankAccountId: string; + collectionAmount: number; + currencyCode?: string; + exchangeRate?: number; + reference?: string; + depositReference?: string; + transferReference?: string; + collectionConcept?: string; + notes?: string; + accountReceivableIds: string[]; +} + +interface AgingBucket { + current: number; + days1to30: number; + days31to60: number; + days61to90: number; + over90: number; + total: number; +} + +export class ARService { + private arRepository: Repository; + private collectionRepository: Repository; + + constructor(private dataSource: DataSource) { + this.arRepository = dataSource.getRepository(AccountReceivable); + this.collectionRepository = dataSource.getRepository(ARPayment); + } + + // ==================== CUENTAS POR COBRAR ==================== + + async findAll( + ctx: ServiceContext, + options: { + page?: number; + limit?: number; + status?: ARStatus; + partnerId?: string; + projectId?: string; + startDate?: Date; + endDate?: Date; + overdue?: boolean; + search?: string; + } = {} + ): Promise> { + const { + page = 1, + limit = 20, + status, + partnerId, + projectId, + startDate, + endDate, + overdue, + search, + } = options; + + const queryBuilder = this.arRepository + .createQueryBuilder('ar') + .where('ar.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ar.deletedAt IS NULL'); + + if (status) { + queryBuilder.andWhere('ar.status = :status', { status }); + } + + if (partnerId) { + queryBuilder.andWhere('ar.partnerId = :partnerId', { partnerId }); + } + + if (projectId) { + queryBuilder.andWhere('ar.projectId = :projectId', { projectId }); + } + + if (startDate) { + queryBuilder.andWhere('ar.documentDate >= :startDate', { startDate }); + } + + if (endDate) { + queryBuilder.andWhere('ar.documentDate <= :endDate', { endDate }); + } + + if (overdue) { + queryBuilder.andWhere('ar.dueDate < :today', { today: new Date() }); + queryBuilder.andWhere('ar.status IN (:...statuses)', { + statuses: ['pending', 'partial'], + }); + } + + if (search) { + queryBuilder.andWhere( + '(ar.documentNumber ILIKE :search OR ar.partnerName ILIKE :search)', + { search: `%${search}%` } + ); + } + + queryBuilder.orderBy('ar.dueDate', 'ASC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.arRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['collections'], + }); + } + + async create(ctx: ServiceContext, data: CreateARDto): Promise { + // Calcular monto neto (con retenciones) + const netAmount = + data.originalAmount - + (data.retentionIsr ?? 0) - + (data.retentionIva ?? 0) - + (data.guaranteeFund ?? 0); + + const ar = this.arRepository.create({ + tenantId: ctx.tenantId, + documentType: data.documentType, + documentNumber: data.documentNumber, + documentDate: data.documentDate, + dueDate: data.dueDate, + partnerId: data.partnerId, + partnerName: data.partnerName, + partnerRfc: data.partnerRfc, + projectId: data.projectId, + projectCode: data.projectCode, + contractId: data.contractId, + estimationId: data.estimationId, + originalAmount: data.originalAmount, + taxAmount: data.taxAmount ?? 0, + retentionIsr: data.retentionIsr ?? 0, + retentionIva: data.retentionIva ?? 0, + guaranteeFund: data.guaranteeFund ?? 0, + netAmount, + collectedAmount: 0, + balanceAmount: netAmount, + currencyCode: data.currencyCode ?? 'MXN', + exchangeRate: data.exchangeRate ?? 1, + cfdiUuid: data.cfdiUuid, + cfdiXml: data.cfdiXml, + description: data.description, + paymentTermDays: data.paymentTermDays, + ledgerAccountId: data.ledgerAccountId, + notes: data.notes, + metadata: data.metadata, + status: 'pending', + collectionAttempts: 0, + createdBy: ctx.userId, + }); + + return this.arRepository.save(ar); + } + + async update( + ctx: ServiceContext, + id: string, + data: Partial + ): Promise { + const ar = await this.findById(ctx, id); + if (!ar) { + throw new Error('Cuenta por cobrar no encontrada'); + } + + if (ar.status === 'collected' || ar.status === 'cancelled' || ar.status === 'written_off') { + throw new Error('No se puede modificar esta cuenta'); + } + + Object.assign(ar, { + ...data, + updatedBy: ctx.userId, + }); + + // Recalcular montos si cambió el monto original + if (data.originalAmount !== undefined) { + ar.netAmount = + ar.originalAmount - + (ar.retentionIsr ?? 0) - + (ar.retentionIva ?? 0) - + (ar.guaranteeFund ?? 0); + ar.balanceAmount = ar.netAmount - ar.collectedAmount; + } + + return this.arRepository.save(ar); + } + + async cancel(ctx: ServiceContext, id: string, reason: string): Promise { + const ar = await this.findById(ctx, id); + if (!ar) { + throw new Error('Cuenta por cobrar no encontrada'); + } + + if (ar.collectedAmount > 0) { + throw new Error('No se puede cancelar una cuenta con cobros aplicados'); + } + + ar.status = 'cancelled'; + ar.notes = `${ar.notes || ''}\n[CANCELADA]: ${reason}`; + ar.updatedBy = ctx.userId; + + return this.arRepository.save(ar); + } + + async writeOff(ctx: ServiceContext, id: string, reason: string): Promise { + const ar = await this.findById(ctx, id); + if (!ar) { + throw new Error('Cuenta por cobrar no encontrada'); + } + + ar.status = 'written_off'; + ar.notes = `${ar.notes || ''}\n[CASTIGO]: ${reason}`; + ar.updatedBy = ctx.userId; + + return this.arRepository.save(ar); + } + + async recordCollectionAttempt( + ctx: ServiceContext, + id: string, + notes: string + ): Promise { + const ar = await this.findById(ctx, id); + if (!ar) { + throw new Error('Cuenta por cobrar no encontrada'); + } + + ar.collectionAttempts += 1; + ar.lastCollectionAttempt = new Date(); + ar.notes = `${ar.notes || ''}\n[GESTIÓN ${ar.collectionAttempts}]: ${notes}`; + ar.updatedBy = ctx.userId; + + return this.arRepository.save(ar); + } + + // ==================== COBROS ==================== + + async createCollection(ctx: ServiceContext, data: CreateCollectionDto): Promise { + // Validar que las cuentas por cobrar existen y calcular total + let totalToApply = 0; + const arRecords: AccountReceivable[] = []; + + for (const arId of data.accountReceivableIds) { + const ar = await this.findById(ctx, arId); + if (!ar) { + throw new Error(`Cuenta por cobrar ${arId} no encontrada`); + } + if (ar.status === 'collected' || ar.status === 'cancelled' || ar.status === 'written_off') { + throw new Error(`Cuenta por cobrar ${ar.documentNumber} ya está cobrada, cancelada o castigada`); + } + arRecords.push(ar); + totalToApply += ar.balanceAmount; + } + + // Validar monto + if (data.collectionAmount > totalToApply) { + throw new Error( + `El monto del cobro (${data.collectionAmount}) excede el saldo pendiente (${totalToApply})` + ); + } + + // Generar número de cobro + const collectionNumber = await this.generateCollectionNumber(ctx); + + // Crear cobro + const collection = this.collectionRepository.create({ + tenantId: ctx.tenantId, + collectionNumber, + collectionMethod: data.collectionMethod, + collectionDate: data.collectionDate, + bankAccountId: data.bankAccountId, + collectionAmount: data.collectionAmount, + currencyCode: data.currencyCode ?? 'MXN', + exchangeRate: data.exchangeRate ?? 1, + reference: data.reference, + depositReference: data.depositReference, + transferReference: data.transferReference, + collectionConcept: data.collectionConcept, + notes: data.notes, + status: 'pending', + documentCount: arRecords.length, + createdBy: ctx.userId, + }); + + const savedCollection = await this.collectionRepository.save(collection); + + // Aplicar cobro a las cuentas por cobrar (FIFO por fecha de vencimiento) + let remainingAmount = data.collectionAmount; + const sortedAR = arRecords.sort( + (a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime() + ); + + const applications: { arId: string; amount: number }[] = []; + + for (const ar of sortedAR) { + if (remainingAmount <= 0) break; + + const amountToApply = Math.min(remainingAmount, ar.balanceAmount); + applications.push({ arId: ar.id, amount: amountToApply }); + + ar.collectedAmount += amountToApply; + ar.balanceAmount -= amountToApply; + ar.lastCollectionDate = data.collectionDate; + + if (ar.balanceAmount <= 0.01) { + ar.status = 'collected'; + } else { + ar.status = 'partial'; + } + + ar.updatedBy = ctx.userId; + await this.arRepository.save(ar); + + remainingAmount -= amountToApply; + } + + // Guardar aplicaciones en metadata del cobro + savedCollection.metadata = { applications }; + await this.collectionRepository.save(savedCollection); + + return savedCollection; + } + + private async generateCollectionNumber(ctx: ServiceContext): Promise { + const year = new Date().getFullYear(); + const lastCollection = await this.collectionRepository + .createQueryBuilder('collection') + .where('collection.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('collection.collectionNumber LIKE :pattern', { pattern: `CB-${year}%` }) + .orderBy('collection.collectionNumber', 'DESC') + .getOne(); + + let sequence = 1; + if (lastCollection) { + const match = lastCollection.collectionNumber.match(/(\d+)$/); + if (match) { + sequence = parseInt(match[1], 10) + 1; + } + } + + return `CB-${year}-${String(sequence).padStart(6, '0')}`; + } + + async confirmCollection(ctx: ServiceContext, collectionId: string): Promise { + const collection = await this.collectionRepository.findOne({ + where: { + id: collectionId, + tenantId: ctx.tenantId, + }, + }); + + if (!collection) { + throw new Error('Cobro no encontrado'); + } + + if (collection.status !== 'pending') { + throw new Error('Solo se pueden confirmar cobros pendientes'); + } + + collection.status = 'confirmed'; + collection.confirmedAt = new Date(); + collection.confirmedById = ctx.userId; + collection.updatedBy = ctx.userId; + + return this.collectionRepository.save(collection); + } + + async cancelCollection( + ctx: ServiceContext, + collectionId: string, + reason: string + ): Promise { + const collection = await this.collectionRepository.findOne({ + where: { + id: collectionId, + tenantId: ctx.tenantId, + }, + }); + + if (!collection) { + throw new Error('Cobro no encontrado'); + } + + if (collection.status === 'cancelled' || collection.status === 'reconciled') { + throw new Error('No se puede cancelar este cobro'); + } + + // Reversar aplicaciones + const applications = (collection.metadata as any)?.applications || []; + for (const app of applications) { + const ar = await this.arRepository.findOne({ where: { id: app.arId } }); + if (ar) { + ar.collectedAmount -= app.amount; + ar.balanceAmount += app.amount; + ar.status = ar.balanceAmount >= ar.netAmount ? 'pending' : 'partial'; + ar.updatedBy = ctx.userId; + await this.arRepository.save(ar); + } + } + + collection.status = 'cancelled'; + collection.notes = `${collection.notes || ''}\n[CANCELADO]: ${reason}`; + collection.updatedBy = ctx.userId; + + return this.collectionRepository.save(collection); + } + + // ==================== REPORTES ==================== + + async getAgingReport( + ctx: ServiceContext, + options: { + partnerId?: string; + projectId?: string; + asOfDate?: Date; + } = {} + ): Promise<{ + summary: AgingBucket; + byPartner: { partnerId: string; partnerName: string; aging: AgingBucket }[]; + }> { + const { partnerId, projectId, asOfDate = new Date() } = options; + + const queryBuilder = this.arRepository + .createQueryBuilder('ar') + .where('ar.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ar.status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .andWhere('ar.deletedAt IS NULL'); + + if (partnerId) { + queryBuilder.andWhere('ar.partnerId = :partnerId', { partnerId }); + } + + if (projectId) { + queryBuilder.andWhere('ar.projectId = :projectId', { projectId }); + } + + const arRecords = await queryBuilder.getMany(); + + const summary: AgingBucket = { + current: 0, + days1to30: 0, + days31to60: 0, + days61to90: 0, + over90: 0, + total: 0, + }; + + const partnerMap = new Map< + string, + { partnerId: string; partnerName: string; aging: AgingBucket } + >(); + + for (const ar of arRecords) { + const daysOverdue = Math.floor( + (asOfDate.getTime() - new Date(ar.dueDate).getTime()) / (1000 * 60 * 60 * 24) + ); + const balance = ar.balanceAmount; + + // Clasificar en bucket + let bucket: keyof AgingBucket; + if (daysOverdue <= 0) { + bucket = 'current'; + } else if (daysOverdue <= 30) { + bucket = 'days1to30'; + } else if (daysOverdue <= 60) { + bucket = 'days31to60'; + } else if (daysOverdue <= 90) { + bucket = 'days61to90'; + } else { + bucket = 'over90'; + } + + summary[bucket] += balance; + summary.total += balance; + + // Por cliente + if (!partnerMap.has(ar.partnerId)) { + partnerMap.set(ar.partnerId, { + partnerId: ar.partnerId, + partnerName: ar.partnerName, + aging: { + current: 0, + days1to30: 0, + days31to60: 0, + days61to90: 0, + over90: 0, + total: 0, + }, + }); + } + + const partnerData = partnerMap.get(ar.partnerId)!; + partnerData.aging[bucket] += balance; + partnerData.aging.total += balance; + } + + return { + summary, + byPartner: Array.from(partnerMap.values()).sort((a, b) => b.aging.total - a.aging.total), + }; + } + + async getCollectionForecast( + ctx: ServiceContext, + startDate: Date, + endDate: Date, + options: { partnerId?: string; projectId?: string } = {} + ): Promise<{ date: Date; documents: AccountReceivable[]; totalAmount: number }[]> { + const { partnerId, projectId } = options; + + const queryBuilder = this.arRepository + .createQueryBuilder('ar') + .where('ar.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ar.status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .andWhere('ar.dueDate BETWEEN :startDate AND :endDate', { startDate, endDate }) + .andWhere('ar.deletedAt IS NULL'); + + if (partnerId) { + queryBuilder.andWhere('ar.partnerId = :partnerId', { partnerId }); + } + + if (projectId) { + queryBuilder.andWhere('ar.projectId = :projectId', { projectId }); + } + + queryBuilder.orderBy('ar.dueDate', 'ASC'); + + const arRecords = await queryBuilder.getMany(); + + // Agrupar por fecha de vencimiento + const forecastMap = new Map(); + + for (const ar of arRecords) { + const dateKey = new Date(ar.dueDate).toISOString().split('T')[0]; + + if (!forecastMap.has(dateKey)) { + forecastMap.set(dateKey, { + date: new Date(ar.dueDate), + documents: [], + totalAmount: 0, + }); + } + + const entry = forecastMap.get(dateKey)!; + entry.documents.push(ar); + entry.totalAmount += ar.balanceAmount; + } + + return Array.from(forecastMap.values()).sort( + (a, b) => a.date.getTime() - b.date.getTime() + ); + } + + async getDashboardStats(ctx: ServiceContext): Promise<{ + totalPending: number; + totalOverdue: number; + dueThisWeek: number; + dueThisMonth: number; + countPending: number; + countOverdue: number; + collectionEfficiency: number; + }> { + const today = new Date(); + const endOfWeek = new Date(today); + endOfWeek.setDate(today.getDate() + (7 - today.getDay())); + const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + + const baseQuery = this.arRepository + .createQueryBuilder('ar') + .where('ar.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ar.status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .andWhere('ar.deletedAt IS NULL'); + + // Total pendiente + const totalPending = await baseQuery + .clone() + .select('SUM(ar.balanceAmount)', 'total') + .getRawOne(); + + // Vencido + const totalOverdue = await baseQuery + .clone() + .andWhere('ar.dueDate < :today', { today }) + .select('SUM(ar.balanceAmount)', 'total') + .getRawOne(); + + // Por cobrar esta semana + const dueThisWeek = await baseQuery + .clone() + .andWhere('ar.dueDate BETWEEN :today AND :endOfWeek', { today, endOfWeek }) + .select('SUM(ar.balanceAmount)', 'total') + .getRawOne(); + + // Por cobrar este mes + const dueThisMonth = await baseQuery + .clone() + .andWhere('ar.dueDate BETWEEN :today AND :endOfMonth', { today, endOfMonth }) + .select('SUM(ar.balanceAmount)', 'total') + .getRawOne(); + + // Conteos + const countPending = await baseQuery.clone().getCount(); + + const countOverdue = await baseQuery + .clone() + .andWhere('ar.dueDate < :today', { today }) + .getCount(); + + // Eficiencia de cobranza (cobrado vs facturado en el mes) + const monthlyInvoiced = await this.arRepository + .createQueryBuilder('ar') + .where('ar.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ar.documentDate BETWEEN :startOfMonth AND :endOfMonth', { + startOfMonth, + endOfMonth, + }) + .select('SUM(ar.netAmount)', 'total') + .getRawOne(); + + const monthlyCollected = await this.collectionRepository + .createQueryBuilder('col') + .where('col.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('col.collectionDate BETWEEN :startOfMonth AND :endOfMonth', { + startOfMonth, + endOfMonth, + }) + .andWhere('col.status != :cancelled', { cancelled: 'cancelled' }) + .select('SUM(col.collectionAmount)', 'total') + .getRawOne(); + + const invoicedAmount = parseFloat(monthlyInvoiced?.total) || 0; + const collectedAmount = parseFloat(monthlyCollected?.total) || 0; + const collectionEfficiency = invoicedAmount > 0 ? (collectedAmount / invoicedAmount) * 100 : 0; + + return { + totalPending: parseFloat(totalPending?.total) || 0, + totalOverdue: parseFloat(totalOverdue?.total) || 0, + dueThisWeek: parseFloat(dueThisWeek?.total) || 0, + dueThisMonth: parseFloat(dueThisMonth?.total) || 0, + countPending, + countOverdue, + collectionEfficiency: Math.round(collectionEfficiency * 100) / 100, + }; + } +} diff --git a/src/modules/finance/services/bank-reconciliation.service.ts b/src/modules/finance/services/bank-reconciliation.service.ts new file mode 100644 index 0000000..66e3510 --- /dev/null +++ b/src/modules/finance/services/bank-reconciliation.service.ts @@ -0,0 +1,846 @@ +/** + * BankReconciliationService - Servicio de Conciliación Bancaria + * + * Gestión de cuentas bancarias, movimientos y conciliación. + * + * @module Finance + */ + +import { DataSource, Repository, IsNull, Between, In } from 'typeorm'; +import { + BankAccount, + BankAccountType, + BankAccountStatus, + BankMovement, + MovementType, + MovementStatus, + MovementSource, + BankReconciliation, + ReconciliationStatus, +} from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +interface CreateBankAccountDto { + name: string; + accountNumber: string; + clabe?: string; + accountType: BankAccountType; + bankName: string; + bankCode?: string; + branchName?: string; + branchCode?: string; + currency?: string; + initialBalance?: number; + projectId?: string; + projectCode?: string; + ledgerAccountId?: string; + bankContactName?: string; + bankContactPhone?: string; + bankContactEmail?: string; + creditLimit?: number; + minimumBalance?: number; + isDefault?: boolean; + allowsPayments?: boolean; + allowsCollections?: boolean; + notes?: string; + metadata?: Record; +} + +interface CreateMovementDto { + bankAccountId: string; + movementReference?: string; + bankReference?: string; + movementType: MovementType; + movementDate: Date; + valueDate?: Date; + description: string; + bankDescription?: string; + amount: number; + currency?: string; + balanceAfter?: number; + source?: MovementSource; + category?: string; + subcategory?: string; + partnerId?: string; + partnerName?: string; + notes?: string; + rawData?: Record; + metadata?: Record; +} + +interface CreateReconciliationDto { + bankAccountId: string; + periodStart: Date; + periodEnd: Date; + bankOpeningBalance: number; + bankClosingBalance: number; + statementFilePath?: string; + notes?: string; +} + +interface ImportBankStatementDto { + bankAccountId: string; + movements: { + date: Date; + reference?: string; + description: string; + amount: number; + type: MovementType; + balance?: number; + rawData?: Record; + }[]; +} + +export class BankReconciliationService { + private bankAccountRepository: Repository; + private movementRepository: Repository; + private reconciliationRepository: Repository; + + constructor(private dataSource: DataSource) { + this.bankAccountRepository = dataSource.getRepository(BankAccount); + this.movementRepository = dataSource.getRepository(BankMovement); + this.reconciliationRepository = dataSource.getRepository(BankReconciliation); + } + + // ==================== CUENTAS BANCARIAS ==================== + + async findAllAccounts( + ctx: ServiceContext, + options: { + page?: number; + limit?: number; + accountType?: BankAccountType; + status?: BankAccountStatus; + projectId?: string; + search?: string; + } = {} + ): Promise> { + const { page = 1, limit = 20, accountType, status, projectId, search } = options; + + const queryBuilder = this.bankAccountRepository + .createQueryBuilder('ba') + .where('ba.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ba.deletedAt IS NULL'); + + if (accountType) { + queryBuilder.andWhere('ba.accountType = :accountType', { accountType }); + } + + if (status) { + queryBuilder.andWhere('ba.status = :status', { status }); + } + + if (projectId) { + queryBuilder.andWhere('ba.projectId = :projectId', { projectId }); + } + + if (search) { + queryBuilder.andWhere( + '(ba.name ILIKE :search OR ba.accountNumber ILIKE :search OR ba.bankName ILIKE :search)', + { search: `%${search}%` } + ); + } + + queryBuilder.orderBy('ba.name', 'ASC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findAccountById(ctx: ServiceContext, id: string): Promise { + return this.bankAccountRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + } + + async createAccount(ctx: ServiceContext, data: CreateBankAccountDto): Promise { + // Verificar número de cuenta único + const existing = await this.bankAccountRepository.findOne({ + where: { + tenantId: ctx.tenantId, + accountNumber: data.accountNumber, + deletedAt: IsNull(), + }, + }); + + if (existing) { + throw new Error(`Ya existe una cuenta con el número ${data.accountNumber}`); + } + + // Si es cuenta por defecto, quitar el flag de las demás + if (data.isDefault) { + await this.bankAccountRepository.update( + { tenantId: ctx.tenantId, isDefault: true }, + { isDefault: false } + ); + } + + const account = this.bankAccountRepository.create({ + tenantId: ctx.tenantId, + name: data.name, + accountNumber: data.accountNumber, + clabe: data.clabe, + accountType: data.accountType, + bankName: data.bankName, + bankCode: data.bankCode, + branchName: data.branchName, + branchCode: data.branchCode, + currency: data.currency ?? 'MXN', + initialBalance: data.initialBalance ?? 0, + currentBalance: data.initialBalance ?? 0, + availableBalance: data.initialBalance ?? 0, + projectId: data.projectId, + projectCode: data.projectCode, + ledgerAccountId: data.ledgerAccountId, + bankContactName: data.bankContactName, + bankContactPhone: data.bankContactPhone, + bankContactEmail: data.bankContactEmail, + creditLimit: data.creditLimit, + minimumBalance: data.minimumBalance, + isDefault: data.isDefault ?? false, + allowsPayments: data.allowsPayments ?? true, + allowsCollections: data.allowsCollections ?? true, + status: 'active', + notes: data.notes, + metadata: data.metadata, + createdBy: ctx.userId, + }); + + return this.bankAccountRepository.save(account); + } + + async updateAccount( + ctx: ServiceContext, + id: string, + data: Partial + ): Promise { + const account = await this.findAccountById(ctx, id); + if (!account) { + throw new Error('Cuenta bancaria no encontrada'); + } + + // Si es cuenta por defecto, quitar el flag de las demás + if (data.isDefault && !account.isDefault) { + await this.bankAccountRepository.update( + { tenantId: ctx.tenantId, isDefault: true }, + { isDefault: false } + ); + } + + Object.assign(account, { + ...data, + updatedBy: ctx.userId, + }); + + return this.bankAccountRepository.save(account); + } + + async updateAccountBalance( + ctx: ServiceContext, + id: string, + currentBalance: number, + availableBalance?: number + ): Promise { + const account = await this.findAccountById(ctx, id); + if (!account) { + throw new Error('Cuenta bancaria no encontrada'); + } + + account.currentBalance = currentBalance; + account.availableBalance = availableBalance ?? currentBalance; + account.balanceUpdatedAt = new Date(); + account.updatedBy = ctx.userId; + + return this.bankAccountRepository.save(account); + } + + // ==================== MOVIMIENTOS BANCARIOS ==================== + + async findAllMovements( + ctx: ServiceContext, + options: { + page?: number; + limit?: number; + bankAccountId?: string; + movementType?: MovementType; + status?: MovementStatus; + startDate?: Date; + endDate?: Date; + search?: string; + } = {} + ): Promise> { + const { + page = 1, + limit = 50, + bankAccountId, + movementType, + status, + startDate, + endDate, + search, + } = options; + + const queryBuilder = this.movementRepository + .createQueryBuilder('mv') + .leftJoinAndSelect('mv.bankAccount', 'bankAccount') + .where('mv.tenantId = :tenantId', { tenantId: ctx.tenantId }); + + if (bankAccountId) { + queryBuilder.andWhere('mv.bankAccountId = :bankAccountId', { bankAccountId }); + } + + if (movementType) { + queryBuilder.andWhere('mv.movementType = :movementType', { movementType }); + } + + if (status) { + queryBuilder.andWhere('mv.status = :status', { status }); + } + + if (startDate) { + queryBuilder.andWhere('mv.movementDate >= :startDate', { startDate }); + } + + if (endDate) { + queryBuilder.andWhere('mv.movementDate <= :endDate', { endDate }); + } + + if (search) { + queryBuilder.andWhere( + '(mv.description ILIKE :search OR mv.movementReference ILIKE :search OR mv.bankReference ILIKE :search)', + { search: `%${search}%` } + ); + } + + queryBuilder.orderBy('mv.movementDate', 'DESC').addOrderBy('mv.createdAt', 'DESC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findMovementById(ctx: ServiceContext, id: string): Promise { + return this.movementRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + relations: ['bankAccount'], + }); + } + + async createMovement(ctx: ServiceContext, data: CreateMovementDto): Promise { + const bankAccount = await this.findAccountById(ctx, data.bankAccountId); + if (!bankAccount) { + throw new Error('Cuenta bancaria no encontrada'); + } + + const movement = this.movementRepository.create({ + tenantId: ctx.tenantId, + bankAccountId: data.bankAccountId, + movementReference: data.movementReference, + bankReference: data.bankReference, + movementType: data.movementType, + movementDate: data.movementDate, + valueDate: data.valueDate, + description: data.description, + bankDescription: data.bankDescription, + amount: data.amount, + currency: data.currency ?? bankAccount.currency, + balanceAfter: data.balanceAfter, + source: data.source ?? 'manual', + status: 'pending', + category: data.category, + subcategory: data.subcategory, + partnerId: data.partnerId, + partnerName: data.partnerName, + notes: data.notes, + rawData: data.rawData, + metadata: data.metadata, + createdBy: ctx.userId, + }); + + return this.movementRepository.save(movement); + } + + async importBankStatement( + ctx: ServiceContext, + data: ImportBankStatementDto + ): Promise<{ imported: number; duplicates: number; movements: BankMovement[] }> { + const bankAccount = await this.findAccountById(ctx, data.bankAccountId); + if (!bankAccount) { + throw new Error('Cuenta bancaria no encontrada'); + } + + const importBatchId = crypto.randomUUID(); + const importedMovements: BankMovement[] = []; + let duplicates = 0; + + for (const mv of data.movements) { + // Verificar duplicado por fecha, monto y referencia + const existing = await this.movementRepository.findOne({ + where: { + tenantId: ctx.tenantId, + bankAccountId: data.bankAccountId, + movementDate: mv.date, + amount: mv.amount, + movementType: mv.type, + }, + }); + + if (existing) { + duplicates++; + continue; + } + + const movement = await this.createMovement(ctx, { + bankAccountId: data.bankAccountId, + movementReference: mv.reference, + movementType: mv.type, + movementDate: mv.date, + description: mv.description, + amount: mv.amount, + balanceAfter: mv.balance, + source: 'import_file', + rawData: mv.rawData, + metadata: { importBatchId }, + }); + + importedMovements.push(movement); + } + + return { + imported: importedMovements.length, + duplicates, + movements: importedMovements, + }; + } + + async matchMovement( + ctx: ServiceContext, + movementId: string, + matchData: { + paymentId?: string; + collectionId?: string; + entryId?: string; + confidence?: number; + } + ): Promise { + const movement = await this.findMovementById(ctx, movementId); + if (!movement) { + throw new Error('Movimiento no encontrado'); + } + + movement.matchedPaymentId = matchData.paymentId; + movement.matchedCollectionId = matchData.collectionId; + movement.matchedEntryId = matchData.entryId; + movement.matchConfidence = matchData.confidence; + movement.status = 'matched'; + movement.updatedBy = ctx.userId; + + return this.movementRepository.save(movement); + } + + async ignoreMovement(ctx: ServiceContext, movementId: string): Promise { + const movement = await this.findMovementById(ctx, movementId); + if (!movement) { + throw new Error('Movimiento no encontrado'); + } + + movement.status = 'ignored'; + movement.updatedBy = ctx.userId; + + return this.movementRepository.save(movement); + } + + // ==================== CONCILIACIÓN ==================== + + async findAllReconciliations( + ctx: ServiceContext, + options: { + page?: number; + limit?: number; + bankAccountId?: string; + status?: ReconciliationStatus; + } = {} + ): Promise> { + const { page = 1, limit = 20, bankAccountId, status } = options; + + const queryBuilder = this.reconciliationRepository + .createQueryBuilder('rec') + .leftJoinAndSelect('rec.bankAccount', 'bankAccount') + .where('rec.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('rec.deletedAt IS NULL'); + + if (bankAccountId) { + queryBuilder.andWhere('rec.bankAccountId = :bankAccountId', { bankAccountId }); + } + + if (status) { + queryBuilder.andWhere('rec.status = :status', { status }); + } + + queryBuilder.orderBy('rec.periodEnd', 'DESC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findReconciliationById( + ctx: ServiceContext, + id: string + ): Promise { + return this.reconciliationRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + relations: ['bankAccount', 'approvedBy'], + }); + } + + async createReconciliation( + ctx: ServiceContext, + data: CreateReconciliationDto + ): Promise { + const bankAccount = await this.findAccountById(ctx, data.bankAccountId); + if (!bankAccount) { + throw new Error('Cuenta bancaria no encontrada'); + } + + // Obtener saldo en libros + const bookBalance = bankAccount.currentBalance; + + // Contar movimientos en el periodo + const movements = await this.movementRepository.count({ + where: { + tenantId: ctx.tenantId, + bankAccountId: data.bankAccountId, + movementDate: Between(data.periodStart, data.periodEnd), + }, + }); + + const reconciliation = this.reconciliationRepository.create({ + tenantId: ctx.tenantId, + bankAccountId: data.bankAccountId, + periodStart: data.periodStart, + periodEnd: data.periodEnd, + bankOpeningBalance: data.bankOpeningBalance, + bankClosingBalance: data.bankClosingBalance, + bookOpeningBalance: bookBalance, // TODO: Calcular saldo de apertura + bookClosingBalance: bookBalance, + depositsInTransit: 0, + checksInTransit: 0, + bankChargesNotRecorded: 0, + interestNotRecorded: 0, + otherAdjustments: 0, + reconciledBalance: 0, + difference: data.bankClosingBalance - bookBalance, + isBalanced: false, + totalMovements: movements, + reconciledMovements: 0, + pendingMovements: movements, + statementFilePath: data.statementFilePath, + statementImportDate: data.statementFilePath ? new Date() : undefined, + notes: data.notes, + status: 'draft', + createdBy: ctx.userId, + }); + + return this.reconciliationRepository.save(reconciliation); + } + + async startReconciliation(ctx: ServiceContext, id: string): Promise { + const reconciliation = await this.findReconciliationById(ctx, id); + if (!reconciliation) { + throw new Error('Conciliación no encontrada'); + } + + if (reconciliation.status !== 'draft') { + throw new Error('La conciliación ya fue iniciada'); + } + + reconciliation.status = 'in_progress'; + reconciliation.startedAt = new Date(); + reconciliation.updatedBy = ctx.userId; + + return this.reconciliationRepository.save(reconciliation); + } + + async reconcileMovement( + ctx: ServiceContext, + reconciliationId: string, + movementId: string + ): Promise { + const reconciliation = await this.findReconciliationById(ctx, reconciliationId); + if (!reconciliation) { + throw new Error('Conciliación no encontrada'); + } + + const movement = await this.findMovementById(ctx, movementId); + if (!movement) { + throw new Error('Movimiento no encontrado'); + } + + // Marcar movimiento como conciliado + movement.status = 'reconciled'; + movement.reconciliationId = reconciliationId; + movement.reconciledAt = new Date(); + movement.reconciledById = ctx.userId; + movement.updatedBy = ctx.userId; + await this.movementRepository.save(movement); + + // Actualizar contadores + reconciliation.reconciledMovements++; + reconciliation.pendingMovements--; + reconciliation.updatedBy = ctx.userId; + + // Recalcular diferencia + await this.recalculateReconciliation(ctx, reconciliationId); + + return this.findReconciliationById(ctx, reconciliationId) as Promise; + } + + private async recalculateReconciliation( + ctx: ServiceContext, + reconciliationId: string + ): Promise { + const reconciliation = await this.findReconciliationById(ctx, reconciliationId); + if (!reconciliation) return; + + // Obtener movimientos pendientes de conciliación + const pendingDeposits = await this.movementRepository + .createQueryBuilder('mv') + .where('mv.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('mv.bankAccountId = :bankAccountId', { bankAccountId: reconciliation.bankAccountId }) + .andWhere('mv.movementDate <= :periodEnd', { periodEnd: reconciliation.periodEnd }) + .andWhere('mv.status IN (:...statuses)', { statuses: ['pending', 'matched'] }) + .andWhere('mv.movementType = :type', { type: 'credit' }) + .select('SUM(mv.amount)', 'total') + .getRawOne(); + + const pendingChecks = await this.movementRepository + .createQueryBuilder('mv') + .where('mv.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('mv.bankAccountId = :bankAccountId', { bankAccountId: reconciliation.bankAccountId }) + .andWhere('mv.movementDate <= :periodEnd', { periodEnd: reconciliation.periodEnd }) + .andWhere('mv.status IN (:...statuses)', { statuses: ['pending', 'matched'] }) + .andWhere('mv.movementType = :type', { type: 'debit' }) + .select('SUM(mv.amount)', 'total') + .getRawOne(); + + reconciliation.depositsInTransit = parseFloat(pendingDeposits?.total) || 0; + reconciliation.checksInTransit = parseFloat(pendingChecks?.total) || 0; + + // Calcular saldo conciliado + const reconciledBalance = + reconciliation.bankClosingBalance - + reconciliation.depositsInTransit + + reconciliation.checksInTransit - + reconciliation.bankChargesNotRecorded + + reconciliation.interestNotRecorded + + reconciliation.otherAdjustments; + + reconciliation.reconciledBalance = reconciledBalance; + reconciliation.difference = Math.abs(reconciliation.bookClosingBalance - reconciledBalance); + reconciliation.isBalanced = reconciliation.difference < 0.01; + + await this.reconciliationRepository.save(reconciliation); + } + + async completeReconciliation(ctx: ServiceContext, id: string): Promise { + const reconciliation = await this.findReconciliationById(ctx, id); + if (!reconciliation) { + throw new Error('Conciliación no encontrada'); + } + + if (!reconciliation.isBalanced) { + throw new Error('La conciliación no está balanceada'); + } + + reconciliation.status = 'completed'; + reconciliation.completedAt = new Date(); + reconciliation.updatedBy = ctx.userId; + + // Actualizar cuenta bancaria + const bankAccount = await this.findAccountById(ctx, reconciliation.bankAccountId); + if (bankAccount) { + bankAccount.lastReconciliationDate = reconciliation.periodEnd; + bankAccount.lastReconciledBalance = reconciliation.bankClosingBalance; + await this.bankAccountRepository.save(bankAccount); + } + + return this.reconciliationRepository.save(reconciliation); + } + + async approveReconciliation(ctx: ServiceContext, id: string): Promise { + const reconciliation = await this.findReconciliationById(ctx, id); + if (!reconciliation) { + throw new Error('Conciliación no encontrada'); + } + + if (reconciliation.status !== 'completed') { + throw new Error('Solo se pueden aprobar conciliaciones completadas'); + } + + reconciliation.status = 'approved'; + reconciliation.approvedById = ctx.userId; + reconciliation.approvedAt = new Date(); + reconciliation.updatedBy = ctx.userId; + + return this.reconciliationRepository.save(reconciliation); + } + + // ==================== REPORTES ==================== + + async getBankAccountSummary(ctx: ServiceContext): Promise<{ + totalBalance: number; + byAccount: { id: string; name: string; bankName: string; balance: number; currency: string }[]; + byCurrency: { currency: string; total: number }[]; + }> { + const accounts = await this.bankAccountRepository.find({ + where: { + tenantId: ctx.tenantId, + status: 'active', + deletedAt: IsNull(), + }, + }); + + const byAccount = accounts.map((acc) => ({ + id: acc.id, + name: acc.name, + bankName: acc.bankName, + balance: Number(acc.currentBalance), + currency: acc.currency, + })); + + const byCurrencyMap = new Map(); + for (const acc of accounts) { + const current = byCurrencyMap.get(acc.currency) || 0; + byCurrencyMap.set(acc.currency, current + Number(acc.currentBalance)); + } + + const byCurrency = Array.from(byCurrencyMap.entries()).map(([currency, total]) => ({ + currency, + total, + })); + + const totalBalance = accounts + .filter((acc) => acc.currency === 'MXN') + .reduce((sum, acc) => sum + Number(acc.currentBalance), 0); + + return { + totalBalance, + byAccount, + byCurrency, + }; + } + + async getReconciliationStatus(ctx: ServiceContext): Promise<{ + pendingCount: number; + inProgressCount: number; + accountsNotReconciled: { id: string; name: string; lastReconciliationDate?: Date }[]; + }> { + const pending = await this.reconciliationRepository.count({ + where: { + tenantId: ctx.tenantId, + status: 'draft', + deletedAt: IsNull(), + }, + }); + + const inProgress = await this.reconciliationRepository.count({ + where: { + tenantId: ctx.tenantId, + status: 'in_progress', + deletedAt: IsNull(), + }, + }); + + // Cuentas sin conciliación reciente (más de 30 días) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const accounts = await this.bankAccountRepository.find({ + where: { + tenantId: ctx.tenantId, + status: 'active', + deletedAt: IsNull(), + }, + }); + + const accountsNotReconciled = accounts + .filter( + (acc) => + !acc.lastReconciliationDate || new Date(acc.lastReconciliationDate) < thirtyDaysAgo + ) + .map((acc) => ({ + id: acc.id, + name: acc.name, + lastReconciliationDate: acc.lastReconciliationDate, + })); + + return { + pendingCount: pending, + inProgressCount: inProgress, + accountsNotReconciled, + }; + } +} diff --git a/src/modules/finance/services/cash-flow.service.ts b/src/modules/finance/services/cash-flow.service.ts new file mode 100644 index 0000000..ea52291 --- /dev/null +++ b/src/modules/finance/services/cash-flow.service.ts @@ -0,0 +1,701 @@ +/** + * CashFlowService - Servicio de Flujo de Efectivo + * + * Gestión de proyecciones y análisis de flujo de efectivo. + * + * @module Finance + */ + +import { DataSource, Repository, IsNull, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + CashFlowProjection, + CashFlowType, + CashFlowPeriodType, + AccountPayable, + AccountReceivable, + BankAccount, +} from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface PaginatedResult { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +interface CreateProjectionDto { + flowType: CashFlowType; + periodType: CashFlowPeriodType; + periodStart: Date; + periodEnd: Date; + fiscalYear: number; + fiscalPeriod: number; + projectId?: string; + projectCode?: string; + openingBalance: number; + incomeEstimations?: number; + incomeSales?: number; + incomeAdvances?: number; + incomeOther?: number; + expenseSuppliers?: number; + expenseSubcontractors?: number; + expensePayroll?: number; + expenseTaxes?: number; + expenseOperating?: number; + expenseOther?: number; + investingIncome?: number; + investingExpense?: number; + financingIncome?: number; + financingExpense?: number; + incomeBreakdown?: Record[]; + expenseBreakdown?: Record[]; + notes?: string; + metadata?: Record; +} + +interface CashFlowSummary { + period: string; + periodStart: Date; + periodEnd: Date; + openingBalance: number; + totalIncome: number; + totalExpenses: number; + netOperatingFlow: number; + netInvestingFlow: number; + netFinancingFlow: number; + netCashFlow: number; + closingBalance: number; +} + +export class CashFlowService { + private projectionRepository: Repository; + private apRepository: Repository; + private arRepository: Repository; + private bankAccountRepository: Repository; + + constructor(private dataSource: DataSource) { + this.projectionRepository = dataSource.getRepository(CashFlowProjection); + this.apRepository = dataSource.getRepository(AccountPayable); + this.arRepository = dataSource.getRepository(AccountReceivable); + this.bankAccountRepository = dataSource.getRepository(BankAccount); + } + + // ==================== PROYECCIONES ==================== + + async findAll( + ctx: ServiceContext, + options: { + page?: number; + limit?: number; + flowType?: CashFlowType; + periodType?: CashFlowPeriodType; + projectId?: string; + fiscalYear?: number; + startDate?: Date; + endDate?: Date; + } = {} + ): Promise> { + const { + page = 1, + limit = 20, + flowType, + periodType, + projectId, + fiscalYear, + startDate, + endDate, + } = options; + + const queryBuilder = this.projectionRepository + .createQueryBuilder('cf') + .where('cf.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('cf.deletedAt IS NULL'); + + if (flowType) { + queryBuilder.andWhere('cf.flowType = :flowType', { flowType }); + } + + if (periodType) { + queryBuilder.andWhere('cf.periodType = :periodType', { periodType }); + } + + if (projectId) { + queryBuilder.andWhere('cf.projectId = :projectId', { projectId }); + } + + if (fiscalYear) { + queryBuilder.andWhere('cf.fiscalYear = :fiscalYear', { fiscalYear }); + } + + if (startDate) { + queryBuilder.andWhere('cf.periodStart >= :startDate', { startDate }); + } + + if (endDate) { + queryBuilder.andWhere('cf.periodEnd <= :endDate', { endDate }); + } + + queryBuilder.orderBy('cf.periodStart', 'ASC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.projectionRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + }); + } + + async create(ctx: ServiceContext, data: CreateProjectionDto): Promise { + // Calcular totales + const totalIncome = + (data.incomeEstimations ?? 0) + + (data.incomeSales ?? 0) + + (data.incomeAdvances ?? 0) + + (data.incomeOther ?? 0); + + const totalExpenses = + (data.expenseSuppliers ?? 0) + + (data.expenseSubcontractors ?? 0) + + (data.expensePayroll ?? 0) + + (data.expenseTaxes ?? 0) + + (data.expenseOperating ?? 0) + + (data.expenseOther ?? 0); + + const netOperatingFlow = totalIncome - totalExpenses; + const netInvestingFlow = (data.investingIncome ?? 0) - (data.investingExpense ?? 0); + const netFinancingFlow = (data.financingIncome ?? 0) - (data.financingExpense ?? 0); + const netCashFlow = netOperatingFlow + netInvestingFlow + netFinancingFlow; + const closingBalance = data.openingBalance + netCashFlow; + + const projection = this.projectionRepository.create({ + tenantId: ctx.tenantId, + flowType: data.flowType, + periodType: data.periodType, + periodStart: data.periodStart, + periodEnd: data.periodEnd, + fiscalYear: data.fiscalYear, + fiscalPeriod: data.fiscalPeriod, + projectId: data.projectId, + projectCode: data.projectCode, + openingBalance: data.openingBalance, + incomeEstimations: data.incomeEstimations ?? 0, + incomeSales: data.incomeSales ?? 0, + incomeAdvances: data.incomeAdvances ?? 0, + incomeOther: data.incomeOther ?? 0, + totalIncome, + expenseSuppliers: data.expenseSuppliers ?? 0, + expenseSubcontractors: data.expenseSubcontractors ?? 0, + expensePayroll: data.expensePayroll ?? 0, + expenseTaxes: data.expenseTaxes ?? 0, + expenseOperating: data.expenseOperating ?? 0, + expenseOther: data.expenseOther ?? 0, + totalExpenses, + netOperatingFlow, + investingIncome: data.investingIncome ?? 0, + investingExpense: data.investingExpense ?? 0, + netInvestingFlow, + financingIncome: data.financingIncome ?? 0, + financingExpense: data.financingExpense ?? 0, + netFinancingFlow, + netCashFlow, + closingBalance, + incomeBreakdown: data.incomeBreakdown, + expenseBreakdown: data.expenseBreakdown, + notes: data.notes, + metadata: data.metadata, + isLocked: false, + createdBy: ctx.userId, + }); + + return this.projectionRepository.save(projection); + } + + async update( + ctx: ServiceContext, + id: string, + data: Partial + ): Promise { + const projection = await this.findById(ctx, id); + if (!projection) { + throw new Error('Proyección no encontrada'); + } + + if (projection.isLocked) { + throw new Error('La proyección está bloqueada y no se puede modificar'); + } + + Object.assign(projection, data); + + // Recalcular totales + projection.totalIncome = + projection.incomeEstimations + + projection.incomeSales + + projection.incomeAdvances + + projection.incomeOther; + + projection.totalExpenses = + projection.expenseSuppliers + + projection.expenseSubcontractors + + projection.expensePayroll + + projection.expenseTaxes + + projection.expenseOperating + + projection.expenseOther; + + projection.netOperatingFlow = projection.totalIncome - projection.totalExpenses; + projection.netInvestingFlow = projection.investingIncome - projection.investingExpense; + projection.netFinancingFlow = projection.financingIncome - projection.financingExpense; + projection.netCashFlow = + projection.netOperatingFlow + projection.netInvestingFlow + projection.netFinancingFlow; + projection.closingBalance = projection.openingBalance + projection.netCashFlow; + + projection.updatedBy = ctx.userId; + + return this.projectionRepository.save(projection); + } + + async lock(ctx: ServiceContext, id: string): Promise { + const projection = await this.findById(ctx, id); + if (!projection) { + throw new Error('Proyección no encontrada'); + } + + projection.isLocked = true; + projection.lockedAt = new Date(); + projection.updatedBy = ctx.userId; + + return this.projectionRepository.save(projection); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const projection = await this.findById(ctx, id); + if (!projection) { + throw new Error('Proyección no encontrada'); + } + + if (projection.isLocked) { + throw new Error('No se puede eliminar una proyección bloqueada'); + } + + projection.deletedAt = new Date(); + projection.updatedBy = ctx.userId; + await this.projectionRepository.save(projection); + } + + // ==================== GENERACIÓN AUTOMÁTICA ==================== + + async generateProjection( + ctx: ServiceContext, + periodStart: Date, + periodEnd: Date, + options: { + periodType?: CashFlowPeriodType; + projectId?: string; + projectCode?: string; + } = {} + ): Promise { + const { periodType = 'weekly', projectId, projectCode } = options; + + // Obtener saldo inicial de cuentas bancarias + const bankAccounts = await this.bankAccountRepository.find({ + where: { + tenantId: ctx.tenantId, + status: 'active', + ...(projectId && { projectId }), + }, + }); + + const openingBalance = bankAccounts.reduce( + (sum, acc) => sum + Number(acc.currentBalance), + 0 + ); + + // Obtener cobranza esperada (AR por vencer en el periodo) + const arQuery = this.arRepository + .createQueryBuilder('ar') + .where('ar.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ar.status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .andWhere('ar.dueDate BETWEEN :periodStart AND :periodEnd', { periodStart, periodEnd }) + .andWhere('ar.deletedAt IS NULL'); + + if (projectId) { + arQuery.andWhere('ar.projectId = :projectId', { projectId }); + } + + const arRecords = await arQuery.getMany(); + const incomeEstimations = arRecords + .filter((ar) => ar.documentType === 'estimation') + .reduce((sum, ar) => sum + Number(ar.balanceAmount), 0); + const incomeSales = arRecords + .filter((ar) => ar.documentType !== 'estimation') + .reduce((sum, ar) => sum + Number(ar.balanceAmount), 0); + + // Obtener pagos esperados (AP por vencer en el periodo) + const apQuery = this.apRepository + .createQueryBuilder('ap') + .where('ap.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ap.status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .andWhere('ap.dueDate BETWEEN :periodStart AND :periodEnd', { periodStart, periodEnd }) + .andWhere('ap.deletedAt IS NULL'); + + if (projectId) { + apQuery.andWhere('ap.projectId = :projectId', { projectId }); + } + + const apRecords = await apQuery.getMany(); + const expenseSuppliers = apRecords + .filter((ap) => ap.documentType === 'invoice') + .reduce((sum, ap) => sum + Number(ap.balanceAmount), 0); + + // Determinar año y periodo fiscal + const fiscalYear = periodStart.getFullYear(); + const fiscalPeriod = periodStart.getMonth() + 1; + + // Crear la proyección + return this.create(ctx, { + flowType: 'projected', + periodType, + periodStart, + periodEnd, + fiscalYear, + fiscalPeriod, + projectId, + projectCode, + openingBalance, + incomeEstimations, + incomeSales, + incomeAdvances: 0, + incomeOther: 0, + expenseSuppliers, + expenseSubcontractors: 0, + expensePayroll: 0, + expenseTaxes: 0, + expenseOperating: 0, + expenseOther: 0, + investingIncome: 0, + investingExpense: 0, + financingIncome: 0, + financingExpense: 0, + incomeBreakdown: arRecords.map((ar) => ({ + id: ar.id, + documentNumber: ar.documentNumber, + partnerName: ar.partnerName, + dueDate: ar.dueDate, + amount: ar.balanceAmount, + })), + expenseBreakdown: apRecords.map((ap) => ({ + id: ap.id, + documentNumber: ap.documentNumber, + partnerName: ap.partnerName, + dueDate: ap.dueDate, + amount: ap.balanceAmount, + })), + notes: `Proyección automática generada el ${new Date().toISOString()}`, + }); + } + + async generateMultiplePeriods( + ctx: ServiceContext, + startDate: Date, + weeks: number, + options: { projectId?: string; projectCode?: string } = {} + ): Promise { + const projections: CashFlowProjection[] = []; + let currentStart = new Date(startDate); + + for (let i = 0; i < weeks; i++) { + const periodEnd = new Date(currentStart); + periodEnd.setDate(periodEnd.getDate() + 6); + + const projection = await this.generateProjection(ctx, currentStart, periodEnd, { + periodType: 'weekly', + ...options, + }); + + projections.push(projection); + + // Siguiente semana + currentStart = new Date(periodEnd); + currentStart.setDate(currentStart.getDate() + 1); + } + + return projections; + } + + // ==================== COMPARACIÓN PROYECTADO VS REAL ==================== + + async createComparison( + ctx: ServiceContext, + projectedId: string + ): Promise { + const projected = await this.findById(ctx, projectedId); + if (!projected) { + throw new Error('Proyección no encontrada'); + } + + if (projected.flowType !== 'projected') { + throw new Error('Solo se pueden comparar proyecciones'); + } + + // Generar flujo real para el mismo periodo + const actual = await this.generateProjection(ctx, projected.periodStart, projected.periodEnd, { + periodType: projected.periodType, + projectId: projected.projectId ?? undefined, + projectCode: projected.projectCode ?? undefined, + }); + + // Actualizar a tipo actual + actual.flowType = 'actual'; + await this.projectionRepository.save(actual); + + // Crear registro de comparación + const varianceAmount = actual.netCashFlow - projected.netCashFlow; + const variancePercentage = + projected.netCashFlow !== 0 + ? (varianceAmount / Math.abs(projected.netCashFlow)) * 100 + : 0; + + const comparison = this.projectionRepository.create({ + tenantId: ctx.tenantId, + flowType: 'comparison', + periodType: projected.periodType, + periodStart: projected.periodStart, + periodEnd: projected.periodEnd, + fiscalYear: projected.fiscalYear, + fiscalPeriod: projected.fiscalPeriod, + projectId: projected.projectId, + projectCode: projected.projectCode, + openingBalance: projected.openingBalance, + incomeEstimations: actual.incomeEstimations, + incomeSales: actual.incomeSales, + incomeAdvances: actual.incomeAdvances, + incomeOther: actual.incomeOther, + totalIncome: actual.totalIncome, + expenseSuppliers: actual.expenseSuppliers, + expenseSubcontractors: actual.expenseSubcontractors, + expensePayroll: actual.expensePayroll, + expenseTaxes: actual.expenseTaxes, + expenseOperating: actual.expenseOperating, + expenseOther: actual.expenseOther, + totalExpenses: actual.totalExpenses, + netOperatingFlow: actual.netOperatingFlow, + investingIncome: actual.investingIncome, + investingExpense: actual.investingExpense, + netInvestingFlow: actual.netInvestingFlow, + financingIncome: actual.financingIncome, + financingExpense: actual.financingExpense, + netFinancingFlow: actual.netFinancingFlow, + netCashFlow: actual.netCashFlow, + closingBalance: actual.closingBalance, + projectedAmount: projected.netCashFlow, + actualAmount: actual.netCashFlow, + varianceAmount, + variancePercentage, + notes: `Comparación: Proyectado vs Real`, + metadata: { + projectedId: projected.id, + actualId: actual.id, + }, + createdBy: ctx.userId, + }); + + return this.projectionRepository.save(comparison); + } + + // ==================== REPORTES ==================== + + async getCashFlowSummary( + ctx: ServiceContext, + startDate: Date, + endDate: Date, + options: { + periodType?: CashFlowPeriodType; + flowType?: CashFlowType; + projectId?: string; + } = {} + ): Promise { + const { periodType = 'weekly', flowType = 'projected', projectId } = options; + + const queryBuilder = this.projectionRepository + .createQueryBuilder('cf') + .where('cf.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('cf.flowType = :flowType', { flowType }) + .andWhere('cf.periodType = :periodType', { periodType }) + .andWhere('cf.periodStart >= :startDate', { startDate }) + .andWhere('cf.periodEnd <= :endDate', { endDate }) + .andWhere('cf.deletedAt IS NULL'); + + if (projectId) { + queryBuilder.andWhere('cf.projectId = :projectId', { projectId }); + } + + queryBuilder.orderBy('cf.periodStart', 'ASC'); + + const projections = await queryBuilder.getMany(); + + return projections.map((p) => ({ + period: `${p.periodStart.toISOString().split('T')[0]} - ${p.periodEnd.toISOString().split('T')[0]}`, + periodStart: p.periodStart, + periodEnd: p.periodEnd, + openingBalance: Number(p.openingBalance), + totalIncome: Number(p.totalIncome), + totalExpenses: Number(p.totalExpenses), + netOperatingFlow: Number(p.netOperatingFlow), + netInvestingFlow: Number(p.netInvestingFlow), + netFinancingFlow: Number(p.netFinancingFlow), + netCashFlow: Number(p.netCashFlow), + closingBalance: Number(p.closingBalance), + })); + } + + async getVarianceAnalysis( + ctx: ServiceContext, + fiscalYear: number, + options: { projectId?: string } = {} + ): Promise<{ + periods: { + period: string; + projected: number; + actual: number; + variance: number; + variancePercent: number; + }[]; + totals: { + projected: number; + actual: number; + variance: number; + variancePercent: number; + }; + }> { + const { projectId } = options; + + const queryBuilder = this.projectionRepository + .createQueryBuilder('cf') + .where('cf.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('cf.flowType = :flowType', { flowType: 'comparison' }) + .andWhere('cf.fiscalYear = :fiscalYear', { fiscalYear }) + .andWhere('cf.deletedAt IS NULL'); + + if (projectId) { + queryBuilder.andWhere('cf.projectId = :projectId', { projectId }); + } + + queryBuilder.orderBy('cf.periodStart', 'ASC'); + + const comparisons = await queryBuilder.getMany(); + + const periods = comparisons.map((c) => ({ + period: `${c.periodStart.toISOString().split('T')[0]} - ${c.periodEnd.toISOString().split('T')[0]}`, + projected: Number(c.projectedAmount) || 0, + actual: Number(c.actualAmount) || 0, + variance: Number(c.varianceAmount) || 0, + variancePercent: Number(c.variancePercentage) || 0, + })); + + const totals = periods.reduce( + (acc, p) => ({ + projected: acc.projected + p.projected, + actual: acc.actual + p.actual, + variance: acc.variance + p.variance, + variancePercent: 0, + }), + { projected: 0, actual: 0, variance: 0, variancePercent: 0 } + ); + + totals.variancePercent = + totals.projected !== 0 + ? (totals.variance / Math.abs(totals.projected)) * 100 + : 0; + + return { periods, totals }; + } + + async getDashboardData(ctx: ServiceContext): Promise<{ + currentBalance: number; + projectedIncome: number; + projectedExpenses: number; + projectedNetFlow: number; + cashPosition: { date: Date; balance: number }[]; + }> { + // Saldo actual + const bankAccounts = await this.bankAccountRepository.find({ + where: { + tenantId: ctx.tenantId, + status: 'active', + }, + }); + + const currentBalance = bankAccounts.reduce( + (sum, acc) => sum + Number(acc.currentBalance), + 0 + ); + + // Proyección próximas 4 semanas + const today = new Date(); + const fourWeeksLater = new Date(today); + fourWeeksLater.setDate(fourWeeksLater.getDate() + 28); + + const projections = await this.projectionRepository.find({ + where: { + tenantId: ctx.tenantId, + flowType: 'projected', + periodStart: MoreThanOrEqual(today), + periodEnd: LessThanOrEqual(fourWeeksLater), + deletedAt: IsNull(), + }, + order: { periodStart: 'ASC' }, + }); + + const projectedIncome = projections.reduce((sum, p) => sum + Number(p.totalIncome), 0); + const projectedExpenses = projections.reduce((sum, p) => sum + Number(p.totalExpenses), 0); + const projectedNetFlow = projectedIncome - projectedExpenses; + + // Posición de caja proyectada + const cashPosition: { date: Date; balance: number }[] = [ + { date: today, balance: currentBalance }, + ]; + + let runningBalance = currentBalance; + for (const p of projections) { + runningBalance += Number(p.netCashFlow); + cashPosition.push({ + date: p.periodEnd, + balance: runningBalance, + }); + } + + return { + currentBalance, + projectedIncome, + projectedExpenses, + projectedNetFlow, + cashPosition, + }; + } +} diff --git a/src/modules/finance/services/erp-integration.service.ts b/src/modules/finance/services/erp-integration.service.ts new file mode 100644 index 0000000..3f9c5bd --- /dev/null +++ b/src/modules/finance/services/erp-integration.service.ts @@ -0,0 +1,699 @@ +/** + * ERPIntegrationService - Servicio de Integración con ERPs + * + * Exportación de datos a SAP, CONTPAQi y otros sistemas. + * + * @module Finance + */ + +import { DataSource, Repository, IsNull, Between } from 'typeorm'; +import { + ChartOfAccounts, + AccountingEntry, + AccountingEntryLine, + AccountPayable, + AccountReceivable, + BankMovement, +} from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface ExportConfig { + format: 'csv' | 'xml' | 'json' | 'txt'; + encoding?: string; + delimiter?: string; + includeHeaders?: boolean; +} + +interface SAPExportEntry { + BUKRS: string; // Sociedad + BELNR: string; // Número de documento + GJAHR: number; // Ejercicio + BLART: string; // Clase de documento + BLDAT: string; // Fecha de documento + BUDAT: string; // Fecha de contabilización + MONAT: number; // Periodo + WAERS: string; // Moneda + KURSF: number; // Tipo de cambio + BKTXT: string; // Texto de cabecera + lines: SAPExportLine[]; +} + +interface SAPExportLine { + BUZEI: number; // Posición + BSCHL: string; // Clave de contabilización + HKONT: string; // Cuenta + WRBTR: number; // Importe en moneda del documento + DMBTR: number; // Importe en moneda local + SGTXT: string; // Texto + ZUONR?: string; // Asignación + KOSTL?: string; // Centro de costo + PROJK?: string; // Elemento PEP +} + +interface CONTPAQiPoliza { + Tipo: number; + Folio: number; + Fecha: string; + Concepto: string; + Diario: number; + Movimientos: CONTPAQiMovimiento[]; +} + +interface CONTPAQiMovimiento { + NumMovto: number; + Cuenta: string; + Concepto: string; + Cargo: number; + Abono: number; + Referencia?: string; + Diario?: number; +} + +interface ExportResult { + success: boolean; + format: string; + recordCount: number; + data: string | object; + filename: string; + errors?: string[]; +} + +export class ERPIntegrationService { + private accountRepository: Repository; + private entryRepository: Repository; + private lineRepository: Repository; + private apRepository: Repository; + private arRepository: Repository; + private movementRepository: Repository; + + constructor(private dataSource: DataSource) { + this.accountRepository = dataSource.getRepository(ChartOfAccounts); + this.entryRepository = dataSource.getRepository(AccountingEntry); + this.lineRepository = dataSource.getRepository(AccountingEntryLine); + this.apRepository = dataSource.getRepository(AccountPayable); + this.arRepository = dataSource.getRepository(AccountReceivable); + this.movementRepository = dataSource.getRepository(BankMovement); + } + + // ==================== EXPORTACIÓN SAP ==================== + + async exportToSAP( + ctx: ServiceContext, + periodStart: Date, + periodEnd: Date, + options: { + companyCode?: string; + documentType?: string; + journalNumber?: number; + } = {} + ): Promise { + const { companyCode = '1000', documentType = 'SA', journalNumber = 1 } = options; + + const entries = await this.entryRepository.find({ + where: { + tenantId: ctx.tenantId, + status: 'posted', + entryDate: Between(periodStart, periodEnd), + deletedAt: IsNull(), + }, + relations: ['lines'], + order: { entryDate: 'ASC', entryNumber: 'ASC' }, + }); + + const sapEntries: SAPExportEntry[] = entries.map((entry) => ({ + BUKRS: companyCode, + BELNR: entry.entryNumber.replace(/[^0-9]/g, '').slice(-10).padStart(10, '0'), + GJAHR: entry.fiscalYear, + BLART: this.mapEntryTypeToSAP(entry.entryType), + BLDAT: this.formatDateSAP(entry.entryDate), + BUDAT: this.formatDateSAP(entry.entryDate), + MONAT: entry.fiscalPeriod, + WAERS: entry.currencyCode, + KURSF: entry.exchangeRate, + BKTXT: entry.description.slice(0, 25), + lines: (entry.lines || []).map((line, idx) => ({ + BUZEI: idx + 1, + BSCHL: line.debitAmount > 0 ? '40' : '50', // 40=Debe, 50=Haber + HKONT: line.accountCode.replace(/\./g, '').padStart(10, '0'), + WRBTR: Math.abs(line.debitAmount || line.creditAmount), + DMBTR: Math.abs(line.debitAmount || line.creditAmount) * entry.exchangeRate, + SGTXT: (line.description || entry.description).slice(0, 50), + ZUONR: line.reference?.slice(0, 18), + KOSTL: line.costCenterId?.slice(0, 10), + PROJK: line.projectId?.slice(0, 24), + })), + })); + + // Generar archivo texto para LSMW o BAPI + const lines: string[] = []; + + for (const entry of sapEntries) { + // Cabecera + lines.push( + `H|${entry.BUKRS}|${entry.BELNR}|${entry.GJAHR}|${entry.BLART}|${entry.BLDAT}|${entry.BUDAT}|${entry.MONAT}|${entry.WAERS}|${entry.KURSF}|${entry.BKTXT}` + ); + + // Posiciones + for (const line of entry.lines) { + lines.push( + `L|${line.BUZEI}|${line.BSCHL}|${line.HKONT}|${line.WRBTR}|${line.DMBTR}|${line.SGTXT}|${line.ZUONR || ''}|${line.KOSTL || ''}|${line.PROJK || ''}` + ); + } + } + + const filename = `SAP_POLIZAS_${periodStart.toISOString().split('T')[0]}_${periodEnd.toISOString().split('T')[0]}.txt`; + + return { + success: true, + format: 'SAP-LSMW', + recordCount: entries.length, + data: lines.join('\n'), + filename, + }; + } + + private mapEntryTypeToSAP(entryType: string): string { + const mapping: Record = { + purchase: 'KR', // Factura de acreedor + sale: 'DR', // Factura de deudor + payment: 'KZ', // Pago a acreedor + collection: 'DZ', // Cobro de deudor + payroll: 'PR', // Nómina + adjustment: 'SA', // Documento contable + depreciation: 'AF', // Amortización + transfer: 'SA', // Traspaso + opening: 'AB', // Apertura + closing: 'SB', // Cierre + }; + return mapping[entryType] || 'SA'; + } + + private formatDateSAP(date: Date): string { + const d = new Date(date); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}${month}${day}`; + } + + // ==================== EXPORTACIÓN CONTPAQi ==================== + + async exportToCONTPAQi( + ctx: ServiceContext, + periodStart: Date, + periodEnd: Date, + options: { + polizaTipo?: number; + diario?: number; + } = {} + ): Promise { + const { polizaTipo = 1, diario = 1 } = options; // 1=Diario + + const entries = await this.entryRepository.find({ + where: { + tenantId: ctx.tenantId, + status: 'posted', + entryDate: Between(periodStart, periodEnd), + deletedAt: IsNull(), + }, + relations: ['lines'], + order: { entryDate: 'ASC', entryNumber: 'ASC' }, + }); + + const polizas: CONTPAQiPoliza[] = entries.map((entry, idx) => ({ + Tipo: this.mapEntryTypeToCONTPAQi(entry.entryType), + Folio: idx + 1, + Fecha: this.formatDateCONTPAQi(entry.entryDate), + Concepto: entry.description.slice(0, 200), + Diario: diario, + Movimientos: (entry.lines || []).map((line, lineIdx) => ({ + NumMovto: lineIdx + 1, + Cuenta: line.accountCode, + Concepto: (line.description || entry.description).slice(0, 200), + Cargo: line.debitAmount, + Abono: line.creditAmount, + Referencia: line.reference?.slice(0, 20), + Diario: diario, + })), + })); + + // Formato texto para importación CONTPAQi + const lines: string[] = []; + + for (const poliza of polizas) { + // Cabecera de póliza + lines.push(`P,${poliza.Tipo},${poliza.Folio},${poliza.Fecha},"${poliza.Concepto}",${poliza.Diario}`); + + // Movimientos + for (const mov of poliza.Movimientos) { + lines.push( + `M,${mov.NumMovto},"${mov.Cuenta}","${mov.Concepto}",${mov.Cargo.toFixed(2)},${mov.Abono.toFixed(2)},"${mov.Referencia || ''}"` + ); + } + } + + const filename = `CONTPAQI_POLIZAS_${periodStart.toISOString().split('T')[0]}_${periodEnd.toISOString().split('T')[0]}.txt`; + + return { + success: true, + format: 'CONTPAQi-TXT', + recordCount: entries.length, + data: lines.join('\n'), + filename, + }; + } + + private mapEntryTypeToCONTPAQi(entryType: string): number { + const mapping: Record = { + purchase: 2, // Egresos + sale: 1, // Ingresos + payment: 2, // Egresos + collection: 1, // Ingresos + payroll: 3, // Nómina + adjustment: 4, // Diario + depreciation: 4, // Diario + transfer: 4, // Diario + opening: 4, // Diario + closing: 4, // Diario + }; + return mapping[entryType] || 4; + } + + private formatDateCONTPAQi(date: Date): string { + const d = new Date(date); + const day = String(d.getDate()).padStart(2, '0'); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const year = d.getFullYear(); + return `${day}/${month}/${year}`; + } + + // ==================== EXPORTACIÓN XML CFDI ==================== + + async exportCFDIPolizas( + ctx: ServiceContext, + periodStart: Date, + periodEnd: Date, + options: { + tipoSolicitud?: string; + numOrden?: string; + numTramite?: string; + } = {} + ): Promise { + const { tipoSolicitud = 'AF', numOrden, numTramite } = options; + + const entries = await this.entryRepository.find({ + where: { + tenantId: ctx.tenantId, + status: 'posted', + entryDate: Between(periodStart, periodEnd), + deletedAt: IsNull(), + }, + relations: ['lines'], + order: { entryDate: 'ASC' }, + }); + + // Generar XML según Anexo 24 del SAT + const xml = ` + +${entries.map((entry) => this.generatePolizaXML(entry)).join('\n')} +`; + + const filename = `CFDI_POLIZAS_${periodStart.getFullYear()}${String(periodStart.getMonth() + 1).padStart(2, '0')}.xml`; + + return { + success: true, + format: 'CFDI-XML', + recordCount: entries.length, + data: xml, + filename, + }; + } + + private generatePolizaXML(entry: AccountingEntry): string { + const fecha = new Date(entry.entryDate).toISOString().split('T')[0]; + const tipoPoliza = this.mapEntryTypeToCFDI(entry.entryType); + + const transacciones = (entry.lines || []) + .map((line) => { + return ` `; + }) + .join('\n'); + + return ` +${transacciones} + `; + } + + private mapEntryTypeToCFDI(entryType: string): string { + const mapping: Record = { + purchase: 'Eg', + sale: 'In', + payment: 'Eg', + collection: 'In', + payroll: 'No', + adjustment: 'Di', + depreciation: 'Di', + transfer: 'Di', + opening: 'Di', + closing: 'Di', + }; + return mapping[entryType] || 'Di'; + } + + private escapeXML(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // ==================== EXPORTACIÓN CATÁLOGO DE CUENTAS ==================== + + async exportChartOfAccounts( + ctx: ServiceContext, + format: 'csv' | 'xml' | 'json' = 'csv' + ): Promise { + const accounts = await this.accountRepository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: IsNull(), + }, + order: { code: 'ASC' }, + }); + + let data: string | object; + let filename: string; + + switch (format) { + case 'xml': + data = this.generateChartXML(accounts); + filename = `CATALOGO_CUENTAS.xml`; + break; + + case 'json': + data = accounts.map((acc) => ({ + code: acc.code, + name: acc.name, + type: acc.accountType, + nature: acc.nature, + level: acc.level, + parentCode: acc.parentId, + satCode: acc.satCode, + status: acc.status, + })); + filename = `CATALOGO_CUENTAS.json`; + break; + + default: + const rows = [ + 'Código,Nombre,Tipo,Naturaleza,Nivel,Código SAT,Estado', + ...accounts.map( + (acc) => + `"${acc.code}","${acc.name}","${acc.accountType}","${acc.nature}",${acc.level},"${acc.satCode || ''}","${acc.status}"` + ), + ]; + data = rows.join('\n'); + filename = `CATALOGO_CUENTAS.csv`; + } + + return { + success: true, + format, + recordCount: accounts.length, + data, + filename, + }; + } + + private generateChartXML(accounts: ChartOfAccounts[]): string { + const cuentas = accounts + .map( + (acc) => ` ` + ) + .join('\n'); + + return ` + +${cuentas} +`; + } + + // ==================== EXPORTACIÓN BALANZA ==================== + + async exportTrialBalance( + ctx: ServiceContext, + periodStart: Date, + periodEnd: Date, + format: 'csv' | 'xml' | 'json' = 'csv' + ): Promise { + // Obtener balanza + const accounts = await this.accountRepository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: IsNull(), + acceptsMovements: true, + }, + order: { code: 'ASC' }, + }); + + // Obtener saldos + const balances = await this.lineRepository + .createQueryBuilder('line') + .innerJoin('line.entry', 'entry') + .select([ + 'line.accountCode as "accountCode"', + 'SUM(line.debitAmount) as "debit"', + 'SUM(line.creditAmount) as "credit"', + ]) + .where('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('entry.status = :status', { status: 'posted' }) + .andWhere('entry.entryDate BETWEEN :periodStart AND :periodEnd', { + periodStart, + periodEnd, + }) + .groupBy('line.accountCode') + .getRawMany(); + + const balanceMap = new Map( + balances.map((b) => [ + b.accountCode, + { + debit: parseFloat(b.debit) || 0, + credit: parseFloat(b.credit) || 0, + }, + ]) + ); + + const rows = accounts + .map((acc) => { + const bal = balanceMap.get(acc.code) || { debit: 0, credit: 0 }; + return { + code: acc.code, + name: acc.name, + initialDebit: 0, + initialCredit: 0, + periodDebit: bal.debit, + periodCredit: bal.credit, + finalDebit: bal.debit, + finalCredit: bal.credit, + }; + }) + .filter((r) => r.periodDebit > 0 || r.periodCredit > 0); + + let data: string | object; + let filename: string; + + switch (format) { + case 'xml': + data = this.generateBalanzaXML(rows, periodStart); + filename = `BALANZA_${periodStart.getFullYear()}${String(periodStart.getMonth() + 1).padStart(2, '0')}.xml`; + break; + + case 'json': + data = rows; + filename = `BALANZA_${periodStart.getFullYear()}${String(periodStart.getMonth() + 1).padStart(2, '0')}.json`; + break; + + default: + const csvRows = [ + 'Cuenta,Nombre,Saldo Inicial Debe,Saldo Inicial Haber,Debe,Haber,Saldo Final Debe,Saldo Final Haber', + ...rows.map( + (r) => + `"${r.code}","${r.name}",${r.initialDebit.toFixed(2)},${r.initialCredit.toFixed(2)},${r.periodDebit.toFixed(2)},${r.periodCredit.toFixed(2)},${r.finalDebit.toFixed(2)},${r.finalCredit.toFixed(2)}` + ), + ]; + data = csvRows.join('\n'); + filename = `BALANZA_${periodStart.getFullYear()}${String(periodStart.getMonth() + 1).padStart(2, '0')}.csv`; + } + + return { + success: true, + format, + recordCount: rows.length, + data, + filename, + }; + } + + private generateBalanzaXML( + rows: { + code: string; + name: string; + initialDebit: number; + initialCredit: number; + periodDebit: number; + periodCredit: number; + finalDebit: number; + finalCredit: number; + }[], + periodStart: Date + ): string { + const cuentas = rows + .map( + (r) => ` ` + ) + .join('\n'); + + return ` + +${cuentas} +`; + } + + // ==================== IMPORTACIÓN ==================== + + async importChartOfAccounts( + ctx: ServiceContext, + data: string, + format: 'csv' | 'json' + ): Promise<{ imported: number; errors: string[] }> { + const errors: string[] = []; + let imported = 0; + + let accounts: { + code: string; + name: string; + type: AccountType; + nature: 'debit' | 'credit'; + level: number; + parentCode?: string; + satCode?: string; + }[] = []; + + if (format === 'json') { + accounts = JSON.parse(data); + } else { + const lines = data.split('\n').slice(1); // Skip header + accounts = lines + .filter((line) => line.trim()) + .map((line) => { + const parts = line.split(',').map((p) => p.replace(/"/g, '').trim()); + return { + code: parts[0], + name: parts[1], + type: parts[2] as AccountType, + nature: parts[3] as 'debit' | 'credit', + level: parseInt(parts[4]) || 1, + satCode: parts[5], + }; + }); + } + + for (const acc of accounts) { + try { + const existing = await this.accountRepository.findOne({ + where: { + tenantId: ctx.tenantId, + code: acc.code, + deletedAt: IsNull(), + }, + }); + + if (existing) { + // Actualizar + existing.name = acc.name; + existing.accountType = acc.type; + existing.nature = acc.nature; + existing.level = acc.level; + existing.satCode = acc.satCode; + existing.updatedBy = ctx.userId; + await this.accountRepository.save(existing); + } else { + // Crear + const newAccount = this.accountRepository.create({ + tenantId: ctx.tenantId, + code: acc.code, + name: acc.name, + accountType: acc.type, + nature: acc.nature, + level: acc.level, + satCode: acc.satCode, + fullPath: acc.code, + isGroupAccount: false, + acceptsMovements: true, + status: 'active', + currencyCode: 'MXN', + balance: 0, + createdBy: ctx.userId, + }); + await this.accountRepository.save(newAccount); + } + + imported++; + } catch (error) { + errors.push(`Error en cuenta ${acc.code}: ${(error as Error).message}`); + } + } + + return { imported, errors }; + } +} diff --git a/src/modules/finance/services/financial-reports.service.ts b/src/modules/finance/services/financial-reports.service.ts new file mode 100644 index 0000000..7d3a89a --- /dev/null +++ b/src/modules/finance/services/financial-reports.service.ts @@ -0,0 +1,893 @@ +/** + * FinancialReportsService - Servicio de Reportes Financieros + * + * Generación de estados financieros y reportes contables. + * + * @module Finance + */ + +import { DataSource, Repository, IsNull } from 'typeorm'; +import { + ChartOfAccounts, + AccountType, + AccountingEntry, + AccountingEntryLine, + AccountPayable, + AccountReceivable, + BankAccount, +} from '../entities'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +interface BalanceSheetAccount { + accountId: string; + accountCode: string; + accountName: string; + level: number; + balance: number; + children?: BalanceSheetAccount[]; +} + +interface BalanceSheet { + asOfDate: Date; + assets: { + current: BalanceSheetAccount[]; + nonCurrent: BalanceSheetAccount[]; + totalCurrent: number; + totalNonCurrent: number; + total: number; + }; + liabilities: { + current: BalanceSheetAccount[]; + nonCurrent: BalanceSheetAccount[]; + totalCurrent: number; + totalNonCurrent: number; + total: number; + }; + equity: { + accounts: BalanceSheetAccount[]; + total: number; + }; + totalLiabilitiesAndEquity: number; + isBalanced: boolean; +} + +interface IncomeStatementLine { + accountId: string; + accountCode: string; + accountName: string; + level: number; + amount: number; + children?: IncomeStatementLine[]; +} + +interface IncomeStatement { + periodStart: Date; + periodEnd: Date; + revenue: { + lines: IncomeStatementLine[]; + total: number; + }; + costOfSales: { + lines: IncomeStatementLine[]; + total: number; + }; + grossProfit: number; + operatingExpenses: { + lines: IncomeStatementLine[]; + total: number; + }; + operatingIncome: number; + otherIncome: { + lines: IncomeStatementLine[]; + total: number; + }; + otherExpenses: { + lines: IncomeStatementLine[]; + total: number; + }; + incomeBeforeTax: number; + taxExpense: number; + netIncome: number; +} + +interface CashFlowStatement { + periodStart: Date; + periodEnd: Date; + operatingActivities: { + netIncome: number; + adjustments: { description: string; amount: number }[]; + changesInWorkingCapital: { description: string; amount: number }[]; + netCash: number; + }; + investingActivities: { + items: { description: string; amount: number }[]; + netCash: number; + }; + financingActivities: { + items: { description: string; amount: number }[]; + netCash: number; + }; + netChangeInCash: number; + beginningCash: number; + endingCash: number; +} + +export class FinancialReportsService { + private accountRepository: Repository; + private entryRepository: Repository; + private lineRepository: Repository; + private apRepository: Repository; + private arRepository: Repository; + private bankAccountRepository: Repository; + + constructor(private dataSource: DataSource) { + this.accountRepository = dataSource.getRepository(ChartOfAccounts); + this.entryRepository = dataSource.getRepository(AccountingEntry); + this.lineRepository = dataSource.getRepository(AccountingEntryLine); + this.apRepository = dataSource.getRepository(AccountPayable); + this.arRepository = dataSource.getRepository(AccountReceivable); + this.bankAccountRepository = dataSource.getRepository(BankAccount); + } + + // ==================== BALANCE GENERAL ==================== + + async generateBalanceSheet( + ctx: ServiceContext, + asOfDate: Date, + options: { projectId?: string } = {} + ): Promise { + const { projectId } = options; + + // Obtener todas las cuentas con saldos + const accounts = await this.getAccountBalances(ctx, asOfDate, projectId); + + // Clasificar cuentas + const assets = accounts.filter((acc) => acc.accountType === 'asset'); + const liabilities = accounts.filter((acc) => acc.accountType === 'liability'); + const equity = accounts.filter((acc) => acc.accountType === 'equity'); + + // Construir árbol de activos + const currentAssets = this.buildAccountTree( + assets.filter((acc) => acc.isCurrentAccount) + ); + const nonCurrentAssets = this.buildAccountTree( + assets.filter((acc) => !acc.isCurrentAccount) + ); + + const totalCurrentAssets = currentAssets.reduce((sum, acc) => sum + acc.balance, 0); + const totalNonCurrentAssets = nonCurrentAssets.reduce((sum, acc) => sum + acc.balance, 0); + const totalAssets = totalCurrentAssets + totalNonCurrentAssets; + + // Construir árbol de pasivos + const currentLiabilities = this.buildAccountTree( + liabilities.filter((acc) => acc.isCurrentAccount) + ); + const nonCurrentLiabilities = this.buildAccountTree( + liabilities.filter((acc) => !acc.isCurrentAccount) + ); + + const totalCurrentLiabilities = currentLiabilities.reduce((sum, acc) => sum + acc.balance, 0); + const totalNonCurrentLiabilities = nonCurrentLiabilities.reduce( + (sum, acc) => sum + acc.balance, + 0 + ); + const totalLiabilities = totalCurrentLiabilities + totalNonCurrentLiabilities; + + // Construir árbol de capital + const equityAccounts = this.buildAccountTree(equity); + const totalEquity = equityAccounts.reduce((sum, acc) => sum + acc.balance, 0); + + const totalLiabilitiesAndEquity = totalLiabilities + totalEquity; + const isBalanced = Math.abs(totalAssets - totalLiabilitiesAndEquity) < 0.01; + + return { + asOfDate, + assets: { + current: currentAssets, + nonCurrent: nonCurrentAssets, + totalCurrent: totalCurrentAssets, + totalNonCurrent: totalNonCurrentAssets, + total: totalAssets, + }, + liabilities: { + current: currentLiabilities, + nonCurrent: nonCurrentLiabilities, + totalCurrent: totalCurrentLiabilities, + totalNonCurrent: totalNonCurrentLiabilities, + total: totalLiabilities, + }, + equity: { + accounts: equityAccounts, + total: totalEquity, + }, + totalLiabilitiesAndEquity, + isBalanced, + }; + } + + private async getAccountBalances( + ctx: ServiceContext, + asOfDate: Date, + projectId?: string + ): Promise< + (ChartOfAccounts & { balance: number; isCurrentAccount: boolean })[] + > { + // Obtener todas las cuentas + const accounts = await this.accountRepository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: IsNull(), + acceptsMovements: true, + }, + order: { code: 'ASC' }, + }); + + // Obtener saldos de movimientos + const queryBuilder = this.lineRepository + .createQueryBuilder('line') + .innerJoin('line.entry', 'entry') + .select('line.accountId', 'accountId') + .addSelect('SUM(line.debitAmount)', 'totalDebit') + .addSelect('SUM(line.creditAmount)', 'totalCredit') + .where('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('entry.status = :status', { status: 'posted' }) + .andWhere('entry.entryDate <= :asOfDate', { asOfDate }); + + if (projectId) { + queryBuilder.andWhere('entry.projectId = :projectId', { projectId }); + } + + queryBuilder.groupBy('line.accountId'); + + const balances = await queryBuilder.getRawMany(); + const balanceMap = new Map( + balances.map((b) => [ + b.accountId, + { + debit: parseFloat(b.totalDebit) || 0, + credit: parseFloat(b.totalCredit) || 0, + }, + ]) + ); + + return accounts.map((acc) => { + const bal = balanceMap.get(acc.id) || { debit: 0, credit: 0 }; + let balance: number; + + // Calcular saldo según naturaleza + if (acc.nature === 'debit') { + balance = bal.debit - bal.credit; + } else { + balance = bal.credit - bal.debit; + } + + // Determinar si es cuenta corriente (por código o metadata) + const isCurrentAccount = + acc.code.startsWith('1.1') || // Activo circulante + acc.code.startsWith('2.1') || // Pasivo a corto plazo + (acc.metadata as any)?.isCurrentAccount === true; + + return { + ...acc, + balance, + isCurrentAccount, + }; + }); + } + + private buildAccountTree( + accounts: (ChartOfAccounts & { balance: number })[] + ): BalanceSheetAccount[] { + const rootAccounts = accounts.filter((acc) => !acc.parentId); + + const buildChildren = ( + parentId: string + ): BalanceSheetAccount[] => { + return accounts + .filter((acc) => acc.parentId === parentId) + .map((acc) => { + const children = buildChildren(acc.id); + const childBalance = children.reduce((sum, c) => sum + c.balance, 0); + + return { + accountId: acc.id, + accountCode: acc.code, + accountName: acc.name, + level: acc.level, + balance: acc.balance + childBalance, + children: children.length > 0 ? children : undefined, + }; + }); + }; + + return rootAccounts.map((acc) => { + const children = buildChildren(acc.id); + const childBalance = children.reduce((sum, c) => sum + c.balance, 0); + + return { + accountId: acc.id, + accountCode: acc.code, + accountName: acc.name, + level: acc.level, + balance: acc.balance + childBalance, + children: children.length > 0 ? children : undefined, + }; + }); + } + + // ==================== ESTADO DE RESULTADOS ==================== + + async generateIncomeStatement( + ctx: ServiceContext, + periodStart: Date, + periodEnd: Date, + options: { projectId?: string } = {} + ): Promise { + const { projectId } = options; + + // Obtener movimientos del periodo + const queryBuilder = this.lineRepository + .createQueryBuilder('line') + .innerJoin('line.entry', 'entry') + .innerJoin(ChartOfAccounts, 'account', 'account.id = line.accountId') + .select([ + 'line.accountId as "accountId"', + 'line.accountCode as "accountCode"', + 'account.name as "accountName"', + 'account.accountType as "accountType"', + 'account.level as "level"', + 'account.parentId as "parentId"', + 'SUM(line.debitAmount) as "totalDebit"', + 'SUM(line.creditAmount) as "totalCredit"', + ]) + .where('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('entry.status = :status', { status: 'posted' }) + .andWhere('entry.entryDate BETWEEN :periodStart AND :periodEnd', { + periodStart, + periodEnd, + }) + .andWhere('account.accountType IN (:...types)', { types: ['income', 'expense'] }); + + if (projectId) { + queryBuilder.andWhere('entry.projectId = :projectId', { projectId }); + } + + queryBuilder.groupBy( + 'line.accountId, line.accountCode, account.name, account.accountType, account.level, account.parentId' + ); + + const results = await queryBuilder.getRawMany(); + + // Separar ingresos y gastos + const incomeAccounts = results + .filter((r) => r.accountType === 'income') + .map((r) => ({ + accountId: r.accountId, + accountCode: r.accountCode, + accountName: r.accountName, + level: r.level, + parentId: r.parentId, + amount: (parseFloat(r.totalCredit) || 0) - (parseFloat(r.totalDebit) || 0), + })); + + const expenseAccounts = results + .filter((r) => r.accountType === 'expense') + .map((r) => ({ + accountId: r.accountId, + accountCode: r.accountCode, + accountName: r.accountName, + level: r.level, + parentId: r.parentId, + amount: (parseFloat(r.totalDebit) || 0) - (parseFloat(r.totalCredit) || 0), + })); + + // Clasificar ingresos + const revenue = incomeAccounts.filter((acc) => acc.accountCode.startsWith('4.1')); + const otherIncome = incomeAccounts.filter((acc) => !acc.accountCode.startsWith('4.1')); + + // Clasificar gastos + const costOfSales = expenseAccounts.filter((acc) => acc.accountCode.startsWith('5.1')); + const operatingExpenses = expenseAccounts.filter((acc) => acc.accountCode.startsWith('5.2')); + const otherExpenses = expenseAccounts.filter( + (acc) => !acc.accountCode.startsWith('5.1') && !acc.accountCode.startsWith('5.2') + ); + + // Calcular totales + const totalRevenue = revenue.reduce((sum, acc) => sum + acc.amount, 0); + const totalCostOfSales = costOfSales.reduce((sum, acc) => sum + acc.amount, 0); + const grossProfit = totalRevenue - totalCostOfSales; + + const totalOperatingExpenses = operatingExpenses.reduce((sum, acc) => sum + acc.amount, 0); + const operatingIncome = grossProfit - totalOperatingExpenses; + + const totalOtherIncome = otherIncome.reduce((sum, acc) => sum + acc.amount, 0); + const totalOtherExpenses = otherExpenses.reduce((sum, acc) => sum + acc.amount, 0); + + const incomeBeforeTax = operatingIncome + totalOtherIncome - totalOtherExpenses; + const taxExpense = 0; // TODO: Calcular ISR + const netIncome = incomeBeforeTax - taxExpense; + + return { + periodStart, + periodEnd, + revenue: { + lines: this.buildIncomeTree(revenue), + total: totalRevenue, + }, + costOfSales: { + lines: this.buildIncomeTree(costOfSales), + total: totalCostOfSales, + }, + grossProfit, + operatingExpenses: { + lines: this.buildIncomeTree(operatingExpenses), + total: totalOperatingExpenses, + }, + operatingIncome, + otherIncome: { + lines: this.buildIncomeTree(otherIncome), + total: totalOtherIncome, + }, + otherExpenses: { + lines: this.buildIncomeTree(otherExpenses), + total: totalOtherExpenses, + }, + incomeBeforeTax, + taxExpense, + netIncome, + }; + } + + private buildIncomeTree( + accounts: { + accountId: string; + accountCode: string; + accountName: string; + level: number; + parentId?: string; + amount: number; + }[] + ): IncomeStatementLine[] { + // Por simplicidad, retornamos lista plana ordenada por código + return accounts + .sort((a, b) => a.accountCode.localeCompare(b.accountCode)) + .map((acc) => ({ + accountId: acc.accountId, + accountCode: acc.accountCode, + accountName: acc.accountName, + level: acc.level, + amount: acc.amount, + })); + } + + // ==================== ESTADO DE FLUJO DE EFECTIVO ==================== + + async generateCashFlowStatement( + ctx: ServiceContext, + periodStart: Date, + periodEnd: Date, + options: { projectId?: string } = {} + ): Promise { + const { projectId } = options; + + // Obtener estado de resultados para utilidad neta + const incomeStatement = await this.generateIncomeStatement( + ctx, + periodStart, + periodEnd, + options + ); + + // Obtener saldos de cuentas bancarias + const bankAccounts = await this.bankAccountRepository.find({ + where: { + tenantId: ctx.tenantId, + status: 'active', + ...(projectId && { projectId }), + }, + }); + + const endingCash = bankAccounts.reduce( + (sum, acc) => sum + Number(acc.currentBalance), + 0 + ); + + // Simplificado - en producción se calcularían los cambios reales + const operatingActivities = { + netIncome: incomeStatement.netIncome, + adjustments: [ + { description: 'Depreciación y amortización', amount: 0 }, + { description: 'Provisiones', amount: 0 }, + ], + changesInWorkingCapital: [ + { description: 'Cambio en cuentas por cobrar', amount: 0 }, + { description: 'Cambio en inventarios', amount: 0 }, + { description: 'Cambio en cuentas por pagar', amount: 0 }, + ], + netCash: incomeStatement.netIncome, + }; + + const investingActivities = { + items: [ + { description: 'Compra de activos fijos', amount: 0 }, + { description: 'Venta de activos fijos', amount: 0 }, + ], + netCash: 0, + }; + + const financingActivities = { + items: [ + { description: 'Préstamos recibidos', amount: 0 }, + { description: 'Pagos de préstamos', amount: 0 }, + { description: 'Dividendos pagados', amount: 0 }, + ], + netCash: 0, + }; + + const netChangeInCash = + operatingActivities.netCash + + investingActivities.netCash + + financingActivities.netCash; + + const beginningCash = endingCash - netChangeInCash; + + return { + periodStart, + periodEnd, + operatingActivities, + investingActivities, + financingActivities, + netChangeInCash, + beginningCash, + endingCash, + }; + } + + // ==================== BALANZA DE COMPROBACIÓN ==================== + + async generateTrialBalance( + ctx: ServiceContext, + periodStart: Date, + periodEnd: Date, + options: { projectId?: string; includeZeroBalances?: boolean } = {} + ): Promise<{ + accounts: { + accountCode: string; + accountName: string; + accountType: AccountType; + openingDebit: number; + openingCredit: number; + periodDebit: number; + periodCredit: number; + closingDebit: number; + closingCredit: number; + }[]; + totals: { + openingDebit: number; + openingCredit: number; + periodDebit: number; + periodCredit: number; + closingDebit: number; + closingCredit: number; + }; + }> { + const { projectId, includeZeroBalances = false } = options; + + // Obtener todas las cuentas + const accounts = await this.accountRepository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: IsNull(), + acceptsMovements: true, + }, + order: { code: 'ASC' }, + }); + + // Obtener saldos iniciales (movimientos antes del periodo) + const openingQuery = this.lineRepository + .createQueryBuilder('line') + .innerJoin('line.entry', 'entry') + .select('line.accountId', 'accountId') + .addSelect('SUM(line.debitAmount)', 'debit') + .addSelect('SUM(line.creditAmount)', 'credit') + .where('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('entry.status = :status', { status: 'posted' }) + .andWhere('entry.entryDate < :periodStart', { periodStart }); + + if (projectId) { + openingQuery.andWhere('entry.projectId = :projectId', { projectId }); + } + + openingQuery.groupBy('line.accountId'); + const openingBalances = await openingQuery.getRawMany(); + const openingMap = new Map( + openingBalances.map((b) => [ + b.accountId, + { + debit: parseFloat(b.debit) || 0, + credit: parseFloat(b.credit) || 0, + }, + ]) + ); + + // Obtener movimientos del periodo + const periodQuery = this.lineRepository + .createQueryBuilder('line') + .innerJoin('line.entry', 'entry') + .select('line.accountId', 'accountId') + .addSelect('SUM(line.debitAmount)', 'debit') + .addSelect('SUM(line.creditAmount)', 'credit') + .where('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('entry.status = :status', { status: 'posted' }) + .andWhere('entry.entryDate BETWEEN :periodStart AND :periodEnd', { + periodStart, + periodEnd, + }); + + if (projectId) { + periodQuery.andWhere('entry.projectId = :projectId', { projectId }); + } + + periodQuery.groupBy('line.accountId'); + const periodMovements = await periodQuery.getRawMany(); + const periodMap = new Map( + periodMovements.map((b) => [ + b.accountId, + { + debit: parseFloat(b.debit) || 0, + credit: parseFloat(b.credit) || 0, + }, + ]) + ); + + // Construir balanza + const trialBalance = accounts + .map((acc) => { + const opening = openingMap.get(acc.id) || { debit: 0, credit: 0 }; + const period = periodMap.get(acc.id) || { debit: 0, credit: 0 }; + + // Calcular saldos según naturaleza + let openingDebit = 0; + let openingCredit = 0; + const openingBalance = opening.debit - opening.credit; + + if (acc.nature === 'debit') { + if (openingBalance >= 0) { + openingDebit = openingBalance; + } else { + openingCredit = Math.abs(openingBalance); + } + } else { + if (openingBalance <= 0) { + openingCredit = Math.abs(openingBalance); + } else { + openingDebit = openingBalance; + } + } + + const closingBalance = + openingBalance + period.debit - period.credit; + let closingDebit = 0; + let closingCredit = 0; + + if (acc.nature === 'debit') { + if (closingBalance >= 0) { + closingDebit = closingBalance; + } else { + closingCredit = Math.abs(closingBalance); + } + } else { + if (closingBalance <= 0) { + closingCredit = Math.abs(closingBalance); + } else { + closingDebit = closingBalance; + } + } + + return { + accountCode: acc.code, + accountName: acc.name, + accountType: acc.accountType, + openingDebit, + openingCredit, + periodDebit: period.debit, + periodCredit: period.credit, + closingDebit, + closingCredit, + }; + }) + .filter( + (row) => + includeZeroBalances || + row.openingDebit !== 0 || + row.openingCredit !== 0 || + row.periodDebit !== 0 || + row.periodCredit !== 0 + ); + + // Calcular totales + const totals = trialBalance.reduce( + (acc, row) => ({ + openingDebit: acc.openingDebit + row.openingDebit, + openingCredit: acc.openingCredit + row.openingCredit, + periodDebit: acc.periodDebit + row.periodDebit, + periodCredit: acc.periodCredit + row.periodCredit, + closingDebit: acc.closingDebit + row.closingDebit, + closingCredit: acc.closingCredit + row.closingCredit, + }), + { + openingDebit: 0, + openingCredit: 0, + periodDebit: 0, + periodCredit: 0, + closingDebit: 0, + closingCredit: 0, + } + ); + + return { + accounts: trialBalance, + totals, + }; + } + + // ==================== REPORTES AUXILIARES ==================== + + async generateAccountStatement( + ctx: ServiceContext, + accountId: string, + periodStart: Date, + periodEnd: Date + ): Promise<{ + account: ChartOfAccounts; + openingBalance: number; + movements: { + date: Date; + entryNumber: string; + reference?: string; + description: string; + debit: number; + credit: number; + balance: number; + }[]; + closingBalance: number; + }> { + const account = await this.accountRepository.findOne({ + where: { id: accountId, tenantId: ctx.tenantId }, + }); + + if (!account) { + throw new Error('Cuenta no encontrada'); + } + + // Saldo de apertura + const openingResult = await this.lineRepository + .createQueryBuilder('line') + .innerJoin('line.entry', 'entry') + .select('SUM(line.debitAmount)', 'debit') + .addSelect('SUM(line.creditAmount)', 'credit') + .where('line.accountId = :accountId', { accountId }) + .andWhere('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('entry.status = :status', { status: 'posted' }) + .andWhere('entry.entryDate < :periodStart', { periodStart }) + .getRawOne(); + + const openingDebit = parseFloat(openingResult?.debit) || 0; + const openingCredit = parseFloat(openingResult?.credit) || 0; + let openingBalance = + account.nature === 'debit' + ? openingDebit - openingCredit + : openingCredit - openingDebit; + + // Movimientos del periodo + const lines = await this.lineRepository + .createQueryBuilder('line') + .innerJoinAndSelect('line.entry', 'entry') + .where('line.accountId = :accountId', { accountId }) + .andWhere('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('entry.status = :status', { status: 'posted' }) + .andWhere('entry.entryDate BETWEEN :periodStart AND :periodEnd', { + periodStart, + periodEnd, + }) + .orderBy('entry.entryDate', 'ASC') + .addOrderBy('entry.entryNumber', 'ASC') + .getMany(); + + let runningBalance = openingBalance; + const movements = lines.map((line) => { + if (account.nature === 'debit') { + runningBalance += line.debitAmount - line.creditAmount; + } else { + runningBalance += line.creditAmount - line.debitAmount; + } + + return { + date: line.entry!.entryDate, + entryNumber: line.entry!.entryNumber, + reference: line.entry!.reference, + description: line.description || line.entry!.description, + debit: line.debitAmount, + credit: line.creditAmount, + balance: runningBalance, + }; + }); + + return { + account, + openingBalance, + movements, + closingBalance: runningBalance, + }; + } + + async getFinancialSummary(ctx: ServiceContext): Promise<{ + totalAssets: number; + totalLiabilities: number; + totalEquity: number; + currentRatio: number; + cashPosition: number; + accountsReceivable: number; + accountsPayable: number; + workingCapital: number; + }> { + const today = new Date(); + + // Balance general simplificado + const balanceSheet = await this.generateBalanceSheet(ctx, today); + + // Cuentas por cobrar + const arResult = await this.arRepository + .createQueryBuilder('ar') + .select('SUM(ar.balanceAmount)', 'total') + .where('ar.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ar.status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .andWhere('ar.deletedAt IS NULL') + .getRawOne(); + + // Cuentas por pagar + const apResult = await this.apRepository + .createQueryBuilder('ap') + .select('SUM(ap.balanceAmount)', 'total') + .where('ap.tenantId = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ap.status IN (:...statuses)', { statuses: ['pending', 'partial'] }) + .andWhere('ap.deletedAt IS NULL') + .getRawOne(); + + // Efectivo + const bankAccounts = await this.bankAccountRepository.find({ + where: { + tenantId: ctx.tenantId, + status: 'active', + }, + }); + + const cashPosition = bankAccounts.reduce( + (sum, acc) => sum + Number(acc.currentBalance), + 0 + ); + + const accountsReceivable = parseFloat(arResult?.total) || 0; + const accountsPayable = parseFloat(apResult?.total) || 0; + + const currentAssets = balanceSheet.assets.totalCurrent; + const currentLiabilities = balanceSheet.liabilities.totalCurrent; + const currentRatio = currentLiabilities > 0 ? currentAssets / currentLiabilities : 0; + const workingCapital = currentAssets - currentLiabilities; + + return { + totalAssets: balanceSheet.assets.total, + totalLiabilities: balanceSheet.liabilities.total, + totalEquity: balanceSheet.equity.total, + currentRatio: Math.round(currentRatio * 100) / 100, + cashPosition, + accountsReceivable, + accountsPayable, + workingCapital, + }; + } +} diff --git a/src/modules/finance/services/index.ts b/src/modules/finance/services/index.ts new file mode 100644 index 0000000..a9eb6c8 --- /dev/null +++ b/src/modules/finance/services/index.ts @@ -0,0 +1,12 @@ +/** + * Finance Services Index + * @module Finance + */ + +export { AccountingService } from './accounting.service'; +export { APService } from './ap.service'; +export { ARService } from './ar.service'; +export { CashFlowService } from './cash-flow.service'; +export { BankReconciliationService } from './bank-reconciliation.service'; +export { FinancialReportsService } from './financial-reports.service'; +export { ERPIntegrationService } from './erp-integration.service'; diff --git a/src/modules/hr/controllers/employee.controller.ts b/src/modules/hr/controllers/employee.controller.ts new file mode 100644 index 0000000..9612614 --- /dev/null +++ b/src/modules/hr/controllers/employee.controller.ts @@ -0,0 +1,342 @@ +/** + * EmployeeController - Controller de empleados + * + * Endpoints REST para gestión de empleados y asignaciones a obras. + * + * @module HR + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + EmployeeService, + CreateEmployeeDto, + UpdateEmployeeDto, + AssignFraccionamientoDto, + EmployeeFilters, +} from '../services/employee.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Employee } from '../entities/employee.entity'; +import { EmployeeFraccionamiento } from '../entities/employee-fraccionamiento.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de empleados + */ +export function createEmployeeController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const employeeRepository = dataSource.getRepository(Employee); + const asignacionRepository = dataSource.getRepository(EmployeeFraccionamiento); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const employeeService = new EmployeeService(employeeRepository, asignacionRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /empleados + * Listar empleados con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: EmployeeFilters = { + estado: req.query.estado as any, + puestoId: req.query.puestoId as string, + departamento: req.query.departamento as string, + fraccionamientoId: req.query.fraccionamientoId as string, + search: req.query.search as string, + }; + + const result = await employeeService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /empleados/stats + * Obtener estadísticas de empleados + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await employeeService.getStats(getContext(req)); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /empleados/by-fraccionamiento/:fraccionamientoId + * Obtener empleados asignados a un fraccionamiento + */ + router.get('/by-fraccionamiento/:fraccionamientoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const employees = await employeeService.getEmployeesByFraccionamiento( + getContext(req), + req.params.fraccionamientoId + ); + res.status(200).json({ success: true, data: employees }); + } catch (error) { + next(error); + } + }); + + /** + * GET /empleados/:id + * Obtener empleado por ID con detalles + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const employee = await employeeService.findById(getContext(req), req.params.id); + if (!employee) { + res.status(404).json({ error: 'Not Found', message: 'Employee not found' }); + return; + } + + res.status(200).json({ success: true, data: employee }); + } catch (error) { + next(error); + } + }); + + /** + * POST /empleados + * Crear empleado + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateEmployeeDto = req.body; + + if (!dto.codigo || !dto.nombre || !dto.apellidoPaterno || !dto.fechaIngreso) { + res.status(400).json({ + error: 'Bad Request', + message: 'codigo, nombre, apellidoPaterno and fechaIngreso are required', + }); + return; + } + + const employee = await employeeService.create(getContext(req), dto); + res.status(201).json({ success: true, data: employee }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /empleados/:id + * Actualizar empleado + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateEmployeeDto = req.body; + const employee = await employeeService.update(getContext(req), req.params.id, dto); + + if (!employee) { + res.status(404).json({ error: 'Not Found', message: 'Employee not found' }); + return; + } + + res.status(200).json({ success: true, data: employee }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /empleados/:id/status + * Cambiar estado del empleado + */ + router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { estado, fechaBaja } = req.body; + + if (!estado || !['activo', 'inactivo', 'baja'].includes(estado)) { + res.status(400).json({ + error: 'Bad Request', + message: 'estado must be one of: activo, inactivo, baja', + }); + return; + } + + const employee = await employeeService.changeStatus( + getContext(req), + req.params.id, + estado, + fechaBaja ? new Date(fechaBaja) : undefined + ); + + if (!employee) { + res.status(404).json({ error: 'Not Found', message: 'Employee not found' }); + return; + } + + res.status(200).json({ + success: true, + data: employee, + message: `Employee status changed to ${estado}`, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /empleados/:id/assign + * Asignar empleado a fraccionamiento + */ + router.post('/:id/assign', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: AssignFraccionamientoDto = req.body; + + if (!dto.fraccionamientoId || !dto.fechaInicio) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId and fechaInicio are required', + }); + return; + } + + dto.fechaInicio = new Date(dto.fechaInicio); + if (dto.fechaFin) { + dto.fechaFin = new Date(dto.fechaFin); + } + + const asignacion = await employeeService.assignToFraccionamiento( + getContext(req), + req.params.id, + dto + ); + + res.status(201).json({ + success: true, + data: asignacion, + message: 'Employee assigned to project', + }); + } catch (error) { + if (error instanceof Error && error.message === 'Employee not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * DELETE /empleados/:id/assign/:fraccionamientoId + * Remover empleado de fraccionamiento + */ + router.delete('/:id/assign/:fraccionamientoId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const removed = await employeeService.removeFromFraccionamiento( + getContext(req), + req.params.id, + req.params.fraccionamientoId + ); + + if (!removed) { + res.status(404).json({ error: 'Not Found', message: 'Assignment not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Employee removed from project' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createEmployeeController; diff --git a/src/modules/hr/controllers/index.ts b/src/modules/hr/controllers/index.ts new file mode 100644 index 0000000..83999b8 --- /dev/null +++ b/src/modules/hr/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * HR Controllers Index + * @module HR + */ + +export { createPuestoController } from './puesto.controller'; +export { createEmployeeController } from './employee.controller'; diff --git a/src/modules/hr/controllers/puesto.controller.ts b/src/modules/hr/controllers/puesto.controller.ts new file mode 100644 index 0000000..461c6d2 --- /dev/null +++ b/src/modules/hr/controllers/puesto.controller.ts @@ -0,0 +1,193 @@ +/** + * PuestoController - Controller de puestos de trabajo + * + * Endpoints REST para gestión de catálogo de puestos. + * + * @module HR + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { PuestoService, CreatePuestoDto, UpdatePuestoDto, PuestoFilters } from '../services/puesto.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Puesto } from '../entities/puesto.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de puestos + */ +export function createPuestoController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const puestoRepository = dataSource.getRepository(Puesto); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const puestoService = new PuestoService(puestoRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /puestos + * Listar puestos con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: PuestoFilters = { + activo: req.query.activo === 'true' ? true : req.query.activo === 'false' ? false : undefined, + nivelRiesgo: req.query.nivelRiesgo as string, + search: req.query.search as string, + }; + + const result = await puestoService.findAll(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /puestos/:id + * Obtener puesto por ID con empleados asignados + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const puesto = await puestoService.findById(getContext(req), req.params.id); + if (!puesto) { + res.status(404).json({ error: 'Not Found', message: 'Position not found' }); + return; + } + + res.status(200).json({ success: true, data: puesto }); + } catch (error) { + next(error); + } + }); + + /** + * POST /puestos + * Crear puesto + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreatePuestoDto = req.body; + + if (!dto.codigo || !dto.nombre) { + res.status(400).json({ error: 'Bad Request', message: 'codigo and nombre are required' }); + return; + } + + const puesto = await puestoService.create(getContext(req), dto); + res.status(201).json({ success: true, data: puesto }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /puestos/:id + * Actualizar puesto + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdatePuestoDto = req.body; + const puesto = await puestoService.update(getContext(req), req.params.id, dto); + + if (!puesto) { + res.status(404).json({ error: 'Not Found', message: 'Position not found' }); + return; + } + + res.status(200).json({ success: true, data: puesto }); + } catch (error) { + next(error); + } + }); + + /** + * POST /puestos/:id/toggle-active + * Activar/desactivar puesto + */ + router.post('/:id/toggle-active', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const puesto = await puestoService.toggleActive(getContext(req), req.params.id); + + if (!puesto) { + res.status(404).json({ error: 'Not Found', message: 'Position not found' }); + return; + } + + res.status(200).json({ + success: true, + data: puesto, + message: puesto.activo ? 'Position activated' : 'Position deactivated', + }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createPuestoController; diff --git a/src/modules/hr/entities/employee-fraccionamiento.entity.ts b/src/modules/hr/entities/employee-fraccionamiento.entity.ts new file mode 100644 index 0000000..012f74a --- /dev/null +++ b/src/modules/hr/entities/employee-fraccionamiento.entity.ts @@ -0,0 +1,65 @@ +/** + * EmployeeFraccionamiento Entity + * Asignación de empleados a obras/fraccionamientos + * + * @module HR + * @table hr.employee_fraccionamientos + * @ddl schemas/02-hr-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Employee } from './employee.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +@Entity({ schema: 'hr', name: 'employee_fraccionamientos' }) +@Index(['employeeId', 'fraccionamientoId', 'fechaInicio'], { unique: true }) +export class EmployeeFraccionamiento { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'fecha_inicio', type: 'date' }) + fechaInicio: Date; + + @Column({ name: 'fecha_fin', type: 'date', nullable: true }) + fechaFin: Date; + + @Column({ type: 'varchar', length: 50, nullable: true }) + rol: string; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Employee, (e) => e.asignaciones) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; +} diff --git a/src/modules/hr/entities/employee.entity.ts b/src/modules/hr/entities/employee.entity.ts new file mode 100644 index 0000000..b4be02f --- /dev/null +++ b/src/modules/hr/entities/employee.entity.ts @@ -0,0 +1,136 @@ +/** + * Employee Entity + * Empleados de la empresa + * + * @module HR + * @table hr.employees + * @ddl schemas/02-hr-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Puesto } from './puesto.entity'; +import { EmployeeFraccionamiento } from './employee-fraccionamiento.entity'; + +export type EstadoEmpleado = 'activo' | 'inactivo' | 'baja'; +export type Genero = 'M' | 'F'; + +@Entity({ schema: 'hr', name: 'employees' }) +@Index(['tenantId', 'codigo'], { unique: true }) +@Index(['tenantId', 'curp'], { unique: true }) +export class Employee { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + codigo: string; + + @Column({ type: 'varchar', length: 100 }) + nombre: string; + + @Column({ name: 'apellido_paterno', type: 'varchar', length: 100 }) + apellidoPaterno: string; + + @Column({ name: 'apellido_materno', type: 'varchar', length: 100, nullable: true }) + apellidoMaterno: string; + + @Column({ type: 'varchar', length: 18, nullable: true }) + curp: string; + + @Column({ type: 'varchar', length: 13, nullable: true }) + rfc: string; + + @Column({ type: 'varchar', length: 11, nullable: true }) + nss: string; + + @Column({ name: 'fecha_nacimiento', type: 'date', nullable: true }) + fechaNacimiento: Date; + + @Column({ type: 'varchar', length: 1, nullable: true }) + genero: Genero; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + telefono: string; + + @Column({ type: 'text', nullable: true }) + direccion: string; + + @Column({ name: 'fecha_ingreso', type: 'date' }) + fechaIngreso: Date; + + @Column({ name: 'fecha_baja', type: 'date', nullable: true }) + fechaBaja: Date; + + @Column({ name: 'puesto_id', type: 'uuid', nullable: true }) + puestoId: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + departamento: string; + + @Column({ name: 'tipo_contrato', type: 'varchar', length: 50, nullable: true }) + tipoContrato: string; + + @Column({ + name: 'salario_diario', + type: 'decimal', + precision: 10, + scale: 2, + nullable: true + }) + salarioDiario: number; + + @Column({ type: 'varchar', length: 20, default: 'activo' }) + estado: EstadoEmpleado; + + @Column({ name: 'foto_url', type: 'varchar', length: 500, nullable: true }) + fotoUrl: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Puesto, (p) => p.empleados) + @JoinColumn({ name: 'puesto_id' }) + puesto: Puesto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => EmployeeFraccionamiento, (ef) => ef.employee) + asignaciones: EmployeeFraccionamiento[]; + + // Computed property + get nombreCompleto(): string { + return [this.nombre, this.apellidoPaterno, this.apellidoMaterno] + .filter(Boolean) + .join(' '); + } +} diff --git a/src/modules/hr/entities/index.ts b/src/modules/hr/entities/index.ts new file mode 100644 index 0000000..48752f0 --- /dev/null +++ b/src/modules/hr/entities/index.ts @@ -0,0 +1,8 @@ +/** + * HR Entities Index + * @module HR + */ + +export * from './puesto.entity'; +export * from './employee.entity'; +export * from './employee-fraccionamiento.entity'; diff --git a/src/modules/hr/entities/puesto.entity.ts b/src/modules/hr/entities/puesto.entity.ts new file mode 100644 index 0000000..26d89f3 --- /dev/null +++ b/src/modules/hr/entities/puesto.entity.ts @@ -0,0 +1,68 @@ +/** + * Puesto Entity + * Catálogo de puestos de trabajo + * + * @module HR + * @table hr.puestos + * @ddl schemas/02-hr-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Employee } from './employee.entity'; + +@Entity({ schema: 'hr', name: 'puestos' }) +@Index(['tenantId', 'codigo'], { unique: true }) +export class Puesto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + codigo: string; + + @Column({ type: 'varchar', length: 100 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ name: 'nivel_riesgo', type: 'varchar', length: 20, nullable: true }) + nivelRiesgo: string; + + @Column({ + name: 'requiere_capacitacion_especial', + type: 'boolean', + default: false + }) + requiereCapacitacionEspecial: boolean; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @OneToMany(() => Employee, (e) => e.puesto) + empleados: Employee[]; +} diff --git a/src/modules/hr/services/employee.service.ts b/src/modules/hr/services/employee.service.ts new file mode 100644 index 0000000..d52cf2e --- /dev/null +++ b/src/modules/hr/services/employee.service.ts @@ -0,0 +1,330 @@ +/** + * EmployeeService - Servicio para gestión de empleados + * + * CRUD de empleados con gestión de estado y asignaciones a obras. + * + * @module HR + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Employee, EstadoEmpleado, Genero } from '../entities/employee.entity'; +import { EmployeeFraccionamiento } from '../entities/employee-fraccionamiento.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateEmployeeDto { + codigo: string; + nombre: string; + apellidoPaterno: string; + apellidoMaterno?: string; + curp?: string; + rfc?: string; + nss?: string; + fechaNacimiento?: Date; + genero?: Genero; + email?: string; + telefono?: string; + direccion?: string; + fechaIngreso: Date; + puestoId?: string; + departamento?: string; + tipoContrato?: string; + salarioDiario?: number; + fotoUrl?: string; +} + +export interface UpdateEmployeeDto { + nombre?: string; + apellidoPaterno?: string; + apellidoMaterno?: string; + curp?: string; + rfc?: string; + nss?: string; + fechaNacimiento?: Date; + genero?: Genero; + email?: string; + telefono?: string; + direccion?: string; + puestoId?: string; + departamento?: string; + tipoContrato?: string; + salarioDiario?: number; + fotoUrl?: string; +} + +export interface AssignFraccionamientoDto { + fraccionamientoId: string; + fechaInicio: Date; + fechaFin?: Date; + rol?: string; +} + +export interface EmployeeFilters { + estado?: EstadoEmpleado; + puestoId?: string; + departamento?: string; + fraccionamientoId?: string; + search?: string; +} + +export interface EmployeeStats { + total: number; + activos: number; + inactivos: number; + bajas: number; + porDepartamento: { departamento: string; count: number }[]; +} + +export class EmployeeService { + constructor( + private readonly employeeRepository: Repository, + private readonly asignacionRepository: Repository + ) {} + + async findWithFilters( + ctx: ServiceContext, + filters: EmployeeFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.employeeRepository + .createQueryBuilder('employee') + .leftJoinAndSelect('employee.puesto', 'puesto') + .where('employee.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.estado) { + queryBuilder.andWhere('employee.estado = :estado', { estado: filters.estado }); + } + + if (filters.puestoId) { + queryBuilder.andWhere('employee.puesto_id = :puestoId', { puestoId: filters.puestoId }); + } + + if (filters.departamento) { + queryBuilder.andWhere('employee.departamento = :departamento', { departamento: filters.departamento }); + } + + if (filters.fraccionamientoId) { + queryBuilder + .innerJoin('employee.asignaciones', 'asig') + .andWhere('asig.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId: filters.fraccionamientoId }) + .andWhere('asig.activo = true'); + } + + if (filters.search) { + queryBuilder.andWhere( + '(employee.codigo ILIKE :search OR employee.nombre ILIKE :search OR employee.apellido_paterno ILIKE :search OR employee.curp ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + queryBuilder + .orderBy('employee.apellido_paterno', 'ASC') + .addOrderBy('employee.nombre', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.employeeRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + relations: ['puesto', 'asignaciones', 'asignaciones.fraccionamiento'], + }); + } + + async findByCodigo(ctx: ServiceContext, codigo: string): Promise { + return this.employeeRepository.findOne({ + where: { + codigo, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + } + + async findByCurp(ctx: ServiceContext, curp: string): Promise { + return this.employeeRepository.findOne({ + where: { + curp, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + } + + async create(ctx: ServiceContext, dto: CreateEmployeeDto): Promise { + // Validate unique codigo + const existingCodigo = await this.findByCodigo(ctx, dto.codigo); + if (existingCodigo) { + throw new Error(`Employee with codigo ${dto.codigo} already exists`); + } + + // Validate unique CURP if provided + if (dto.curp) { + const existingCurp = await this.findByCurp(ctx, dto.curp); + if (existingCurp) { + throw new Error(`Employee with CURP ${dto.curp} already exists`); + } + } + + const employee = this.employeeRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + estado: 'activo', + ...dto, + }); + + return this.employeeRepository.save(employee); + } + + async update(ctx: ServiceContext, id: string, dto: UpdateEmployeeDto): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return null; + } + + // Validate unique CURP if changed + if (dto.curp && dto.curp !== existing.curp) { + const existingCurp = await this.findByCurp(ctx, dto.curp); + if (existingCurp) { + throw new Error(`Employee with CURP ${dto.curp} already exists`); + } + } + + const updated = this.employeeRepository.merge(existing, dto); + return this.employeeRepository.save(updated); + } + + async changeStatus(ctx: ServiceContext, id: string, estado: EstadoEmpleado, fechaBaja?: Date): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return null; + } + + existing.estado = estado; + if (estado === 'baja' && fechaBaja) { + existing.fechaBaja = fechaBaja; + } + + return this.employeeRepository.save(existing); + } + + async assignToFraccionamiento( + ctx: ServiceContext, + employeeId: string, + dto: AssignFraccionamientoDto + ): Promise { + const employee = await this.findById(ctx, employeeId); + if (!employee) { + throw new Error('Employee not found'); + } + + // Deactivate previous active assignment to same fraccionamiento + await this.asignacionRepository.update( + { + tenantId: ctx.tenantId, + employeeId, + fraccionamientoId: dto.fraccionamientoId, + activo: true, + } as FindOptionsWhere, + { activo: false, fechaFin: new Date() } + ); + + const asignacion = this.asignacionRepository.create({ + tenantId: ctx.tenantId, + employeeId, + fraccionamientoId: dto.fraccionamientoId, + fechaInicio: dto.fechaInicio, + fechaFin: dto.fechaFin, + rol: dto.rol, + activo: true, + }); + + return this.asignacionRepository.save(asignacion); + } + + async removeFromFraccionamiento( + ctx: ServiceContext, + employeeId: string, + fraccionamientoId: string + ): Promise { + const result = await this.asignacionRepository.update( + { + tenantId: ctx.tenantId, + employeeId, + fraccionamientoId, + activo: true, + } as FindOptionsWhere, + { activo: false, fechaFin: new Date() } + ); + + return (result.affected ?? 0) > 0; + } + + async getEmployeesByFraccionamiento( + ctx: ServiceContext, + fraccionamientoId: string + ): Promise { + const asignaciones = await this.asignacionRepository.find({ + where: { + tenantId: ctx.tenantId, + fraccionamientoId, + activo: true, + } as FindOptionsWhere, + relations: ['employee', 'employee.puesto'], + }); + + return asignaciones.map(a => a.employee); + } + + async getStats(ctx: ServiceContext): Promise { + const [total, activos, inactivos, bajas] = await Promise.all([ + this.employeeRepository.count({ + where: { tenantId: ctx.tenantId } as FindOptionsWhere, + }), + this.employeeRepository.count({ + where: { tenantId: ctx.tenantId, estado: 'activo' } as FindOptionsWhere, + }), + this.employeeRepository.count({ + where: { tenantId: ctx.tenantId, estado: 'inactivo' } as FindOptionsWhere, + }), + this.employeeRepository.count({ + where: { tenantId: ctx.tenantId, estado: 'baja' } as FindOptionsWhere, + }), + ]); + + const porDepartamento = await this.employeeRepository + .createQueryBuilder('employee') + .select('employee.departamento', 'departamento') + .addSelect('COUNT(*)', 'count') + .where('employee.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('employee.estado = :estado', { estado: 'activo' }) + .groupBy('employee.departamento') + .getRawMany(); + + return { + total, + activos, + inactivos, + bajas, + porDepartamento: porDepartamento.map(p => ({ + departamento: p.departamento || 'Sin departamento', + count: parseInt(p.count, 10), + })), + }; + } +} diff --git a/src/modules/hr/services/index.ts b/src/modules/hr/services/index.ts new file mode 100644 index 0000000..ef97794 --- /dev/null +++ b/src/modules/hr/services/index.ts @@ -0,0 +1,7 @@ +/** + * HR Services Index + * @module HR + */ + +export * from './puesto.service'; +export * from './employee.service'; diff --git a/src/modules/hr/services/puesto.service.ts b/src/modules/hr/services/puesto.service.ts new file mode 100644 index 0000000..a58a7d1 --- /dev/null +++ b/src/modules/hr/services/puesto.service.ts @@ -0,0 +1,149 @@ +/** + * PuestoService - Servicio para catálogo de puestos + * + * Gestión de puestos de trabajo con CRUD básico. + * + * @module HR + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Puesto } from '../entities/puesto.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreatePuestoDto { + codigo: string; + nombre: string; + descripcion?: string; + nivelRiesgo?: string; + requiereCapacitacionEspecial?: boolean; +} + +export interface UpdatePuestoDto { + nombre?: string; + descripcion?: string; + nivelRiesgo?: string; + requiereCapacitacionEspecial?: boolean; + activo?: boolean; +} + +export interface PuestoFilters { + activo?: boolean; + nivelRiesgo?: string; + search?: string; +} + +export class PuestoService { + constructor(private readonly repository: Repository) {} + + async findAll( + ctx: ServiceContext, + filters: PuestoFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.repository + .createQueryBuilder('puesto') + .where('puesto.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.activo !== undefined) { + queryBuilder.andWhere('puesto.activo = :activo', { activo: filters.activo }); + } + + if (filters.nivelRiesgo) { + queryBuilder.andWhere('puesto.nivel_riesgo = :nivelRiesgo', { nivelRiesgo: filters.nivelRiesgo }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(puesto.codigo ILIKE :search OR puesto.nombre ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + queryBuilder + .orderBy('puesto.nombre', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + relations: ['empleados'], + }); + } + + async findByCodigo(ctx: ServiceContext, codigo: string): Promise { + return this.repository.findOne({ + where: { + codigo, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + } + + async create(ctx: ServiceContext, dto: CreatePuestoDto): Promise { + const existing = await this.findByCodigo(ctx, dto.codigo); + if (existing) { + throw new Error(`Puesto with codigo ${dto.codigo} already exists`); + } + + const puesto = this.repository.create({ + tenantId: ctx.tenantId, + codigo: dto.codigo, + nombre: dto.nombre, + descripcion: dto.descripcion, + nivelRiesgo: dto.nivelRiesgo, + requiereCapacitacionEspecial: dto.requiereCapacitacionEspecial || false, + activo: true, + }); + + return this.repository.save(puesto); + } + + async update(ctx: ServiceContext, id: string, dto: UpdatePuestoDto): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return null; + } + + const updated = this.repository.merge(existing, dto); + return this.repository.save(updated); + } + + async toggleActive(ctx: ServiceContext, id: string): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return null; + } + + existing.activo = !existing.activo; + return this.repository.save(existing); + } + + async getActiveCount(ctx: ServiceContext): Promise { + return this.repository.count({ + where: { + tenantId: ctx.tenantId, + activo: true, + } as FindOptionsWhere, + }); + } +} diff --git a/src/modules/hse/controllers/ambiental.controller.ts b/src/modules/hse/controllers/ambiental.controller.ts new file mode 100644 index 0000000..6364c8e --- /dev/null +++ b/src/modules/hse/controllers/ambiental.controller.ts @@ -0,0 +1,598 @@ +/** + * AmbientalController - Controller de gestión ambiental HSE + * + * Endpoints REST para gestión de residuos y manifiestos. + * + * @module HSE + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + AmbientalService, + CreateResiduoCatalogoDto, + CreateGeneracionDto, + CreateManifiestoDto, + AddDetalleManifiestoDto, + CreateImpactoDto, + CreateQuejaDto, + ResiduoFilters, + GeneracionFilters, + ManifiestoFilters, + ImpactoFilters, + QuejaFilters, +} from '../services/ambiental.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { ResiduoCatalogo } from '../entities/residuo-catalogo.entity'; +import { ResiduoGeneracion, EstadoResiduo } from '../entities/residuo-generacion.entity'; +import { AlmacenTemporal } from '../entities/almacen-temporal.entity'; +import { ProveedorAmbiental } from '../entities/proveedor-ambiental.entity'; +import { ManifiestoResiduos, EstadoManifiesto } from '../entities/manifiesto-residuos.entity'; +import { ManifiestoDetalle } from '../entities/manifiesto-detalle.entity'; +import { ImpactoAmbiental, EstadoImpacto } from '../entities/impacto-ambiental.entity'; +import { QuejaAmbiental } from '../entities/queja-ambiental.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de gestión ambiental + */ +export function createAmbientalController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const residuoCatalogoRepository = dataSource.getRepository(ResiduoCatalogo); + const generacionRepository = dataSource.getRepository(ResiduoGeneracion); + const almacenRepository = dataSource.getRepository(AlmacenTemporal); + const proveedorRepository = dataSource.getRepository(ProveedorAmbiental); + const manifiestoRepository = dataSource.getRepository(ManifiestoResiduos); + const detalleRepository = dataSource.getRepository(ManifiestoDetalle); + const impactoRepository = dataSource.getRepository(ImpactoAmbiental); + const quejaRepository = dataSource.getRepository(QuejaAmbiental); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const ambientalService = new AmbientalService( + residuoCatalogoRepository, + generacionRepository, + almacenRepository, + proveedorRepository, + manifiestoRepository, + detalleRepository, + impactoRepository, + quejaRepository + ); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + // ========== Catálogo de Residuos ========== + + /** + * GET /ambiental/residuos/catalogo + * Listar catálogo de residuos + */ + router.get('/residuos/catalogo', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: ResiduoFilters = { + categoria: req.query.categoria as any, + activo: req.query.activo === 'true' ? true : req.query.activo === 'false' ? false : undefined, + search: req.query.search as string, + }; + + const result = await ambientalService.findResiduos(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /ambiental/residuos/catalogo + * Crear tipo de residuo en catálogo + */ + router.post('/residuos/catalogo', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: CreateResiduoCatalogoDto = req.body; + + if (!dto.codigo || !dto.nombre || !dto.categoria) { + res.status(400).json({ + error: 'Bad Request', + message: 'codigo, nombre and categoria are required', + }); + return; + } + + const residuo = await ambientalService.createResiduo(dto); + res.status(201).json({ success: true, data: residuo }); + } catch (error) { + next(error); + } + }); + + // ========== Generación de Residuos ========== + + /** + * GET /ambiental/generaciones + * Listar generaciones de residuos + */ + router.get('/generaciones', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: GeneracionFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + residuoId: req.query.residuoId as string, + estado: req.query.estado as EstadoResiduo, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + }; + + const result = await ambientalService.findGeneraciones(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /ambiental/generaciones + * Registrar generación de residuo + */ + router.post('/generaciones', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateGeneracionDto = req.body; + + if (!dto.fraccionamientoId || !dto.residuoId || !dto.cantidad || !dto.fechaGeneracion || !dto.unidad) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId, residuoId, cantidad, fechaGeneracion and unidad are required', + }); + return; + } + + dto.fechaGeneracion = new Date(dto.fechaGeneracion); + const generacion = await ambientalService.createGeneracion(getContext(req), dto); + res.status(201).json({ success: true, data: generacion }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /ambiental/generaciones/:id/estado + * Actualizar estado de generación + */ + router.patch('/generaciones/:id/estado', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { estado } = req.body; + if (!estado) { + res.status(400).json({ error: 'Bad Request', message: 'estado is required' }); + return; + } + + const generacion = await ambientalService.updateGeneracionEstado(getContext(req), req.params.id, estado); + if (!generacion) { + res.status(404).json({ error: 'Not Found', message: 'Waste generation not found' }); + return; + } + res.status(200).json({ success: true, data: generacion }); + } catch (error) { + next(error); + } + }); + + // ========== Almacenes Temporales ========== + + /** + * GET /ambiental/almacenes + * Listar almacenes temporales + */ + router.get('/almacenes', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const fraccionamientoId = req.query.fraccionamientoId as string; + const almacenes = await ambientalService.findAlmacenes(getContext(req), fraccionamientoId); + res.status(200).json({ success: true, data: almacenes }); + } catch (error) { + next(error); + } + }); + + // ========== Proveedores Ambientales ========== + + /** + * GET /ambiental/proveedores + * Listar proveedores ambientales + */ + router.get('/proveedores', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const proveedores = await ambientalService.findProveedores(getContext(req)); + res.status(200).json({ success: true, data: proveedores }); + } catch (error) { + next(error); + } + }); + + // ========== Manifiestos ========== + + /** + * GET /ambiental/manifiestos + * Listar manifiestos de residuos + */ + router.get('/manifiestos', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: ManifiestoFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + estado: req.query.estado as EstadoManifiesto, + transportistaId: req.query.transportistaId as string, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + }; + + const result = await ambientalService.findManifiestos(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /ambiental/manifiestos/:id + * Obtener manifiesto con detalles + */ + router.get('/manifiestos/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const manifiesto = await ambientalService.findManifiestoWithDetalles(getContext(req), req.params.id); + if (!manifiesto) { + res.status(404).json({ error: 'Not Found', message: 'Manifest not found' }); + return; + } + res.status(200).json({ success: true, data: manifiesto }); + } catch (error) { + next(error); + } + }); + + /** + * POST /ambiental/manifiestos + * Crear manifiesto de residuos + */ + router.post('/manifiestos', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateManifiestoDto = req.body; + + if (!dto.fraccionamientoId || !dto.transportistaId || !dto.destinoId || !dto.fechaRecoleccion) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId, transportistaId, destinoId and fechaRecoleccion are required', + }); + return; + } + + dto.fechaRecoleccion = new Date(dto.fechaRecoleccion); + const manifiesto = await ambientalService.createManifiesto(getContext(req), dto); + res.status(201).json({ success: true, data: manifiesto }); + } catch (error) { + next(error); + } + }); + + /** + * POST /ambiental/manifiestos/:id/detalles + * Agregar detalle a manifiesto + */ + router.post('/manifiestos/:id/detalles', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: AddDetalleManifiestoDto = req.body; + + if (!dto.residuoId || !dto.cantidad || !dto.unidad) { + res.status(400).json({ + error: 'Bad Request', + message: 'residuoId, cantidad and unidad are required', + }); + return; + } + + const detalle = await ambientalService.addDetalleManifiesto(getContext(req), req.params.id, dto); + res.status(201).json({ success: true, data: detalle }); + } catch (error) { + if (error instanceof Error && error.message === 'Manifiesto no encontrado') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /ambiental/manifiestos/:id/estado + * Actualizar estado de manifiesto + */ + router.patch('/manifiestos/:id/estado', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { estado } = req.body; + if (!estado) { + res.status(400).json({ error: 'Bad Request', message: 'estado is required' }); + return; + } + + const manifiesto = await ambientalService.updateManifiestoEstado(getContext(req), req.params.id, estado); + if (!manifiesto) { + res.status(404).json({ error: 'Not Found', message: 'Manifest not found' }); + return; + } + res.status(200).json({ success: true, data: manifiesto }); + } catch (error) { + next(error); + } + }); + + // ========== Impactos Ambientales ========== + + /** + * GET /ambiental/impactos + * Listar impactos ambientales + */ + router.get('/impactos', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: ImpactoFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + tipoImpacto: req.query.tipoImpacto as any, + nivelRiesgo: req.query.nivelRiesgo as any, + estado: req.query.estado as any, + }; + + const result = await ambientalService.findImpactos(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /ambiental/impactos + * Crear impacto ambiental + */ + router.post('/impactos', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: CreateImpactoDto = req.body; + + if (!dto.fraccionamientoId || !dto.aspecto || !dto.tipoImpacto || !dto.severidad || !dto.probabilidad) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId, aspecto, tipoImpacto, severidad and probabilidad are required', + }); + return; + } + + const impacto = await ambientalService.createImpacto(getContext(req), dto); + res.status(201).json({ success: true, data: impacto }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /ambiental/impactos/:id/estado + * Actualizar estado de impacto + */ + router.patch('/impactos/:id/estado', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { estado } = req.body; + if (!estado) { + res.status(400).json({ error: 'Bad Request', message: 'estado is required' }); + return; + } + + const impacto = await ambientalService.updateImpactoEstado(getContext(req), req.params.id, estado as EstadoImpacto); + if (!impacto) { + res.status(404).json({ error: 'Not Found', message: 'Impact not found' }); + return; + } + res.status(200).json({ success: true, data: impacto }); + } catch (error) { + next(error); + } + }); + + // ========== Quejas Ambientales ========== + + /** + * GET /ambiental/quejas + * Listar quejas ambientales + */ + router.get('/quejas', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: QuejaFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + tipo: req.query.tipo as any, + estado: req.query.estado as any, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + }; + + const result = await ambientalService.findQuejas(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /ambiental/quejas + * Crear queja ambiental + */ + router.post('/quejas', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: CreateQuejaDto = req.body; + + if (!dto.fraccionamientoId || !dto.origen || !dto.tipo || !dto.descripcion) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId, origen, tipo and descripcion are required', + }); + return; + } + + const queja = await ambientalService.createQueja(getContext(req), dto); + res.status(201).json({ success: true, data: queja }); + } catch (error) { + next(error); + } + }); + + /** + * POST /ambiental/quejas/:id/atender + * Atender queja ambiental + */ + router.post('/quejas/:id/atender', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { accionesTomadas } = req.body; + if (!accionesTomadas) { + res.status(400).json({ error: 'Bad Request', message: 'accionesTomadas is required' }); + return; + } + + const queja = await ambientalService.atenderQueja(getContext(req), req.params.id, accionesTomadas); + if (!queja) { + res.status(404).json({ error: 'Not Found', message: 'Complaint not found' }); + return; + } + res.status(200).json({ success: true, data: queja, message: 'Complaint being attended' }); + } catch (error) { + next(error); + } + }); + + /** + * POST /ambiental/quejas/:id/cerrar + * Cerrar queja ambiental + */ + router.post('/quejas/:id/cerrar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const queja = await ambientalService.cerrarQueja(getContext(req), req.params.id); + if (!queja) { + res.status(404).json({ error: 'Not Found', message: 'Complaint not found' }); + return; + } + res.status(200).json({ success: true, data: queja, message: 'Complaint closed' }); + } catch (error) { + next(error); + } + }); + + // ========== Estadísticas ========== + + /** + * GET /ambiental/stats + * Obtener estadísticas ambientales + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string; + const stats = await ambientalService.getStats(getContext(req), fraccionamientoId); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createAmbientalController; diff --git a/src/modules/hse/controllers/capacitacion.controller.ts b/src/modules/hse/controllers/capacitacion.controller.ts new file mode 100644 index 0000000..9d50660 --- /dev/null +++ b/src/modules/hse/controllers/capacitacion.controller.ts @@ -0,0 +1,223 @@ +/** + * CapacitacionController - Controller de capacitaciones HSE + * + * Endpoints REST para gestión de catálogo de capacitaciones. + * + * @module HSE + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + CapacitacionService, + CreateCapacitacionDto, + UpdateCapacitacionDto, + CapacitacionFilters, +} from '../services/capacitacion.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Capacitacion } from '../entities/capacitacion.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de capacitaciones + */ +export function createCapacitacionController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const capacitacionRepository = dataSource.getRepository(Capacitacion); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const capacitacionService = new CapacitacionService(capacitacionRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /capacitaciones + * Listar capacitaciones con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: CapacitacionFilters = { + tipo: req.query.tipo as any, + activo: req.query.activo === 'true' ? true : req.query.activo === 'false' ? false : undefined, + search: req.query.search as string, + }; + + const result = await capacitacionService.findAll(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /capacitaciones/by-tipo/:tipo + * Obtener capacitaciones activas por tipo + */ + router.get('/by-tipo/:tipo', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const validTipos = ['induccion', 'especifica', 'certificacion', 'reentrenamiento']; + if (!validTipos.includes(req.params.tipo)) { + res.status(400).json({ error: 'Bad Request', message: 'Invalid tipo' }); + return; + } + + const capacitaciones = await capacitacionService.getByTipo(getContext(req), req.params.tipo as any); + res.status(200).json({ success: true, data: capacitaciones }); + } catch (error) { + next(error); + } + }); + + /** + * GET /capacitaciones/:id + * Obtener capacitación por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const capacitacion = await capacitacionService.findById(getContext(req), req.params.id); + if (!capacitacion) { + res.status(404).json({ error: 'Not Found', message: 'Training not found' }); + return; + } + + res.status(200).json({ success: true, data: capacitacion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /capacitaciones + * Crear capacitación + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateCapacitacionDto = req.body; + + if (!dto.codigo || !dto.nombre || !dto.tipo) { + res.status(400).json({ error: 'Bad Request', message: 'codigo, nombre and tipo are required' }); + return; + } + + const capacitacion = await capacitacionService.create(getContext(req), dto); + res.status(201).json({ success: true, data: capacitacion }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /capacitaciones/:id + * Actualizar capacitación + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateCapacitacionDto = req.body; + const capacitacion = await capacitacionService.update(getContext(req), req.params.id, dto); + + if (!capacitacion) { + res.status(404).json({ error: 'Not Found', message: 'Training not found' }); + return; + } + + res.status(200).json({ success: true, data: capacitacion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /capacitaciones/:id/toggle-active + * Activar/desactivar capacitación + */ + router.post('/:id/toggle-active', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const capacitacion = await capacitacionService.toggleActive(getContext(req), req.params.id); + + if (!capacitacion) { + res.status(404).json({ error: 'Not Found', message: 'Training not found' }); + return; + } + + res.status(200).json({ + success: true, + data: capacitacion, + message: capacitacion.activo ? 'Training activated' : 'Training deactivated', + }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createCapacitacionController; diff --git a/src/modules/hse/controllers/epp.controller.ts b/src/modules/hse/controllers/epp.controller.ts new file mode 100644 index 0000000..d6bdce2 --- /dev/null +++ b/src/modules/hse/controllers/epp.controller.ts @@ -0,0 +1,464 @@ +/** + * EppController - Controller de EPP HSE + * + * Endpoints REST para gestión de equipo de protección personal. + * + * @module HSE + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + EppService, + CreateEppCatalogoDto, + CreateAsignacionDto, + CreateInspeccionEppDto, + CreateBajaDto, + EppFilters, + AsignacionFilters, +} from '../services/epp.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { EppCatalogo } from '../entities/epp-catalogo.entity'; +import { EppAsignacion } from '../entities/epp-asignacion.entity'; +import { EppInspeccion } from '../entities/epp-inspeccion.entity'; +import { EppBaja } from '../entities/epp-baja.entity'; +import { EppMatrizPuesto } from '../entities/epp-matriz-puesto.entity'; +import { EppInventario } from '../entities/epp-inventario.entity'; +import { EppMovimiento } from '../entities/epp-movimiento.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de EPP + */ +export function createEppController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const catalogoRepository = dataSource.getRepository(EppCatalogo); + const asignacionRepository = dataSource.getRepository(EppAsignacion); + const inspeccionRepository = dataSource.getRepository(EppInspeccion); + const bajaRepository = dataSource.getRepository(EppBaja); + const matrizRepository = dataSource.getRepository(EppMatrizPuesto); + const inventarioRepository = dataSource.getRepository(EppInventario); + const movimientoRepository = dataSource.getRepository(EppMovimiento); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const eppService = new EppService( + catalogoRepository, + asignacionRepository, + inspeccionRepository, + bajaRepository, + matrizRepository, + inventarioRepository, + movimientoRepository + ); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + // ========== Catálogo EPP ========== + + /** + * GET /epp/catalogo + * Listar catálogo de EPP + */ + router.get('/catalogo', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: EppFilters = { + categoria: req.query.categoria as any, + activo: req.query.activo === 'true' ? true : req.query.activo === 'false' ? false : undefined, + search: req.query.search as string, + }; + + const result = await eppService.findCatalogo(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /epp/catalogo/:id + * Obtener EPP del catálogo por ID + */ + router.get('/catalogo/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const epp = await eppService.findCatalogoById(getContext(req), req.params.id); + if (!epp) { + res.status(404).json({ error: 'Not Found', message: 'EPP not found' }); + return; + } + res.status(200).json({ success: true, data: epp }); + } catch (error) { + next(error); + } + }); + + /** + * POST /epp/catalogo + * Crear EPP en catálogo + */ + router.post('/catalogo', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateEppCatalogoDto = req.body; + + if (!dto.codigo || !dto.nombre || !dto.categoria || !dto.vidaUtilDias) { + res.status(400).json({ + error: 'Bad Request', + message: 'codigo, nombre, categoria and vidaUtilDias are required', + }); + return; + } + + const epp = await eppService.createCatalogo(getContext(req), dto); + res.status(201).json({ success: true, data: epp }); + } catch (error) { + next(error); + } + }); + + // ========== Matriz por Puesto ========== + + /** + * GET /epp/matriz/:puestoId + * Obtener matriz de EPP por puesto + */ + router.get('/matriz/:puestoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const matriz = await eppService.getMatrizByPuesto(getContext(req), req.params.puestoId); + res.status(200).json({ success: true, data: matriz }); + } catch (error) { + next(error); + } + }); + + /** + * POST /epp/matriz/:puestoId + * Configurar EPP requerido para puesto + */ + router.post('/matriz/:puestoId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { eppId, esObligatorio, actividadEspecifica } = req.body; + + if (!eppId || esObligatorio === undefined) { + res.status(400).json({ + error: 'Bad Request', + message: 'eppId and esObligatorio are required', + }); + return; + } + + const matriz = await eppService.setMatrizPuesto( + getContext(req), + req.params.puestoId, + eppId, + esObligatorio, + actividadEspecifica + ); + res.status(200).json({ success: true, data: matriz }); + } catch (error) { + next(error); + } + }); + + // ========== Asignaciones ========== + + /** + * GET /epp/asignaciones + * Listar asignaciones de EPP + */ + router.get('/asignaciones', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: AsignacionFilters = { + employeeId: req.query.employeeId as string, + eppId: req.query.eppId as string, + estado: req.query.estado as any, + fraccionamientoId: req.query.fraccionamientoId as string, + vencidos: req.query.vencidos === 'true', + }; + + const result = await eppService.findAsignaciones(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /epp/asignaciones/:id + * Obtener asignación por ID + */ + router.get('/asignaciones/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const asignacion = await eppService.findAsignacionById(getContext(req), req.params.id); + if (!asignacion) { + res.status(404).json({ error: 'Not Found', message: 'Assignment not found' }); + return; + } + res.status(200).json({ success: true, data: asignacion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /epp/asignaciones + * Crear asignación de EPP + */ + router.post('/asignaciones', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateAsignacionDto = req.body; + + if (!dto.eppId || !dto.employeeId || !dto.fechaEntrega || !dto.fechaVencimiento) { + res.status(400).json({ + error: 'Bad Request', + message: 'eppId, employeeId, fechaEntrega and fechaVencimiento are required', + }); + return; + } + + dto.fechaEntrega = new Date(dto.fechaEntrega); + dto.fechaVencimiento = new Date(dto.fechaVencimiento); + const asignacion = await eppService.createAsignacion(getContext(req), dto); + res.status(201).json({ success: true, data: asignacion }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /epp/asignaciones/:id/estado + * Actualizar estado de asignación + */ + router.patch('/asignaciones/:id/estado', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { estado } = req.body; + if (!estado) { + res.status(400).json({ error: 'Bad Request', message: 'estado is required' }); + return; + } + + const asignacion = await eppService.updateAsignacionEstado(getContext(req), req.params.id, estado); + if (!asignacion) { + res.status(404).json({ error: 'Not Found', message: 'Assignment not found' }); + return; + } + res.status(200).json({ success: true, data: asignacion }); + } catch (error) { + next(error); + } + }); + + // ========== Inspecciones EPP ========== + + /** + * GET /epp/asignaciones/:id/inspecciones + * Listar inspecciones de una asignación + */ + router.get('/asignaciones/:id/inspecciones', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const inspecciones = await eppService.getInspeccionesByAsignacion(getContext(req), req.params.id); + res.status(200).json({ success: true, data: inspecciones }); + } catch (error) { + next(error); + } + }); + + /** + * POST /epp/asignaciones/:id/inspecciones + * Crear inspección de EPP + */ + router.post('/asignaciones/:id/inspecciones', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: CreateInspeccionEppDto = { + ...req.body, + asignacionId: req.params.id, + }; + + if (!dto.inspectorId || !dto.estadoEpp) { + res.status(400).json({ + error: 'Bad Request', + message: 'inspectorId and estadoEpp are required', + }); + return; + } + + const inspeccion = await eppService.createInspeccion(getContext(req), dto); + res.status(201).json({ success: true, data: inspeccion }); + } catch (error) { + if (error instanceof Error && error.message === 'Asignación no encontrada') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + // ========== Bajas ========== + + /** + * POST /epp/asignaciones/:id/baja + * Dar de baja EPP + */ + router.post('/asignaciones/:id/baja', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: CreateBajaDto = { + ...req.body, + asignacionId: req.params.id, + }; + + if (!dto.motivo) { + res.status(400).json({ + error: 'Bad Request', + message: 'motivo is required', + }); + return; + } + + const baja = await eppService.createBaja(getContext(req), dto); + res.status(201).json({ success: true, data: baja, message: 'EPP given off' }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Asignación no encontrada') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message === 'EPP ya fue dado de baja') { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + // ========== Inventario ========== + + /** + * GET /epp/inventario + * Obtener inventario de EPP + */ + router.get('/inventario', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const almacenId = req.query.almacenId as string; + const inventario = await eppService.getInventario(getContext(req), almacenId); + res.status(200).json({ success: true, data: inventario }); + } catch (error) { + next(error); + } + }); + + /** + * POST /epp/movimientos + * Registrar movimiento de inventario + */ + router.post('/movimientos', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { eppId, tipo, cantidad, almacenOrigenId, almacenDestinoId, referencia } = req.body; + + if (!eppId || !tipo || !cantidad) { + res.status(400).json({ + error: 'Bad Request', + message: 'eppId, tipo and cantidad are required', + }); + return; + } + + const movimiento = await eppService.registrarMovimiento( + getContext(req), + eppId, + tipo, + cantidad, + almacenOrigenId, + almacenDestinoId, + referencia + ); + res.status(201).json({ success: true, data: movimiento }); + } catch (error) { + next(error); + } + }); + + // ========== Estadísticas ========== + + /** + * GET /epp/stats + * Obtener estadísticas de EPP + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string; + const stats = await eppService.getStats(getContext(req), fraccionamientoId); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createEppController; diff --git a/src/modules/hse/controllers/incidente.controller.ts b/src/modules/hse/controllers/incidente.controller.ts new file mode 100644 index 0000000..3e97906 --- /dev/null +++ b/src/modules/hse/controllers/incidente.controller.ts @@ -0,0 +1,398 @@ +/** + * IncidenteController - Controller de incidentes HSE + * + * Endpoints REST para gestión de incidentes de seguridad. + * Workflow: abierto -> en_investigacion -> cerrado + * + * @module HSE + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + IncidenteService, + CreateIncidenteDto, + UpdateIncidenteDto, + AddInvolucradoDto, + AddAccionDto, + UpdateAccionDto, + IncidenteFilters, +} from '../services/incidente.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Incidente } from '../entities/incidente.entity'; +import { IncidenteInvolucrado } from '../entities/incidente-involucrado.entity'; +import { IncidenteAccion } from '../entities/incidente-accion.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de incidentes + */ +export function createIncidenteController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const incidenteRepository = dataSource.getRepository(Incidente); + const involucradoRepository = dataSource.getRepository(IncidenteInvolucrado); + const accionRepository = dataSource.getRepository(IncidenteAccion); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const incidenteService = new IncidenteService( + incidenteRepository, + involucradoRepository, + accionRepository + ); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /incidentes + * Listar incidentes con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: IncidenteFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + tipo: req.query.tipo as any, + gravedad: req.query.gravedad as any, + estado: req.query.estado as any, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + }; + + const result = await incidenteService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /incidentes/stats + * Obtener estadísticas de incidentes + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string; + const stats = await incidenteService.getStats(getContext(req), fraccionamientoId); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /incidentes/:id + * Obtener incidente con detalles completos + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const incidente = await incidenteService.findWithDetails(getContext(req), req.params.id); + if (!incidente) { + res.status(404).json({ error: 'Not Found', message: 'Incident not found' }); + return; + } + + res.status(200).json({ success: true, data: incidente }); + } catch (error) { + next(error); + } + }); + + /** + * POST /incidentes + * Crear incidente + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateIncidenteDto = req.body; + + if (!dto.fechaHora || !dto.fraccionamientoId || !dto.tipo || !dto.gravedad || !dto.descripcion) { + res.status(400).json({ + error: 'Bad Request', + message: 'fechaHora, fraccionamientoId, tipo, gravedad and descripcion are required', + }); + return; + } + + dto.fechaHora = new Date(dto.fechaHora); + const incidente = await incidenteService.create(getContext(req), dto); + res.status(201).json({ success: true, data: incidente }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /incidentes/:id + * Actualizar incidente + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateIncidenteDto = req.body; + const incidente = await incidenteService.update(getContext(req), req.params.id, dto); + + if (!incidente) { + res.status(404).json({ error: 'Not Found', message: 'Incident not found' }); + return; + } + + res.status(200).json({ success: true, data: incidente }); + } catch (error) { + if (error instanceof Error && error.message.includes('closed')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /incidentes/:id/investigate + * Iniciar investigación del incidente + */ + router.post('/:id/investigate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const incidente = await incidenteService.startInvestigation(getContext(req), req.params.id); + if (!incidente) { + res.status(404).json({ error: 'Not Found', message: 'Incident not found' }); + return; + } + + res.status(200).json({ success: true, data: incidente, message: 'Investigation started' }); + } catch (error) { + if (error instanceof Error && error.message.includes('only start')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /incidentes/:id/close + * Cerrar incidente + */ + router.post('/:id/close', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const incidente = await incidenteService.closeIncident(getContext(req), req.params.id); + if (!incidente) { + res.status(404).json({ error: 'Not Found', message: 'Incident not found' }); + return; + } + + res.status(200).json({ success: true, data: incidente, message: 'Incident closed' }); + } catch (error) { + if (error instanceof Error && (error.message.includes('already closed') || error.message.includes('pending actions'))) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /incidentes/:id/involucrados + * Agregar involucrado al incidente + */ + router.post('/:id/involucrados', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: AddInvolucradoDto = req.body; + + if (!dto.employeeId || !dto.rol) { + res.status(400).json({ error: 'Bad Request', message: 'employeeId and rol are required' }); + return; + } + + const involucrado = await incidenteService.addInvolucrado(getContext(req), req.params.id, dto); + res.status(201).json({ success: true, data: involucrado }); + } catch (error) { + if (error instanceof Error && error.message === 'Incidente not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * DELETE /incidentes/:id/involucrados/:involucradoId + * Remover involucrado del incidente + */ + router.delete('/:id/involucrados/:involucradoId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const removed = await incidenteService.removeInvolucrado( + getContext(req), + req.params.id, + req.params.involucradoId + ); + + if (!removed) { + res.status(404).json({ error: 'Not Found', message: 'Involved person not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Involved person removed' }); + } catch (error) { + next(error); + } + }); + + /** + * POST /incidentes/:id/acciones + * Agregar acción correctiva al incidente + */ + router.post('/:id/acciones', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: AddAccionDto = req.body; + + if (!dto.descripcion || !dto.tipo || !dto.fechaCompromiso) { + res.status(400).json({ + error: 'Bad Request', + message: 'descripcion, tipo and fechaCompromiso are required', + }); + return; + } + + dto.fechaCompromiso = new Date(dto.fechaCompromiso); + const accion = await incidenteService.addAccion(getContext(req), req.params.id, dto); + res.status(201).json({ success: true, data: accion }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Incidente not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message.includes('closed')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * PATCH /incidentes/:id/acciones/:accionId + * Actualizar acción correctiva + */ + router.patch('/:id/acciones/:accionId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateAccionDto = req.body; + if (dto.fechaCompromiso) { + dto.fechaCompromiso = new Date(dto.fechaCompromiso); + } + + const accion = await incidenteService.updateAccion( + getContext(req), + req.params.id, + req.params.accionId, + dto + ); + + if (!accion) { + res.status(404).json({ error: 'Not Found', message: 'Action not found' }); + return; + } + + res.status(200).json({ success: true, data: accion }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createIncidenteController; diff --git a/src/modules/hse/controllers/index.ts b/src/modules/hse/controllers/index.ts new file mode 100644 index 0000000..ba9818d --- /dev/null +++ b/src/modules/hse/controllers/index.ts @@ -0,0 +1,28 @@ +/** + * HSE Controllers Index + * @module HSE + */ + +// RF-MAA017-001: Gestión de Incidentes +export { createIncidenteController } from './incidente.controller'; + +// RF-MAA017-002: Control de Capacitaciones +export { createCapacitacionController } from './capacitacion.controller'; + +// RF-MAA017-003: Inspecciones de Seguridad +export { createInspeccionController } from './inspeccion.controller'; + +// RF-MAA017-004: Control de EPP +export { createEppController } from './epp.controller'; + +// RF-MAA017-005: Cumplimiento STPS +export { createStpsController } from './stps.controller'; + +// RF-MAA017-006: Gestión Ambiental +export { createAmbientalController } from './ambiental.controller'; + +// RF-MAA017-007: Permisos de Trabajo +export { createPermisoTrabajoController } from './permiso-trabajo.controller'; + +// RF-MAA017-008: Indicadores HSE +export { createIndicadorController } from './indicador.controller'; diff --git a/src/modules/hse/controllers/indicador.controller.ts b/src/modules/hse/controllers/indicador.controller.ts new file mode 100644 index 0000000..05c14ea --- /dev/null +++ b/src/modules/hse/controllers/indicador.controller.ts @@ -0,0 +1,354 @@ +/** + * IndicadorController - Controller de indicadores HSE + * + * Endpoints REST para gestión de indicadores y reportes HSE. + * + * @module HSE + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + IndicadorService, + CreateIndicadorDto, + IndicadorFilters, +} from '../services/indicador.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { IndicadorConfig } from '../entities/indicador-config.entity'; +import { IndicadorMetaObra } from '../entities/indicador-meta-obra.entity'; +import { IndicadorValor } from '../entities/indicador-valor.entity'; +import { HorasTrabajadas } from '../entities/horas-trabajadas.entity'; +import { DiasSinAccidente } from '../entities/dias-sin-accidente.entity'; +import { ReporteProgramado } from '../entities/reporte-programado.entity'; +import { AlertaIndicador } from '../entities/alerta-indicador.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de indicadores + */ +export function createIndicadorController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const indicadorRepository = dataSource.getRepository(IndicadorConfig); + const metaRepository = dataSource.getRepository(IndicadorMetaObra); + const valorRepository = dataSource.getRepository(IndicadorValor); + const horasRepository = dataSource.getRepository(HorasTrabajadas); + const diasRepository = dataSource.getRepository(DiasSinAccidente); + const reporteRepository = dataSource.getRepository(ReporteProgramado); + const alertaRepository = dataSource.getRepository(AlertaIndicador); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const indicadorService = new IndicadorService( + indicadorRepository, + metaRepository, + valorRepository, + horasRepository, + diasRepository, + reporteRepository, + alertaRepository + ); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + // ========== Configuración de Indicadores ========== + + /** + * GET /indicadores + * Listar indicadores con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: IndicadorFilters = { + tipo: req.query.tipo as any, + activo: req.query.activo === 'true' ? true : req.query.activo === 'false' ? false : undefined, + search: req.query.search as string, + }; + + const result = await indicadorService.findIndicadores(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /indicadores/stats + * Obtener estadísticas de indicadores + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await indicadorService.getStats(getContext(req)); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /indicadores/:id + * Obtener indicador por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const indicador = await indicadorService.findIndicadorById(getContext(req), req.params.id); + if (!indicador) { + res.status(404).json({ error: 'Not Found', message: 'Indicator not found' }); + return; + } + res.status(200).json({ success: true, data: indicador }); + } catch (error) { + next(error); + } + }); + + /** + * POST /indicadores + * Crear indicador + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateIndicadorDto = req.body; + + if (!dto.codigo || !dto.nombre || !dto.tipo) { + res.status(400).json({ + error: 'Bad Request', + message: 'codigo, nombre and tipo are required', + }); + return; + } + + const indicador = await indicadorService.createIndicador(getContext(req), dto); + res.status(201).json({ success: true, data: indicador }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /indicadores/:id + * Actualizar indicador + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: Partial = req.body; + const indicador = await indicadorService.updateIndicador(getContext(req), req.params.id, dto); + + if (!indicador) { + res.status(404).json({ error: 'Not Found', message: 'Indicator not found' }); + return; + } + + res.status(200).json({ success: true, data: indicador }); + } catch (error) { + next(error); + } + }); + + /** + * POST /indicadores/:id/toggle + * Activar/desactivar indicador + */ + router.post('/:id/toggle', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const indicador = await indicadorService.toggleIndicadorActivo(getContext(req), req.params.id); + + if (!indicador) { + res.status(404).json({ error: 'Not Found', message: 'Indicator not found' }); + return; + } + + res.status(200).json({ + success: true, + data: indicador, + message: indicador.activo ? 'Indicator activated' : 'Indicator deactivated', + }); + } catch (error) { + next(error); + } + }); + + // ========== Metas por Obra ========== + + /** + * GET /indicadores/metas + * Listar metas por obra + */ + router.get('/metas/list', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const fraccionamientoId = req.query.fraccionamientoId as string; + const indicadorId = req.query.indicadorId as string; + const metas = await indicadorService.findMetas(getContext(req), fraccionamientoId, indicadorId); + res.status(200).json({ success: true, data: metas }); + } catch (error) { + next(error); + } + }); + + // ========== Valores de Indicadores ========== + + /** + * GET /indicadores/:id/valores + * Listar valores de un indicador + */ + router.get('/:id/valores', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + const fraccionamientoId = req.query.fraccionamientoId as string; + + const result = await indicadorService.findValores( + getContext(req), + req.params.id, + fraccionamientoId, + page, + limit + ); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + // ========== Horas Trabajadas ========== + + /** + * GET /indicadores/horas-trabajadas/:fraccionamientoId + * Obtener horas trabajadas por obra + */ + router.get('/horas-trabajadas/:fraccionamientoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const year = req.query.year ? parseInt(req.query.year as string) : undefined; + const horas = await indicadorService.findHorasTrabajadas( + getContext(req), + req.params.fraccionamientoId, + year + ); + res.status(200).json({ success: true, data: horas }); + } catch (error) { + next(error); + } + }); + + // ========== Días Sin Accidente ========== + + /** + * GET /indicadores/dias-sin-accidente/:fraccionamientoId + * Obtener días sin accidente por obra + */ + router.get('/dias-sin-accidente/:fraccionamientoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dias = await indicadorService.findDiasSinAccidente( + getContext(req), + req.params.fraccionamientoId + ); + res.status(200).json({ success: true, data: dias }); + } catch (error) { + next(error); + } + }); + + // ========== Reportes Programados ========== + + /** + * GET /indicadores/reportes + * Listar reportes programados + */ + router.get('/reportes/list', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const reportes = await indicadorService.findReportes(getContext(req)); + res.status(200).json({ success: true, data: reportes }); + } catch (error) { + next(error); + } + }); + + // ========== Alertas ========== + + /** + * GET /indicadores/alertas + * Listar alertas de indicadores + */ + router.get('/alertas/list', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const fraccionamientoId = req.query.fraccionamientoId as string; + + const result = await indicadorService.findAlertas(getContext(req), fraccionamientoId, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createIndicadorController; diff --git a/src/modules/hse/controllers/inspeccion.controller.ts b/src/modules/hse/controllers/inspeccion.controller.ts new file mode 100644 index 0000000..243e271 --- /dev/null +++ b/src/modules/hse/controllers/inspeccion.controller.ts @@ -0,0 +1,400 @@ +/** + * InspeccionController - Controller de inspecciones HSE + * + * Endpoints REST para gestión de inspecciones de seguridad y hallazgos. + * + * @module HSE + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + InspeccionService, + CreateInspeccionDto, + CreateHallazgoDto, + InspeccionFilters, + HallazgoFilters, +} from '../services/inspeccion.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Inspeccion } from '../entities/inspeccion.entity'; +import { InspeccionEvaluacion } from '../entities/inspeccion-evaluacion.entity'; +import { Hallazgo } from '../entities/hallazgo.entity'; +import { HallazgoEvidencia } from '../entities/hallazgo-evidencia.entity'; +import { TipoInspeccion } from '../entities/tipo-inspeccion.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de inspecciones + */ +export function createInspeccionController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const inspeccionRepository = dataSource.getRepository(Inspeccion); + const evaluacionRepository = dataSource.getRepository(InspeccionEvaluacion); + const hallazgoRepository = dataSource.getRepository(Hallazgo); + const evidenciaRepository = dataSource.getRepository(HallazgoEvidencia); + const tipoInspeccionRepository = dataSource.getRepository(TipoInspeccion); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const inspeccionService = new InspeccionService( + inspeccionRepository, + evaluacionRepository, + hallazgoRepository, + evidenciaRepository, + tipoInspeccionRepository + ); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /inspecciones/tipos + * Listar tipos de inspección + */ + router.get('/tipos', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tipos = await inspeccionService.findTiposInspeccion(getContext(req)); + res.status(200).json({ success: true, data: tipos }); + } catch (error) { + next(error); + } + }); + + /** + * GET /inspecciones + * Listar inspecciones con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: InspeccionFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + tipoInspeccionId: req.query.tipoInspeccionId as string, + estado: req.query.estado as string, + inspectorId: req.query.inspectorId as string, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + }; + + const result = await inspeccionService.findInspecciones(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /inspecciones/stats + * Obtener estadísticas de inspecciones + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string; + const stats = await inspeccionService.getStats(getContext(req), fraccionamientoId); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /inspecciones/:id + * Obtener inspección con detalles + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const inspeccion = await inspeccionService.findInspeccionWithDetails(getContext(req), req.params.id); + if (!inspeccion) { + res.status(404).json({ error: 'Not Found', message: 'Inspection not found' }); + return; + } + + res.status(200).json({ success: true, data: inspeccion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /inspecciones + * Crear inspección + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateInspeccionDto = req.body; + + if (!dto.tipoInspeccionId || !dto.fraccionamientoId || !dto.inspectorId) { + res.status(400).json({ + error: 'Bad Request', + message: 'tipoInspeccionId, fraccionamientoId and inspectorId are required', + }); + return; + } + + const inspeccion = await inspeccionService.createInspeccion(getContext(req), dto); + res.status(201).json({ success: true, data: inspeccion }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /inspecciones/:id/estado + * Actualizar estado de inspección + */ + router.patch('/:id/estado', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { estado } = req.body; + if (!estado) { + res.status(400).json({ error: 'Bad Request', message: 'estado is required' }); + return; + } + + const inspeccion = await inspeccionService.updateInspeccionEstado(getContext(req), req.params.id, estado); + if (!inspeccion) { + res.status(404).json({ error: 'Not Found', message: 'Inspection not found' }); + return; + } + + res.status(200).json({ success: true, data: inspeccion }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /inspecciones/:id/observaciones + * Actualizar observaciones de inspección + */ + router.patch('/:id/observaciones', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { observaciones } = req.body; + if (!observaciones) { + res.status(400).json({ error: 'Bad Request', message: 'observaciones is required' }); + return; + } + + const inspeccion = await inspeccionService.updateInspeccionObservaciones(getContext(req), req.params.id, observaciones); + if (!inspeccion) { + res.status(404).json({ error: 'Not Found', message: 'Inspection not found' }); + return; + } + + res.status(200).json({ success: true, data: inspeccion }); + } catch (error) { + next(error); + } + }); + + // ========== Hallazgos ========== + + /** + * GET /inspecciones/hallazgos + * Listar hallazgos con filtros + */ + router.get('/hallazgos/list', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: HallazgoFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + gravedad: req.query.gravedad as any, + estado: req.query.estado as any, + responsableId: req.query.responsableId as string, + vencidos: req.query.vencidos === 'true', + }; + + const result = await inspeccionService.findHallazgos(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /inspecciones/:id/hallazgos + * Agregar hallazgo a inspección + */ + router.post('/:id/hallazgos', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateHallazgoDto = req.body; + + if (!dto.gravedad || !dto.tipo || !dto.descripcion || !dto.fechaLimite) { + res.status(400).json({ + error: 'Bad Request', + message: 'gravedad, tipo, descripcion and fechaLimite are required', + }); + return; + } + + dto.fechaLimite = new Date(dto.fechaLimite); + const hallazgo = await inspeccionService.createHallazgo(getContext(req), req.params.id, dto); + res.status(201).json({ success: true, data: hallazgo }); + } catch (error) { + if (error instanceof Error && error.message === 'Inspección no encontrada') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /inspecciones/hallazgos/:hallazgoId/correccion + * Registrar corrección de hallazgo + */ + router.post('/hallazgos/:hallazgoId/correccion', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { descripcionCorreccion } = req.body; + if (!descripcionCorreccion) { + res.status(400).json({ error: 'Bad Request', message: 'descripcionCorreccion is required' }); + return; + } + + const hallazgo = await inspeccionService.registrarCorreccion( + getContext(req), + req.params.hallazgoId, + descripcionCorreccion + ); + + if (!hallazgo) { + res.status(404).json({ error: 'Not Found', message: 'Finding not found' }); + return; + } + + res.status(200).json({ success: true, data: hallazgo, message: 'Correction registered' }); + } catch (error) { + next(error); + } + }); + + /** + * POST /inspecciones/hallazgos/:hallazgoId/verificar + * Verificar corrección de hallazgo + */ + router.post('/hallazgos/:hallazgoId/verificar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { aprobado } = req.body; + if (aprobado === undefined) { + res.status(400).json({ error: 'Bad Request', message: 'aprobado is required' }); + return; + } + + const ctx = getContext(req); + const hallazgo = await inspeccionService.verificarHallazgo( + ctx, + req.params.hallazgoId, + ctx.userId || '', + aprobado + ); + + if (!hallazgo) { + res.status(404).json({ error: 'Not Found', message: 'Finding not found' }); + return; + } + + res.status(200).json({ + success: true, + data: hallazgo, + message: aprobado ? 'Finding closed' : 'Finding reopened', + }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createInspeccionController; diff --git a/src/modules/hse/controllers/permiso-trabajo.controller.ts b/src/modules/hse/controllers/permiso-trabajo.controller.ts new file mode 100644 index 0000000..384e3c6 --- /dev/null +++ b/src/modules/hse/controllers/permiso-trabajo.controller.ts @@ -0,0 +1,323 @@ +/** + * PermisoTrabajoController - Controller de permisos de trabajo HSE + * + * Endpoints REST para gestión de permisos de trabajo de alto riesgo. + * + * @module HSE + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + PermisoTrabajoService, + CreatePermisoDto, + PermisoFilters, +} from '../services/permiso-trabajo.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { TipoPermisoTrabajo } from '../entities/tipo-permiso-trabajo.entity'; +import { PermisoTrabajo, EstadoPermiso } from '../entities/permiso-trabajo.entity'; +import { PermisoPersonal } from '../entities/permiso-personal.entity'; +import { PermisoAutorizacion } from '../entities/permiso-autorizacion.entity'; +import { PermisoChecklist } from '../entities/permiso-checklist.entity'; +import { PermisoMonitoreo } from '../entities/permiso-monitoreo.entity'; +import { PermisoEvento } from '../entities/permiso-evento.entity'; +import { PermisoDocumento } from '../entities/permiso-documento.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de permisos de trabajo + */ +export function createPermisoTrabajoController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const tipoPermisoRepository = dataSource.getRepository(TipoPermisoTrabajo); + const permisoRepository = dataSource.getRepository(PermisoTrabajo); + const personalRepository = dataSource.getRepository(PermisoPersonal); + const autorizacionRepository = dataSource.getRepository(PermisoAutorizacion); + const checklistRepository = dataSource.getRepository(PermisoChecklist); + const monitoreoRepository = dataSource.getRepository(PermisoMonitoreo); + const eventoRepository = dataSource.getRepository(PermisoEvento); + const documentoRepository = dataSource.getRepository(PermisoDocumento); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const permisoService = new PermisoTrabajoService( + tipoPermisoRepository, + permisoRepository, + personalRepository, + autorizacionRepository, + checklistRepository, + monitoreoRepository, + eventoRepository, + documentoRepository + ); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /permisos-trabajo/tipos + * Listar tipos de permisos de trabajo + */ + router.get('/tipos', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tipos = await permisoService.findTiposPermiso(getContext(req)); + res.status(200).json({ success: true, data: tipos }); + } catch (error) { + next(error); + } + }); + + /** + * GET /permisos-trabajo + * Listar permisos de trabajo con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: PermisoFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + tipoPermisoId: req.query.tipoPermisoId as string, + estado: req.query.estado as EstadoPermiso, + solicitanteId: req.query.solicitanteId as string, + vigentes: req.query.vigentes === 'true', + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + }; + + const result = await permisoService.findPermisos(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /permisos-trabajo/stats + * Obtener estadísticas de permisos + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string; + const stats = await permisoService.getStats(getContext(req), fraccionamientoId); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /permisos-trabajo/:id + * Obtener permiso con detalles + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const permiso = await permisoService.findPermisoWithDetails(getContext(req), req.params.id); + if (!permiso) { + res.status(404).json({ error: 'Not Found', message: 'Work permit not found' }); + return; + } + + res.status(200).json({ success: true, data: permiso }); + } catch (error) { + next(error); + } + }); + + /** + * POST /permisos-trabajo + * Crear permiso de trabajo + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreatePermisoDto = req.body; + + if (!dto.fraccionamientoId || !dto.tipoPermisoId || !dto.descripcionTrabajo || !dto.ubicacion || !dto.fechaInicioProgramada || !dto.fechaFinProgramada) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId, tipoPermisoId, descripcionTrabajo, ubicacion, fechaInicioProgramada and fechaFinProgramada are required', + }); + return; + } + + dto.fechaInicioProgramada = new Date(dto.fechaInicioProgramada); + dto.fechaFinProgramada = new Date(dto.fechaFinProgramada); + const permiso = await permisoService.createPermiso(getContext(req), dto); + res.status(201).json({ success: true, data: permiso }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /permisos-trabajo/:id/estado + * Actualizar estado del permiso + */ + router.patch('/:id/estado', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { estado, motivo } = req.body; + if (!estado) { + res.status(400).json({ error: 'Bad Request', message: 'estado is required' }); + return; + } + + const permiso = await permisoService.updatePermisoEstado(getContext(req), req.params.id, estado, motivo); + if (!permiso) { + res.status(404).json({ error: 'Not Found', message: 'Work permit not found' }); + return; + } + + res.status(200).json({ success: true, data: permiso }); + } catch (error) { + next(error); + } + }); + + // ========== Personal Autorizado ========== + + /** + * GET /permisos-trabajo/:id/personal + * Obtener personal autorizado del permiso + */ + router.get('/:id/personal', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const personal = await permisoService.getPersonalByPermiso(getContext(req), req.params.id); + res.status(200).json({ success: true, data: personal }); + } catch (error) { + next(error); + } + }); + + // ========== Autorizaciones ========== + + /** + * GET /permisos-trabajo/:id/autorizaciones + * Obtener autorizaciones del permiso + */ + router.get('/:id/autorizaciones', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const autorizaciones = await permisoService.getAutorizacionesByPermiso(getContext(req), req.params.id); + res.status(200).json({ success: true, data: autorizaciones }); + } catch (error) { + next(error); + } + }); + + // ========== Checklist ========== + + /** + * GET /permisos-trabajo/:id/checklist + * Obtener checklist del permiso + */ + router.get('/:id/checklist', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const checklist = await permisoService.getChecklistByPermiso(getContext(req), req.params.id); + res.status(200).json({ success: true, data: checklist }); + } catch (error) { + next(error); + } + }); + + // ========== Monitoreo ========== + + /** + * GET /permisos-trabajo/:id/monitoreos + * Obtener monitoreos del permiso + */ + router.get('/:id/monitoreos', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const monitoreos = await permisoService.getMonitoreosByPermiso(getContext(req), req.params.id); + res.status(200).json({ success: true, data: monitoreos }); + } catch (error) { + next(error); + } + }); + + // ========== Eventos ========== + + /** + * GET /permisos-trabajo/:id/eventos + * Obtener eventos del permiso + */ + router.get('/:id/eventos', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const eventos = await permisoService.getEventosByPermiso(getContext(req), req.params.id); + res.status(200).json({ success: true, data: eventos }); + } catch (error) { + next(error); + } + }); + + // ========== Documentos ========== + + /** + * GET /permisos-trabajo/:id/documentos + * Obtener documentos del permiso + */ + router.get('/:id/documentos', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const documentos = await permisoService.getDocumentosByPermiso(getContext(req), req.params.id); + res.status(200).json({ success: true, data: documentos }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createPermisoTrabajoController; diff --git a/src/modules/hse/controllers/stps.controller.ts b/src/modules/hse/controllers/stps.controller.ts new file mode 100644 index 0000000..4990305 --- /dev/null +++ b/src/modules/hse/controllers/stps.controller.ts @@ -0,0 +1,794 @@ +/** + * StpsController - Controller de cumplimiento STPS + * + * Endpoints REST para gestión de normas, comisiones y auditorías STPS. + * + * @module HSE + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + StpsService, + CreateCumplimientoDto, + CreateComisionDto, + AddIntegranteDto, + CreateRecorridoDto, + CreateProgramaDto, + CreateActividadDto, + CreateAuditoriaDto, + CreateDocumentoDto, + CumplimientoFilters, + ComisionFilters, + AuditoriaFilters, +} from '../services/stps.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { NormaStps } from '../entities/norma-stps.entity'; +import { NormaRequisito } from '../entities/norma-requisito.entity'; +import { CumplimientoObra, EstadoCumplimiento } from '../entities/cumplimiento-obra.entity'; +import { ComisionSeguridad, EstadoComision } from '../entities/comision-seguridad.entity'; +import { ComisionIntegrante } from '../entities/comision-integrante.entity'; +import { ComisionRecorrido } from '../entities/comision-recorrido.entity'; +import { ProgramaSeguridad } from '../entities/programa-seguridad.entity'; +import { ProgramaActividad, EstadoActividad } from '../entities/programa-actividad.entity'; +import { DocumentoStps } from '../entities/documento-stps.entity'; +import { Auditoria, ResultadoAuditoria } from '../entities/auditoria.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de STPS + */ +export function createStpsController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const normaRepository = dataSource.getRepository(NormaStps); + const requisitoRepository = dataSource.getRepository(NormaRequisito); + const cumplimientoRepository = dataSource.getRepository(CumplimientoObra); + const comisionRepository = dataSource.getRepository(ComisionSeguridad); + const integranteRepository = dataSource.getRepository(ComisionIntegrante); + const recorridoRepository = dataSource.getRepository(ComisionRecorrido); + const programaRepository = dataSource.getRepository(ProgramaSeguridad); + const actividadRepository = dataSource.getRepository(ProgramaActividad); + const documentoRepository = dataSource.getRepository(DocumentoStps); + const auditoriaRepository = dataSource.getRepository(Auditoria); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const stpsService = new StpsService( + normaRepository, + requisitoRepository, + cumplimientoRepository, + comisionRepository, + integranteRepository, + recorridoRepository, + programaRepository, + actividadRepository, + documentoRepository, + auditoriaRepository + ); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + // ========== Normas y Requisitos ========== + + /** + * GET /stps/normas + * Listar normas STPS + */ + router.get('/normas', authMiddleware.authenticate, async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const normas = await stpsService.findNormas(); + res.status(200).json({ success: true, data: normas }); + } catch (error) { + next(error); + } + }); + + /** + * GET /stps/normas/:id + * Obtener norma con requisitos + */ + router.get('/normas/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const norma = await stpsService.findNormaById(req.params.id); + if (!norma) { + res.status(404).json({ error: 'Not Found', message: 'Norm not found' }); + return; + } + res.status(200).json({ success: true, data: norma }); + } catch (error) { + next(error); + } + }); + + /** + * GET /stps/normas/:id/requisitos + * Listar requisitos de una norma + */ + router.get('/normas/:id/requisitos', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const requisitos = await stpsService.findRequisitosByNorma(req.params.id); + res.status(200).json({ success: true, data: requisitos }); + } catch (error) { + next(error); + } + }); + + // ========== Cumplimiento por Obra ========== + + /** + * GET /stps/cumplimiento + * Listar estado de cumplimiento + */ + router.get('/cumplimiento', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: CumplimientoFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + normaId: req.query.normaId as string, + estado: req.query.estado as EstadoCumplimiento, + }; + + const result = await stpsService.findCumplimientos(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/cumplimiento + * Registrar evaluación de cumplimiento + */ + router.post('/cumplimiento', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateCumplimientoDto = req.body; + + if (!dto.fraccionamientoId || !dto.normaId || !dto.fechaEvaluacion) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId, normaId and fechaEvaluacion are required', + }); + return; + } + + dto.fechaEvaluacion = new Date(dto.fechaEvaluacion); + if (dto.fechaCompromiso) dto.fechaCompromiso = new Date(dto.fechaCompromiso); + const cumplimiento = await stpsService.createCumplimiento(getContext(req), dto); + res.status(201).json({ success: true, data: cumplimiento }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /stps/cumplimiento/:id + * Actualizar estado de cumplimiento + */ + router.patch('/cumplimiento/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { estado, evidenciaUrl, observaciones } = req.body; + if (!estado) { + res.status(400).json({ error: 'Bad Request', message: 'estado is required' }); + return; + } + + const cumplimiento = await stpsService.updateCumplimientoEstado( + getContext(req), + req.params.id, + estado, + evidenciaUrl, + observaciones + ); + if (!cumplimiento) { + res.status(404).json({ error: 'Not Found', message: 'Compliance record not found' }); + return; + } + res.status(200).json({ success: true, data: cumplimiento }); + } catch (error) { + next(error); + } + }); + + // ========== Comisiones de Seguridad ========== + + /** + * GET /stps/comisiones + * Listar comisiones de seguridad + */ + router.get('/comisiones', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const filters: ComisionFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + estado: req.query.estado as EstadoComision, + }; + + const comisiones = await stpsService.findComisiones(getContext(req), filters); + res.status(200).json({ success: true, data: comisiones }); + } catch (error) { + next(error); + } + }); + + /** + * GET /stps/comisiones/:id + * Obtener comisión con integrantes + */ + router.get('/comisiones/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const comision = await stpsService.findComisionById(getContext(req), req.params.id); + if (!comision) { + res.status(404).json({ error: 'Not Found', message: 'Commission not found' }); + return; + } + res.status(200).json({ success: true, data: comision }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/comisiones + * Crear comisión de seguridad + */ + router.post('/comisiones', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: CreateComisionDto = req.body; + + if (!dto.fraccionamientoId || !dto.fechaConstitucion || !dto.vigenciaInicio || !dto.vigenciaFin) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId, fechaConstitucion, vigenciaInicio and vigenciaFin are required', + }); + return; + } + + dto.fechaConstitucion = new Date(dto.fechaConstitucion); + dto.vigenciaInicio = new Date(dto.vigenciaInicio); + dto.vigenciaFin = new Date(dto.vigenciaFin); + const comision = await stpsService.createComision(getContext(req), dto); + res.status(201).json({ success: true, data: comision }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /stps/comisiones/:id/estado + * Actualizar estado de comisión + */ + router.patch('/comisiones/:id/estado', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { estado } = req.body; + if (!estado) { + res.status(400).json({ error: 'Bad Request', message: 'estado is required' }); + return; + } + + const comision = await stpsService.updateComisionEstado(getContext(req), req.params.id, estado); + if (!comision) { + res.status(404).json({ error: 'Not Found', message: 'Commission not found' }); + return; + } + res.status(200).json({ success: true, data: comision }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/comisiones/:id/integrantes + * Agregar integrante a comisión + */ + router.post('/comisiones/:id/integrantes', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: AddIntegranteDto = req.body; + + if (!dto.employeeId || !dto.rol || !dto.representacion || !dto.fechaNombramiento) { + res.status(400).json({ + error: 'Bad Request', + message: 'employeeId, rol, representacion and fechaNombramiento are required', + }); + return; + } + + dto.fechaNombramiento = new Date(dto.fechaNombramiento); + const integrante = await stpsService.addIntegrante(getContext(req), req.params.id, dto); + res.status(201).json({ success: true, data: integrante }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /stps/comisiones/integrantes/:integranteId + * Desactivar integrante de comisión + */ + router.delete('/comisiones/integrantes/:integranteId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const removed = await stpsService.removeIntegrante(getContext(req), req.params.integranteId); + if (!removed) { + res.status(404).json({ error: 'Not Found', message: 'Member not found' }); + return; + } + res.status(200).json({ success: true, message: 'Member deactivated' }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/comisiones/:id/recorridos + * Programar recorrido de verificación + */ + router.post('/comisiones/:id/recorridos', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: CreateRecorridoDto = { + ...req.body, + comisionId: req.params.id, + }; + + if (!dto.fechaProgramada) { + res.status(400).json({ + error: 'Bad Request', + message: 'fechaProgramada is required', + }); + return; + } + + dto.fechaProgramada = new Date(dto.fechaProgramada); + const recorrido = await stpsService.createRecorrido(getContext(req), dto); + res.status(201).json({ success: true, data: recorrido }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/recorridos/:id/completar + * Completar recorrido de verificación + */ + router.post('/recorridos/:id/completar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { hallazgos, recomendaciones, numeroActa, documentoActaUrl } = req.body; + + const recorrido = await stpsService.completarRecorrido( + getContext(req), + req.params.id, + hallazgos, + recomendaciones, + numeroActa, + documentoActaUrl + ); + if (!recorrido) { + res.status(404).json({ error: 'Not Found', message: 'Tour not found' }); + return; + } + res.status(200).json({ success: true, data: recorrido, message: 'Tour completed' }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/recorridos/:id/cancelar + * Cancelar recorrido + */ + router.post('/recorridos/:id/cancelar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const recorrido = await stpsService.cancelarRecorrido(getContext(req), req.params.id); + if (!recorrido) { + res.status(404).json({ error: 'Not Found', message: 'Tour not found' }); + return; + } + res.status(200).json({ success: true, data: recorrido, message: 'Tour cancelled' }); + } catch (error) { + next(error); + } + }); + + // ========== Programas de Seguridad ========== + + /** + * GET /stps/programas + * Listar programas de seguridad + */ + router.get('/programas', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const fraccionamientoId = req.query.fraccionamientoId as string; + const anio = req.query.anio ? parseInt(req.query.anio as string) : undefined; + const programas = await stpsService.findProgramas(getContext(req), fraccionamientoId, anio); + res.status(200).json({ success: true, data: programas }); + } catch (error) { + next(error); + } + }); + + /** + * GET /stps/programas/:id + * Obtener programa con actividades + */ + router.get('/programas/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const programa = await stpsService.findProgramaById(getContext(req), req.params.id); + if (!programa) { + res.status(404).json({ error: 'Not Found', message: 'Program not found' }); + return; + } + res.status(200).json({ success: true, data: programa }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/programas + * Crear programa de seguridad + */ + router.post('/programas', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: CreateProgramaDto = req.body; + + if (!dto.fraccionamientoId || !dto.anio) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId and anio are required', + }); + return; + } + + const programa = await stpsService.createPrograma(getContext(req), dto); + res.status(201).json({ success: true, data: programa }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/programas/:id/aprobar + * Aprobar programa de seguridad + */ + router.post('/programas/:id/aprobar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const programa = await stpsService.aprobarPrograma(getContext(req), req.params.id); + if (!programa) { + res.status(404).json({ error: 'Not Found', message: 'Program not found' }); + return; + } + res.status(200).json({ success: true, data: programa, message: 'Program approved' }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/programas/:id/finalizar + * Finalizar programa de seguridad + */ + router.post('/programas/:id/finalizar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const programa = await stpsService.finalizarPrograma(getContext(req), req.params.id); + if (!programa) { + res.status(404).json({ error: 'Not Found', message: 'Program not found' }); + return; + } + res.status(200).json({ success: true, data: programa, message: 'Program finalized' }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/programas/:id/actividades + * Agregar actividad al programa + */ + router.post('/programas/:id/actividades', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: CreateActividadDto = { + ...req.body, + programaId: req.params.id, + }; + + if (!dto.actividad || !dto.tipo || !dto.fechaProgramada) { + res.status(400).json({ + error: 'Bad Request', + message: 'actividad, tipo and fechaProgramada are required', + }); + return; + } + + dto.fechaProgramada = new Date(dto.fechaProgramada); + const actividad = await stpsService.createActividad(getContext(req), dto); + res.status(201).json({ success: true, data: actividad }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /stps/actividades/:id/estado + * Actualizar estado de actividad + */ + router.patch('/actividades/:id/estado', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { estado } = req.body; + if (!estado) { + res.status(400).json({ error: 'Bad Request', message: 'estado is required' }); + return; + } + + const actividad = await stpsService.updateActividadEstado(getContext(req), req.params.id, estado as EstadoActividad); + if (!actividad) { + res.status(404).json({ error: 'Not Found', message: 'Activity not found' }); + return; + } + res.status(200).json({ success: true, data: actividad }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/actividades/:id/completar + * Completar actividad + */ + router.post('/actividades/:id/completar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { evidenciaUrl } = req.body; + + const actividad = await stpsService.completarActividad(getContext(req), req.params.id, evidenciaUrl); + if (!actividad) { + res.status(404).json({ error: 'Not Found', message: 'Activity not found' }); + return; + } + res.status(200).json({ success: true, data: actividad, message: 'Activity completed' }); + } catch (error) { + next(error); + } + }); + + // ========== Documentos STPS ========== + + /** + * GET /stps/documentos + * Listar documentos STPS + */ + router.get('/documentos', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const fraccionamientoId = req.query.fraccionamientoId as string; + const tipo = req.query.tipo as any; + const documentos = await stpsService.findDocumentos(getContext(req), fraccionamientoId, tipo); + res.status(200).json({ success: true, data: documentos }); + } catch (error) { + next(error); + } + }); + + /** + * GET /stps/documentos/:id + * Obtener documento por ID + */ + router.get('/documentos/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const documento = await stpsService.findDocumentoById(getContext(req), req.params.id); + if (!documento) { + res.status(404).json({ error: 'Not Found', message: 'Document not found' }); + return; + } + res.status(200).json({ success: true, data: documento }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/documentos + * Crear documento STPS + */ + router.post('/documentos', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: CreateDocumentoDto = req.body; + + if (!dto.tipo || !dto.folio || !dto.fechaEmision) { + res.status(400).json({ + error: 'Bad Request', + message: 'tipo, folio and fechaEmision are required', + }); + return; + } + + dto.fechaEmision = new Date(dto.fechaEmision); + if (dto.fechaVencimiento) dto.fechaVencimiento = new Date(dto.fechaVencimiento); + const documento = await stpsService.createDocumento(getContext(req), dto); + res.status(201).json({ success: true, data: documento }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/documentos/:id/firmar + * Marcar documento como firmado + */ + router.post('/documentos/:id/firmar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const documento = await stpsService.marcarDocumentoFirmado(getContext(req), req.params.id); + if (!documento) { + res.status(404).json({ error: 'Not Found', message: 'Document not found' }); + return; + } + res.status(200).json({ success: true, data: documento, message: 'Document signed' }); + } catch (error) { + next(error); + } + }); + + // ========== Auditorías ========== + + /** + * GET /stps/auditorias + * Listar auditorías + */ + router.get('/auditorias', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: AuditoriaFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + tipo: req.query.tipo as any, + resultado: req.query.resultado as ResultadoAuditoria, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + }; + + const result = await stpsService.findAuditorias(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /stps/auditorias/:id + * Obtener auditoría por ID + */ + router.get('/auditorias/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const auditoria = await stpsService.findAuditoriaById(getContext(req), req.params.id); + if (!auditoria) { + res.status(404).json({ error: 'Not Found', message: 'Audit not found' }); + return; + } + res.status(200).json({ success: true, data: auditoria }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/auditorias + * Programar auditoría + */ + router.post('/auditorias', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const dto: CreateAuditoriaDto = req.body; + + if (!dto.fraccionamientoId || !dto.tipo || !dto.fechaProgramada) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId, tipo and fechaProgramada are required', + }); + return; + } + + dto.fechaProgramada = new Date(dto.fechaProgramada); + const auditoria = await stpsService.createAuditoria(getContext(req), dto); + res.status(201).json({ success: true, data: auditoria }); + } catch (error) { + next(error); + } + }); + + /** + * POST /stps/auditorias/:id/completar + * Completar auditoría + */ + router.post('/auditorias/:id/completar', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { resultado, noConformidades, observaciones, informeUrl } = req.body; + + if (!resultado || noConformidades === undefined) { + res.status(400).json({ + error: 'Bad Request', + message: 'resultado and noConformidades are required', + }); + return; + } + + const auditoria = await stpsService.completarAuditoria( + getContext(req), + req.params.id, + resultado, + noConformidades, + observaciones, + informeUrl + ); + if (!auditoria) { + res.status(404).json({ error: 'Not Found', message: 'Audit not found' }); + return; + } + res.status(200).json({ success: true, data: auditoria, message: 'Audit completed' }); + } catch (error) { + next(error); + } + }); + + // ========== Estadísticas ========== + + /** + * GET /stps/stats + * Obtener estadísticas STPS + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string; + const stats = await stpsService.getStats(getContext(req), fraccionamientoId); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createStpsController; diff --git a/src/modules/hse/entities/alerta-indicador.entity.ts b/src/modules/hse/entities/alerta-indicador.entity.ts new file mode 100644 index 0000000..b6664dd --- /dev/null +++ b/src/modules/hse/entities/alerta-indicador.entity.ts @@ -0,0 +1,76 @@ +/** + * AlertaIndicador Entity + * Alertas generadas por indicadores HSE + * + * @module HSE + * @table hse.alertas_indicadores + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-008 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { IndicadorConfig } from './indicador-config.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +export type TipoAlertaIndicador = 'meta_superada' | 'tendencia_negativa' | 'sin_datos'; + +@Entity({ schema: 'hse', name: 'alertas_indicadores' }) +export class AlertaIndicador { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'indicador_id', type: 'uuid' }) + indicadorId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true }) + fraccionamientoId: string; + + @Column({ + name: 'tipo_alerta', + type: 'enum', + enum: ['meta_superada', 'tendencia_negativa', 'sin_datos'], + }) + tipoAlerta: TipoAlertaIndicador; + + @Column({ type: 'text' }) + mensaje: string; + + @Column({ name: 'valor_actual', type: 'decimal', precision: 10, scale: 4, nullable: true }) + valorActual: number; + + @Column({ name: 'valor_meta', type: 'decimal', precision: 10, scale: 4, nullable: true }) + valorMeta: number; + + @Column({ type: 'boolean', default: false }) + leida: boolean; + + @Column({ name: 'fecha_alerta', type: 'timestamptz', default: () => 'NOW()' }) + fechaAlerta: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => IndicadorConfig) + @JoinColumn({ name: 'indicador_id' }) + indicador: IndicadorConfig; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; +} diff --git a/src/modules/hse/entities/almacen-temporal.entity.ts b/src/modules/hse/entities/almacen-temporal.entity.ts new file mode 100644 index 0000000..1694b85 --- /dev/null +++ b/src/modules/hse/entities/almacen-temporal.entity.ts @@ -0,0 +1,71 @@ +/** + * AlmacenTemporal Entity + * Almacenes temporales de residuos + * + * @module HSE + * @table hse.almacen_temporal + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-006 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +export type EstadoAlmacen = 'operativo' | 'lleno' | 'mantenimiento'; + +@Entity({ schema: 'hse', name: 'almacen_temporal' }) +export class AlmacenTemporal { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ type: 'varchar', length: 100 }) + nombre: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + ubicacion: string; + + @Column({ name: 'capacidad_m3', type: 'decimal', precision: 8, scale: 2, nullable: true }) + capacidadM3: number; + + @Column({ name: 'tiene_contencion', type: 'boolean', default: true }) + tieneContencion: boolean; + + @Column({ name: 'tiene_techo', type: 'boolean', default: true }) + tieneTecho: boolean; + + @Column({ name: 'senalizacion_ok', type: 'boolean', default: true }) + senalizacionOk: boolean; + + @Column({ + type: 'enum', + enum: ['operativo', 'lleno', 'mantenimiento'], + default: 'operativo', + }) + estado: EstadoAlmacen; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; +} diff --git a/src/modules/hse/entities/auditoria.entity.ts b/src/modules/hse/entities/auditoria.entity.ts new file mode 100644 index 0000000..ca2082a --- /dev/null +++ b/src/modules/hse/entities/auditoria.entity.ts @@ -0,0 +1,82 @@ +/** + * Auditoria Entity + * Auditorías de seguridad + * + * @module HSE + * @table hse.auditorias + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-005 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +export type TipoAuditoria = 'interna' | 'simulada' | 'stps' | 'cliente' | 'certificadora'; +export type ResultadoAuditoria = 'aprobada' | 'aprobada_observaciones' | 'no_aprobada'; + +@Entity({ schema: 'hse', name: 'auditorias' }) +export class Auditoria { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ + type: 'enum', + enum: ['interna', 'simulada', 'stps', 'cliente', 'certificadora'], + }) + tipo: TipoAuditoria; + + @Column({ name: 'fecha_programada', type: 'date' }) + fechaProgramada: Date; + + @Column({ name: 'fecha_realizada', type: 'date', nullable: true }) + fechaRealizada: Date; + + @Column({ type: 'varchar', length: 200, nullable: true }) + auditor: string; + + @Column({ + type: 'enum', + enum: ['aprobada', 'aprobada_observaciones', 'no_aprobada'], + nullable: true, + }) + resultado: ResultadoAuditoria; + + @Column({ name: 'no_conformidades', type: 'integer', default: 0 }) + noConformidades: number; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @Column({ name: 'informe_url', type: 'varchar', length: 500, nullable: true }) + informeUrl: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; +} diff --git a/src/modules/hse/entities/capacitacion-asistente.entity.ts b/src/modules/hse/entities/capacitacion-asistente.entity.ts new file mode 100644 index 0000000..846d607 --- /dev/null +++ b/src/modules/hse/entities/capacitacion-asistente.entity.ts @@ -0,0 +1,66 @@ +/** + * CapacitacionAsistente Entity + * Asistencia a sesiones de capacitación + * + * @module HSE + * @table hse.capacitacion_asistentes + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-002 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { CapacitacionSesion } from './capacitacion-sesion.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +@Entity({ schema: 'hse', name: 'capacitacion_asistentes' }) +export class CapacitacionAsistente { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'sesion_id', type: 'uuid' }) + sesionId: string; + + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + @Column({ type: 'boolean', default: false }) + asistio: boolean; + + @Column({ name: 'hora_entrada', type: 'time', nullable: true }) + horaEntrada: string; + + @Column({ name: 'hora_salida', type: 'time', nullable: true }) + horaSalida: string; + + @Column({ type: 'integer', nullable: true }) + calificacion: number; + + @Column({ type: 'boolean', nullable: true }) + aprobado: boolean; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => CapacitacionSesion, (s) => s.asistentes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'sesion_id' }) + sesion: CapacitacionSesion; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; +} diff --git a/src/modules/hse/entities/capacitacion-matriz.entity.ts b/src/modules/hse/entities/capacitacion-matriz.entity.ts new file mode 100644 index 0000000..45cb358 --- /dev/null +++ b/src/modules/hse/entities/capacitacion-matriz.entity.ts @@ -0,0 +1,53 @@ +/** + * CapacitacionMatriz Entity + * Matriz de capacitación por puesto + * + * @module HSE + * @table hse.capacitacion_matriz + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-002 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Capacitacion } from './capacitacion.entity'; + +@Entity({ schema: 'hse', name: 'capacitacion_matriz' }) +export class CapacitacionMatriz { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'puesto_id', type: 'uuid' }) + puestoId: string; + + @Column({ name: 'capacitacion_id', type: 'uuid' }) + capacitacionId: string; + + @Column({ name: 'es_obligatoria', type: 'boolean', default: true }) + esObligatoria: boolean; + + @Column({ name: 'plazo_dias', type: 'integer', default: 30 }) + plazoDias: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Capacitacion) + @JoinColumn({ name: 'capacitacion_id' }) + capacitacion: Capacitacion; +} diff --git a/src/modules/hse/entities/capacitacion-sesion.entity.ts b/src/modules/hse/entities/capacitacion-sesion.entity.ts new file mode 100644 index 0000000..3e51b38 --- /dev/null +++ b/src/modules/hse/entities/capacitacion-sesion.entity.ts @@ -0,0 +1,96 @@ +/** + * CapacitacionSesion Entity + * Sesiones de capacitación programadas + * + * @module HSE + * @table hse.capacitacion_sesiones + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-002 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Capacitacion } from './capacitacion.entity'; +import { Instructor } from './instructor.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { CapacitacionAsistente } from './capacitacion-asistente.entity'; + +export type EstadoSesion = 'programada' | 'en_curso' | 'completada' | 'cancelada'; + +@Entity({ schema: 'hse', name: 'capacitacion_sesiones' }) +export class CapacitacionSesion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'capacitacion_id', type: 'uuid' }) + capacitacionId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true }) + fraccionamientoId: string; + + @Column({ name: 'instructor_id', type: 'uuid', nullable: true }) + instructorId: string; + + @Column({ name: 'fecha_programada', type: 'date' }) + fechaProgramada: Date; + + @Column({ name: 'hora_inicio', type: 'time' }) + horaInicio: string; + + @Column({ name: 'hora_fin', type: 'time' }) + horaFin: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + lugar: string; + + @Column({ name: 'cupo_maximo', type: 'integer', nullable: true }) + cupoMaximo: number; + + @Column({ + type: 'enum', + enum: ['programada', 'en_curso', 'completada', 'cancelada'], + default: 'programada', + }) + estado: EstadoSesion; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Capacitacion) + @JoinColumn({ name: 'capacitacion_id' }) + capacitacion: Capacitacion; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => Instructor) + @JoinColumn({ name: 'instructor_id' }) + instructor: Instructor; + + @OneToMany(() => CapacitacionAsistente, (a) => a.sesion) + asistentes: CapacitacionAsistente[]; +} diff --git a/src/modules/hse/entities/capacitacion.entity.ts b/src/modules/hse/entities/capacitacion.entity.ts new file mode 100644 index 0000000..ecbd699 --- /dev/null +++ b/src/modules/hse/entities/capacitacion.entity.ts @@ -0,0 +1,74 @@ +/** + * Capacitacion Entity + * Catálogo de capacitaciones HSE + * + * @module HSE + * @table hse.capacitaciones + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-002 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; + +export type TipoCapacitacion = 'induccion' | 'especifica' | 'certificacion' | 'reentrenamiento'; + +@Entity({ schema: 'hse', name: 'capacitaciones' }) +@Index(['tenantId', 'codigo'], { unique: true }) +export class Capacitacion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + codigo: string; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ + type: 'enum', + enum: ['induccion', 'especifica', 'certificacion', 'reentrenamiento'] + }) + tipo: TipoCapacitacion; + + @Column({ name: 'duracion_horas', type: 'integer', default: 1 }) + duracionHoras: number; + + @Column({ name: 'vigencia_meses', type: 'integer', nullable: true }) + vigenciaMeses: number; + + @Column({ name: 'requiere_evaluacion', type: 'boolean', default: false }) + requiereEvaluacion: boolean; + + @Column({ name: 'calificacion_minima', type: 'integer', nullable: true }) + calificacionMinima: number; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; +} diff --git a/src/modules/hse/entities/checklist-item.entity.ts b/src/modules/hse/entities/checklist-item.entity.ts new file mode 100644 index 0000000..abe7261 --- /dev/null +++ b/src/modules/hse/entities/checklist-item.entity.ts @@ -0,0 +1,54 @@ +/** + * ChecklistItem Entity + * Items de checklist de inspección + * + * @module HSE + * @table hse.checklist_items + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-003 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { TipoInspeccion } from './tipo-inspeccion.entity'; + +@Entity({ schema: 'hse', name: 'checklist_items' }) +export class ChecklistItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tipo_inspeccion_id', type: 'uuid' }) + tipoInspeccionId: string; + + @Column({ name: 'numero_orden', type: 'integer' }) + numeroOrden: number; + + @Column({ type: 'varchar', length: 100, nullable: true }) + categoria: string; + + @Column({ type: 'text' }) + descripcion: string; + + @Column({ name: 'criterio_cumplimiento', type: 'text', nullable: true }) + criterioCumplimiento: string; + + @Column({ name: 'es_critico', type: 'boolean', default: false }) + esCritico: boolean; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => TipoInspeccion, (t) => t.checklistItems, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tipo_inspeccion_id' }) + tipoInspeccion: TipoInspeccion; +} diff --git a/src/modules/hse/entities/comision-integrante.entity.ts b/src/modules/hse/entities/comision-integrante.entity.ts new file mode 100644 index 0000000..487b443 --- /dev/null +++ b/src/modules/hse/entities/comision-integrante.entity.ts @@ -0,0 +1,65 @@ +/** + * ComisionIntegrante Entity + * Integrantes de comisiones de seguridad + * + * @module HSE + * @table hse.comision_integrantes + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-005 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ComisionSeguridad } from './comision-seguridad.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type RolComision = 'presidente' | 'secretario' | 'vocal_patronal' | 'vocal_trabajador'; +export type Representacion = 'patronal' | 'trabajadores'; + +@Entity({ schema: 'hse', name: 'comision_integrantes' }) +export class ComisionIntegrante { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'comision_id', type: 'uuid' }) + comisionId: string; + + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + @Column({ + type: 'enum', + enum: ['presidente', 'secretario', 'vocal_patronal', 'vocal_trabajador'], + }) + rol: RolComision; + + @Column({ + type: 'enum', + enum: ['patronal', 'trabajadores'], + }) + representacion: Representacion; + + @Column({ name: 'fecha_nombramiento', type: 'date' }) + fechaNombramiento: Date; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => ComisionSeguridad, (c) => c.integrantes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'comision_id' }) + comision: ComisionSeguridad; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; +} diff --git a/src/modules/hse/entities/comision-recorrido.entity.ts b/src/modules/hse/entities/comision-recorrido.entity.ts new file mode 100644 index 0000000..884729d --- /dev/null +++ b/src/modules/hse/entities/comision-recorrido.entity.ts @@ -0,0 +1,70 @@ +/** + * ComisionRecorrido Entity + * Recorridos de verificación de comisión de seguridad + * + * @module HSE + * @table hse.comision_recorridos + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-005 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ComisionSeguridad } from './comision-seguridad.entity'; + +export type EstadoRecorrido = 'programado' | 'realizado' | 'cancelado' | 'pendiente'; + +@Entity({ schema: 'hse', name: 'comision_recorridos' }) +export class ComisionRecorrido { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'comision_id', type: 'uuid' }) + comisionId: string; + + @Column({ name: 'fecha_programada', type: 'date' }) + fechaProgramada: Date; + + @Column({ name: 'fecha_realizada', type: 'date', nullable: true }) + fechaRealizada: Date; + + @Column({ name: 'numero_acta', type: 'varchar', length: 50, nullable: true }) + numeroActa: string; + + @Column({ name: 'areas_recorridas', type: 'text', nullable: true }) + areasRecorridas: string; + + @Column({ type: 'text', nullable: true }) + hallazgos: string; + + @Column({ type: 'text', nullable: true }) + recomendaciones: string; + + @Column({ + type: 'enum', + enum: ['programado', 'realizado', 'cancelado', 'pendiente'], + default: 'programado', + }) + estado: EstadoRecorrido; + + @Column({ name: 'documento_acta_url', type: 'varchar', length: 500, nullable: true }) + documentoActaUrl: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => ComisionSeguridad, (c) => c.recorridos, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'comision_id' }) + comision: ComisionSeguridad; +} diff --git a/src/modules/hse/entities/comision-seguridad.entity.ts b/src/modules/hse/entities/comision-seguridad.entity.ts new file mode 100644 index 0000000..22ee33e --- /dev/null +++ b/src/modules/hse/entities/comision-seguridad.entity.ts @@ -0,0 +1,83 @@ +/** + * ComisionSeguridad Entity + * Comisiones de seguridad e higiene + * + * @module HSE + * @table hse.comision_seguridad + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-005 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { ComisionIntegrante } from './comision-integrante.entity'; +import { ComisionRecorrido } from './comision-recorrido.entity'; + +export type EstadoComision = 'activa' | 'vencida' | 'renovada'; + +@Entity({ schema: 'hse', name: 'comision_seguridad' }) +@Index(['vigenciaFin']) +export class ComisionSeguridad { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'fecha_constitucion', type: 'date' }) + fechaConstitucion: Date; + + @Column({ name: 'numero_acta', type: 'varchar', length: 50, nullable: true }) + numeroActa: string; + + @Column({ name: 'vigencia_inicio', type: 'date' }) + vigenciaInicio: Date; + + @Column({ name: 'vigencia_fin', type: 'date' }) + vigenciaFin: Date; + + @Column({ + type: 'enum', + enum: ['activa', 'vencida', 'renovada'], + default: 'activa', + }) + estado: EstadoComision; + + @Column({ name: 'documento_acta_url', type: 'varchar', length: 500, nullable: true }) + documentoActaUrl: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @OneToMany(() => ComisionIntegrante, (i) => i.comision) + integrantes: ComisionIntegrante[]; + + @OneToMany(() => ComisionRecorrido, (r) => r.comision) + recorridos: ComisionRecorrido[]; +} diff --git a/src/modules/hse/entities/constancia-dc3.entity.ts b/src/modules/hse/entities/constancia-dc3.entity.ts new file mode 100644 index 0000000..82ed0b4 --- /dev/null +++ b/src/modules/hse/entities/constancia-dc3.entity.ts @@ -0,0 +1,74 @@ +/** + * ConstanciaDc3 Entity + * Constancias DC-3 de capacitación STPS + * + * @module HSE + * @table hse.constancias_dc3 + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-002 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { CapacitacionAsistente } from './capacitacion-asistente.entity'; +import { Employee } from '../../hr/entities/employee.entity'; +import { Capacitacion } from './capacitacion.entity'; + +@Entity({ schema: 'hse', name: 'constancias_dc3' }) +@Index(['tenantId', 'folio'], { unique: true }) +export class ConstanciaDc3 { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 30 }) + folio: string; + + @Column({ name: 'asistente_id', type: 'uuid' }) + asistenteId: string; + + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + @Column({ name: 'capacitacion_id', type: 'uuid' }) + capacitacionId: string; + + @Column({ name: 'fecha_emision', type: 'date' }) + fechaEmision: Date; + + @Column({ name: 'fecha_vencimiento', type: 'date', nullable: true }) + fechaVencimiento: Date; + + @Column({ name: 'documento_url', type: 'varchar', length: 500, nullable: true }) + documentoUrl: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => CapacitacionAsistente) + @JoinColumn({ name: 'asistente_id' }) + asistente: CapacitacionAsistente; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; + + @ManyToOne(() => Capacitacion) + @JoinColumn({ name: 'capacitacion_id' }) + capacitacion: Capacitacion; +} diff --git a/src/modules/hse/entities/cumplimiento-obra.entity.ts b/src/modules/hse/entities/cumplimiento-obra.entity.ts new file mode 100644 index 0000000..8e1c11b --- /dev/null +++ b/src/modules/hse/entities/cumplimiento-obra.entity.ts @@ -0,0 +1,93 @@ +/** + * CumplimientoObra Entity + * Estado de cumplimiento de normas por obra + * + * @module HSE + * @table hse.cumplimiento_obra + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-005 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { NormaStps } from './norma-stps.entity'; +import { NormaRequisito } from './norma-requisito.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type EstadoCumplimiento = 'cumple' | 'parcial' | 'no_cumple' | 'no_aplica'; + +@Entity({ schema: 'hse', name: 'cumplimiento_obra' }) +export class CumplimientoObra { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'norma_id', type: 'uuid' }) + normaId: string; + + @Column({ name: 'requisito_id', type: 'uuid', nullable: true }) + requisitoId: string; + + @Column({ + type: 'enum', + enum: ['cumple', 'parcial', 'no_cumple', 'no_aplica'], + default: 'no_cumple', + }) + estado: EstadoCumplimiento; + + @Column({ name: 'evidencia_url', type: 'varchar', length: 500, nullable: true }) + evidenciaUrl: string; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @Column({ name: 'fecha_evaluacion', type: 'date' }) + fechaEvaluacion: Date; + + @Column({ name: 'evaluador_id', type: 'uuid', nullable: true }) + evaluadorId: string; + + @Column({ name: 'fecha_compromiso', type: 'date', nullable: true }) + fechaCompromiso: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => NormaStps) + @JoinColumn({ name: 'norma_id' }) + norma: NormaStps; + + @ManyToOne(() => NormaRequisito) + @JoinColumn({ name: 'requisito_id' }) + requisito: NormaRequisito; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'evaluador_id' }) + evaluador: Employee; +} diff --git a/src/modules/hse/entities/dias-sin-accidente.entity.ts b/src/modules/hse/entities/dias-sin-accidente.entity.ts new file mode 100644 index 0000000..5512945 --- /dev/null +++ b/src/modules/hse/entities/dias-sin-accidente.entity.ts @@ -0,0 +1,63 @@ +/** + * DiasSinAccidente Entity + * Contador de días sin accidente por obra + * + * @module HSE + * @table hse.dias_sin_accidente + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-008 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { Incidente } from './incidente.entity'; + +@Entity({ schema: 'hse', name: 'dias_sin_accidente' }) +@Unique(['tenantId', 'fraccionamientoId']) +export class DiasSinAccidente { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'fecha_inicio_conteo', type: 'date' }) + fechaInicioConteo: Date; + + @Column({ name: 'dias_acumulados', type: 'integer', default: 0 }) + diasAcumulados: number; + + @Column({ name: 'record_historico', type: 'integer', default: 0 }) + recordHistorico: number; + + @Column({ name: 'ultimo_incidente_id', type: 'uuid', nullable: true }) + ultimoIncidenteId: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => Incidente) + @JoinColumn({ name: 'ultimo_incidente_id' }) + ultimoIncidente: Incidente; +} diff --git a/src/modules/hse/entities/documento-stps.entity.ts b/src/modules/hse/entities/documento-stps.entity.ts new file mode 100644 index 0000000..59d46df --- /dev/null +++ b/src/modules/hse/entities/documento-stps.entity.ts @@ -0,0 +1,89 @@ +/** + * DocumentoStps Entity + * Documentos STPS emitidos (DC-1, DC-2, DC-3, etc.) + * + * @module HSE + * @table hse.documentos_stps + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-005 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { Employee } from '../../hr/entities/employee.entity'; +import { User } from '../../core/entities/user.entity'; + +export type TipoDocumentoStps = 'dc1' | 'dc2' | 'dc3' | 'dc4' | 'st7' | 'st9'; + +@Entity({ schema: 'hse', name: 'documentos_stps' }) +@Index(['tenantId', 'tipo', 'folio'], { unique: true }) +@Index(['fechaVencimiento']) +export class DocumentoStps { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ + type: 'enum', + enum: ['dc1', 'dc2', 'dc3', 'dc4', 'st7', 'st9'], + }) + tipo: TipoDocumentoStps; + + @Column({ type: 'varchar', length: 30 }) + folio: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true }) + fraccionamientoId: string; + + @Column({ name: 'employee_id', type: 'uuid', nullable: true }) + employeeId: string; + + @Column({ name: 'fecha_emision', type: 'date' }) + fechaEmision: Date; + + @Column({ name: 'fecha_vencimiento', type: 'date', nullable: true }) + fechaVencimiento: Date; + + @Column({ name: 'datos_documento', type: 'jsonb', nullable: true }) + datosDocumento: Record; + + @Column({ name: 'documento_url', type: 'varchar', length: 500, nullable: true }) + documentoUrl: string; + + @Column({ type: 'boolean', default: false }) + firmado: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/hse/entities/epp-asignacion.entity.ts b/src/modules/hse/entities/epp-asignacion.entity.ts new file mode 100644 index 0000000..9c660c0 --- /dev/null +++ b/src/modules/hse/entities/epp-asignacion.entity.ts @@ -0,0 +1,109 @@ +/** + * EppAsignacion Entity + * Asignaciones de EPP a trabajadores + * + * @module HSE + * @table hse.epp_asignaciones + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-004 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Employee } from '../../hr/entities/employee.entity'; +import { EppCatalogo } from './epp-catalogo.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { User } from '../../core/entities/user.entity'; + +export type EstadoEpp = 'activo' | 'vencido' | 'danado' | 'perdido' | 'devuelto'; + +@Entity({ schema: 'hse', name: 'epp_asignaciones' }) +@Index(['employeeId']) +@Index(['fechaVencimiento']) +@Index(['estado']) +export class EppAsignacion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + @Column({ name: 'epp_id', type: 'uuid' }) + eppId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true }) + fraccionamientoId: string; + + @Column({ name: 'fecha_entrega', type: 'date' }) + fechaEntrega: Date; + + @Column({ name: 'fecha_vencimiento', type: 'date' }) + fechaVencimiento: Date; + + @Column({ name: 'numero_serie', type: 'varchar', length: 100, nullable: true }) + numeroSerie: string; + + @Column({ name: 'numero_lote', type: 'varchar', length: 100, nullable: true }) + numeroLote: string; + + @Column({ name: 'firma_trabajador', type: 'text', nullable: true }) + firmaTrabajador: string; + + @Column({ name: 'foto_entrega_url', type: 'varchar', length: 500, nullable: true }) + fotoEntregaUrl: string; + + @Column({ name: 'capacitacion_uso', type: 'boolean', default: false }) + capacitacionUso: boolean; + + @Column({ + type: 'enum', + enum: ['activo', 'vencido', 'danado', 'perdido', 'devuelto'], + default: 'activo', + }) + estado: EstadoEpp; + + @Column({ name: 'costo_unitario', type: 'decimal', precision: 10, scale: 2, nullable: true }) + costoUnitario: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; + + @ManyToOne(() => EppCatalogo) + @JoinColumn({ name: 'epp_id' }) + epp: EppCatalogo; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/hse/entities/epp-baja.entity.ts b/src/modules/hse/entities/epp-baja.entity.ts new file mode 100644 index 0000000..f674329 --- /dev/null +++ b/src/modules/hse/entities/epp-baja.entity.ts @@ -0,0 +1,64 @@ +/** + * EppBaja Entity + * Bajas de EPP + * + * @module HSE + * @table hse.epp_bajas + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-004 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { EppAsignacion } from './epp-asignacion.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type MotivoBajaEpp = 'vencimiento' | 'danado' | 'perdido' | 'terminacion_laboral'; + +@Entity({ schema: 'hse', name: 'epp_bajas' }) +export class EppBaja { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'asignacion_id', type: 'uuid' }) + asignacionId: string; + + @Column({ name: 'fecha_baja', type: 'date' }) + fechaBaja: Date; + + @Column({ + type: 'enum', + enum: ['vencimiento', 'danado', 'perdido', 'terminacion_laboral'], + }) + motivo: MotivoBajaEpp; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ name: 'descuento_aplicado', type: 'boolean', default: false }) + descuentoAplicado: boolean; + + @Column({ name: 'monto_descuento', type: 'decimal', precision: 10, scale: 2, nullable: true }) + montoDescuento: number; + + @Column({ name: 'autorizado_por', type: 'uuid', nullable: true }) + autorizadoPorId: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => EppAsignacion) + @JoinColumn({ name: 'asignacion_id' }) + asignacion: EppAsignacion; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'autorizado_por' }) + autorizadoPor: Employee; +} diff --git a/src/modules/hse/entities/epp-catalogo.entity.ts b/src/modules/hse/entities/epp-catalogo.entity.ts new file mode 100644 index 0000000..a5e2b68 --- /dev/null +++ b/src/modules/hse/entities/epp-catalogo.entity.ts @@ -0,0 +1,86 @@ +/** + * EppCatalogo Entity + * Catálogo de Equipos de Protección Personal + * + * @module HSE + * @table hse.epp_catalogo + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-004 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; + +export type CategoriaEpp = 'cabeza' | 'ojos' | 'auditiva' | 'respiratoria' | 'manos' | 'pies' | 'caidas' | 'ropa'; + +@Entity({ schema: 'hse', name: 'epp_catalogo' }) +@Index(['tenantId', 'codigo'], { unique: true }) +export class EppCatalogo { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + codigo: string; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ + type: 'enum', + enum: ['cabeza', 'ojos', 'auditiva', 'respiratoria', 'manos', 'pies', 'caidas', 'ropa'], + }) + categoria: CategoriaEpp; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ type: 'text', nullable: true }) + especificaciones: string; + + @Column({ name: 'vida_util_dias', type: 'integer' }) + vidaUtilDias: number; + + @Column({ name: 'norma_referencia', type: 'varchar', length: 50, nullable: true }) + normaReferencia: string; + + @Column({ name: 'requiere_certificacion', type: 'boolean', default: false }) + requiereCertificacion: boolean; + + @Column({ name: 'requiere_inspeccion_periodica', type: 'boolean', default: false }) + requiereInspeccionPeriodica: boolean; + + @Column({ name: 'frecuencia_inspeccion_dias', type: 'integer', nullable: true }) + frecuenciaInspeccionDias: number; + + @Column({ name: 'alerta_dias_antes', type: 'integer', default: 15 }) + alertaDiasAntes: number; + + @Column({ name: 'imagen_url', type: 'varchar', length: 500, nullable: true }) + imagenUrl: string; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; +} diff --git a/src/modules/hse/entities/epp-inspeccion.entity.ts b/src/modules/hse/entities/epp-inspeccion.entity.ts new file mode 100644 index 0000000..beb13d8 --- /dev/null +++ b/src/modules/hse/entities/epp-inspeccion.entity.ts @@ -0,0 +1,65 @@ +/** + * EppInspeccion Entity + * Inspecciones periódicas de EPP asignado + * + * @module HSE + * @table hse.epp_inspecciones + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-004 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { EppAsignacion } from './epp-asignacion.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type EstadoInspeccionEpp = 'bueno' | 'regular' | 'malo' | 'danado'; + +@Entity({ schema: 'hse', name: 'epp_inspecciones' }) +export class EppInspeccion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'asignacion_id', type: 'uuid' }) + asignacionId: string; + + @Column({ name: 'inspector_id', type: 'uuid' }) + inspectorId: string; + + @Column({ name: 'fecha_inspeccion', type: 'date' }) + fechaInspeccion: Date; + + @Column({ + name: 'estado_epp', + type: 'enum', + enum: ['bueno', 'regular', 'malo', 'danado'], + }) + estadoEpp: EstadoInspeccionEpp; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @Column({ name: 'requiere_reemplazo', type: 'boolean', default: false }) + requiereReemplazo: boolean; + + @Column({ name: 'foto_url', type: 'varchar', length: 500, nullable: true }) + fotoUrl: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => EppAsignacion, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'asignacion_id' }) + asignacion: EppAsignacion; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'inspector_id' }) + inspector: Employee; +} diff --git a/src/modules/hse/entities/epp-inventario.entity.ts b/src/modules/hse/entities/epp-inventario.entity.ts new file mode 100644 index 0000000..a507b0f --- /dev/null +++ b/src/modules/hse/entities/epp-inventario.entity.ts @@ -0,0 +1,62 @@ +/** + * EppInventario Entity + * Inventario de EPP en almacén + * + * @module HSE + * @table hse.epp_inventario + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-004 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { EppCatalogo } from './epp-catalogo.entity'; + +@Entity({ schema: 'hse', name: 'epp_inventario' }) +export class EppInventario { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'epp_id', type: 'uuid' }) + eppId: string; + + @Column({ name: 'almacen_id', type: 'uuid', nullable: true }) + almacenId: string; + + @Column({ name: 'cantidad_disponible', type: 'integer', default: 0 }) + cantidadDisponible: number; + + @Column({ name: 'cantidad_minima', type: 'integer', default: 0 }) + cantidadMinima: number; + + @Column({ name: 'cantidad_maxima', type: 'integer', nullable: true }) + cantidadMaxima: number; + + @Column({ name: 'costo_promedio', type: 'decimal', precision: 10, scale: 2, nullable: true }) + costoPromedio: number; + + @Column({ name: 'ultima_entrada', type: 'date', nullable: true }) + ultimaEntrada: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => EppCatalogo) + @JoinColumn({ name: 'epp_id' }) + epp: EppCatalogo; +} diff --git a/src/modules/hse/entities/epp-matriz-puesto.entity.ts b/src/modules/hse/entities/epp-matriz-puesto.entity.ts new file mode 100644 index 0000000..f4f3c7f --- /dev/null +++ b/src/modules/hse/entities/epp-matriz-puesto.entity.ts @@ -0,0 +1,56 @@ +/** + * EppMatrizPuesto Entity + * Matriz de EPP requerido por puesto + * + * @module HSE + * @table hse.epp_matriz_puesto + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-004 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { EppCatalogo } from './epp-catalogo.entity'; + +@Entity({ schema: 'hse', name: 'epp_matriz_puesto' }) +export class EppMatrizPuesto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'puesto_id', type: 'uuid' }) + puestoId: string; + + @Column({ name: 'epp_id', type: 'uuid' }) + eppId: string; + + @Column({ name: 'es_obligatorio', type: 'boolean', default: true }) + esObligatorio: boolean; + + @Column({ name: 'actividad_especifica', type: 'varchar', length: 200, nullable: true }) + actividadEspecifica: string; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => EppCatalogo) + @JoinColumn({ name: 'epp_id' }) + epp: EppCatalogo; +} diff --git a/src/modules/hse/entities/epp-movimiento.entity.ts b/src/modules/hse/entities/epp-movimiento.entity.ts new file mode 100644 index 0000000..3bf0c1c --- /dev/null +++ b/src/modules/hse/entities/epp-movimiento.entity.ts @@ -0,0 +1,75 @@ +/** + * EppMovimiento Entity + * Movimientos de inventario de EPP + * + * @module HSE + * @table hse.epp_movimientos + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-004 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { EppCatalogo } from './epp-catalogo.entity'; +import { User } from '../../core/entities/user.entity'; + +export type TipoMovimientoEpp = 'entrada' | 'salida' | 'transferencia' | 'ajuste'; + +@Entity({ schema: 'hse', name: 'epp_movimientos' }) +export class EppMovimiento { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'epp_id', type: 'uuid' }) + eppId: string; + + @Column({ name: 'almacen_origen_id', type: 'uuid', nullable: true }) + almacenOrigenId: string; + + @Column({ name: 'almacen_destino_id', type: 'uuid', nullable: true }) + almacenDestinoId: string; + + @Column({ + type: 'enum', + enum: ['entrada', 'salida', 'transferencia', 'ajuste'], + }) + tipo: TipoMovimientoEpp; + + @Column({ type: 'integer' }) + cantidad: number; + + @Column({ type: 'varchar', length: 100, nullable: true }) + referencia: string; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => EppCatalogo) + @JoinColumn({ name: 'epp_id' }) + epp: EppCatalogo; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/hse/entities/hallazgo-evidencia.entity.ts b/src/modules/hse/entities/hallazgo-evidencia.entity.ts new file mode 100644 index 0000000..42ab8e9 --- /dev/null +++ b/src/modules/hse/entities/hallazgo-evidencia.entity.ts @@ -0,0 +1,67 @@ +/** + * HallazgoEvidencia Entity + * Evidencias de hallazgos de inspección + * + * @module HSE + * @table hse.hallazgo_evidencias + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-003 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Hallazgo } from './hallazgo.entity'; +import { User } from '../../core/entities/user.entity'; + +export type TipoEvidencia = 'hallazgo' | 'correccion'; + +@Entity({ schema: 'hse', name: 'hallazgo_evidencias' }) +export class HallazgoEvidencia { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'hallazgo_id', type: 'uuid' }) + hallazgoId: string; + + @Column({ + type: 'enum', + enum: ['hallazgo', 'correccion'], + }) + tipo: TipoEvidencia; + + @Column({ name: 'archivo_url', type: 'varchar', length: 500 }) + archivoUrl: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + descripcion: string; + + @Column({ + name: 'ubicacion_geo', + type: 'geometry', + spatialFeatureType: 'Point', + srid: 4326, + nullable: true, + }) + ubicacionGeo: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Hallazgo, (h) => h.evidencias, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'hallazgo_id' }) + hallazgo: Hallazgo; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/hse/entities/hallazgo.entity.ts b/src/modules/hse/entities/hallazgo.entity.ts new file mode 100644 index 0000000..b1a7b13 --- /dev/null +++ b/src/modules/hse/entities/hallazgo.entity.ts @@ -0,0 +1,133 @@ +/** + * Hallazgo Entity + * Hallazgos de inspecciones de seguridad + * + * @module HSE + * @table hse.hallazgos + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-003 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Inspeccion } from './inspeccion.entity'; +import { InspeccionEvaluacion } from './inspeccion-evaluacion.entity'; +import { Employee } from '../../hr/entities/employee.entity'; +import { HallazgoEvidencia } from './hallazgo-evidencia.entity'; +import { FactorCausa } from './incidente-investigacion.entity'; + +export type GravedadHallazgo = 'critico' | 'mayor' | 'menor'; +export type EstadoHallazgo = 'abierto' | 'en_correccion' | 'verificando' | 'cerrado' | 'reabierto'; + +@Entity({ schema: 'hse', name: 'hallazgos' }) +@Index(['tenantId', 'folio'], { unique: true }) +@Index(['estado']) +@Index(['fechaLimite']) +export class Hallazgo { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'inspeccion_id', type: 'uuid' }) + inspeccionId: string; + + @Column({ name: 'evaluacion_id', type: 'uuid', nullable: true }) + evaluacionId: string; + + @Column({ type: 'varchar', length: 20 }) + folio: string; + + @Column({ + type: 'enum', + enum: ['critico', 'mayor', 'menor'], + }) + gravedad: GravedadHallazgo; + + @Column({ + type: 'enum', + enum: ['acto_inseguro', 'condicion_insegura'], + }) + tipo: FactorCausa; + + @Column({ type: 'text' }) + descripcion: string; + + @Column({ name: 'ubicacion_descripcion', type: 'varchar', length: 500, nullable: true }) + ubicacionDescripcion: string; + + @Column({ + name: 'ubicacion_geo', + type: 'geometry', + spatialFeatureType: 'Point', + srid: 4326, + nullable: true, + }) + ubicacionGeo: string; + + @Column({ name: 'responsable_correccion_id', type: 'uuid', nullable: true }) + responsableCorreccionId: string; + + @Column({ name: 'fecha_limite', type: 'date' }) + fechaLimite: Date; + + @Column({ + type: 'enum', + enum: ['abierto', 'en_correccion', 'verificando', 'cerrado', 'reabierto'], + default: 'abierto', + }) + estado: EstadoHallazgo; + + @Column({ name: 'fecha_correccion', type: 'timestamptz', nullable: true }) + fechaCorreccion: Date; + + @Column({ name: 'descripcion_correccion', type: 'text', nullable: true }) + descripcionCorreccion: string; + + @Column({ name: 'verificador_id', type: 'uuid', nullable: true }) + verificadorId: string; + + @Column({ name: 'fecha_verificacion', type: 'timestamptz', nullable: true }) + fechaVerificacion: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Inspeccion, (i) => i.hallazgos) + @JoinColumn({ name: 'inspeccion_id' }) + inspeccion: Inspeccion; + + @ManyToOne(() => InspeccionEvaluacion) + @JoinColumn({ name: 'evaluacion_id' }) + evaluacion: InspeccionEvaluacion; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'responsable_correccion_id' }) + responsableCorreccion: Employee; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'verificador_id' }) + verificador: Employee; + + @OneToMany(() => HallazgoEvidencia, (e) => e.hallazgo) + evidencias: HallazgoEvidencia[]; +} diff --git a/src/modules/hse/entities/horas-trabajadas.entity.ts b/src/modules/hse/entities/horas-trabajadas.entity.ts new file mode 100644 index 0000000..3f1b817 --- /dev/null +++ b/src/modules/hse/entities/horas-trabajadas.entity.ts @@ -0,0 +1,70 @@ +/** + * HorasTrabajadas Entity + * Registro de horas trabajadas para cálculo de indicadores + * + * @module HSE + * @table hse.horas_trabajadas + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-008 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, + Unique, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +export type FuenteHoras = 'asistencia' | 'manual'; + +@Entity({ schema: 'hse', name: 'horas_trabajadas' }) +@Index(['fecha']) +@Unique(['tenantId', 'fraccionamientoId', 'fecha']) +export class HorasTrabajadas { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ type: 'date' }) + fecha: Date; + + @Column({ name: 'horas_totales', type: 'decimal', precision: 12, scale: 2 }) + horasTotales: number; + + @Column({ name: 'trabajadores_promedio', type: 'integer' }) + trabajadoresPromedio: number; + + @Column({ + type: 'enum', + enum: ['asistencia', 'manual'], + default: 'manual', + }) + fuente: FuenteHoras; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; +} diff --git a/src/modules/hse/entities/impacto-ambiental.entity.ts b/src/modules/hse/entities/impacto-ambiental.entity.ts new file mode 100644 index 0000000..b013a1e --- /dev/null +++ b/src/modules/hse/entities/impacto-ambiental.entity.ts @@ -0,0 +1,101 @@ +/** + * ImpactoAmbiental Entity + * Identificación y gestión de impactos ambientales + * + * @module HSE + * @table hse.impacto_ambiental + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-006 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type TipoImpacto = 'ruido' | 'polvo' | 'vibraciones' | 'agua' | 'emision' | 'vegetacion' | 'otro'; +export type Severidad = 'bajo' | 'medio' | 'alto'; +export type Probabilidad = 'baja' | 'media' | 'alta'; +export type NivelRiesgo = 'tolerable' | 'moderado' | 'significativo'; +export type EstadoImpacto = 'identificado' | 'mitigando' | 'controlado'; + +@Entity({ schema: 'hse', name: 'impacto_ambiental' }) +export class ImpactoAmbiental { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ type: 'varchar', length: 200 }) + aspecto: string; + + @Column({ + name: 'tipo_impacto', + type: 'enum', + enum: ['ruido', 'polvo', 'vibraciones', 'agua', 'emision', 'vegetacion', 'otro'], + }) + tipoImpacto: TipoImpacto; + + @Column({ + type: 'enum', + enum: ['bajo', 'medio', 'alto'], + }) + severidad: Severidad; + + @Column({ + type: 'enum', + enum: ['baja', 'media', 'alta'], + }) + probabilidad: Probabilidad; + + @Column({ + name: 'nivel_riesgo', + type: 'enum', + enum: ['tolerable', 'moderado', 'significativo'], + }) + nivelRiesgo: NivelRiesgo; + + @Column({ name: 'medidas_mitigacion', type: 'text', nullable: true }) + medidasMitigacion: string; + + @Column({ name: 'responsable_id', type: 'uuid', nullable: true }) + responsableId: string; + + @Column({ + type: 'enum', + enum: ['identificado', 'mitigando', 'controlado'], + default: 'identificado', + }) + estado: EstadoImpacto; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'responsable_id' }) + responsable: Employee; +} diff --git a/src/modules/hse/entities/incidente-accion.entity.ts b/src/modules/hse/entities/incidente-accion.entity.ts new file mode 100644 index 0000000..4fae042 --- /dev/null +++ b/src/modules/hse/entities/incidente-accion.entity.ts @@ -0,0 +1,71 @@ +/** + * IncidenteAccion Entity + * Acciones correctivas de incidentes + * + * @module HSE + * @table hse.incidente_acciones + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-001 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Incidente } from './incidente.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type EstadoAccion = 'pendiente' | 'en_progreso' | 'completada' | 'verificada'; + +@Entity({ schema: 'hse', name: 'incidente_acciones' }) +export class IncidenteAccion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'incidente_id', type: 'uuid' }) + incidenteId: string; + + @Column({ type: 'text' }) + descripcion: string; + + @Column({ type: 'varchar', length: 50 }) + tipo: string; + + @Column({ name: 'responsable_id', type: 'uuid', nullable: true }) + responsableId: string; + + @Column({ name: 'fecha_compromiso', type: 'date' }) + fechaCompromiso: Date; + + @Column({ name: 'fecha_cierre', type: 'date', nullable: true }) + fechaCierre: Date; + + @Column({ type: 'varchar', length: 20, default: 'pendiente' }) + estado: EstadoAccion; + + @Column({ name: 'evidencia_url', type: 'varchar', length: 500, nullable: true }) + evidenciaUrl: string; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Incidente, (i) => i.acciones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'incidente_id' }) + incidente: Incidente; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'responsable_id' }) + responsable: Employee; +} diff --git a/src/modules/hse/entities/incidente-evidencia.entity.ts b/src/modules/hse/entities/incidente-evidencia.entity.ts new file mode 100644 index 0000000..cdbfd42 --- /dev/null +++ b/src/modules/hse/entities/incidente-evidencia.entity.ts @@ -0,0 +1,62 @@ +/** + * IncidenteEvidencia Entity + * Evidencias fotográficas de incidentes + * + * @module HSE + * @table hse.incidente_evidencias + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-001 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Incidente } from './incidente.entity'; +import { User } from '../../core/entities/user.entity'; + +@Entity({ schema: 'hse', name: 'incidente_evidencias' }) +export class IncidenteEvidencia { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'incidente_id', type: 'uuid' }) + incidenteId: string; + + @Column({ type: 'varchar', length: 50, default: 'foto' }) + tipo: string; + + @Column({ name: 'archivo_url', type: 'varchar', length: 500 }) + archivoUrl: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + descripcion: string; + + @Column({ + name: 'ubicacion_geo', + type: 'geometry', + spatialFeatureType: 'Point', + srid: 4326, + nullable: true, + }) + ubicacionGeo: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Incidente, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'incidente_id' }) + incidente: Incidente; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/hse/entities/incidente-investigacion.entity.ts b/src/modules/hse/entities/incidente-investigacion.entity.ts new file mode 100644 index 0000000..6aca639 --- /dev/null +++ b/src/modules/hse/entities/incidente-investigacion.entity.ts @@ -0,0 +1,73 @@ +/** + * IncidenteInvestigacion Entity + * Investigación de incidentes de seguridad + * + * @module HSE + * @table hse.incidente_investigacion + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-001 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Incidente } from './incidente.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type FactorCausa = 'acto_inseguro' | 'condicion_insegura'; + +@Entity({ schema: 'hse', name: 'incidente_investigacion' }) +export class IncidenteInvestigacion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'incidente_id', type: 'uuid' }) + incidenteId: string; + + @Column({ name: 'fecha_inicio', type: 'date' }) + fechaInicio: Date; + + @Column({ name: 'fecha_cierre', type: 'date', nullable: true }) + fechaCierre: Date; + + @Column({ name: 'investigador_id', type: 'uuid', nullable: true }) + investigadorId: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + metodologia: string; + + @Column({ + name: 'factor_causa', + type: 'enum', + enum: ['acto_inseguro', 'condicion_insegura'], + nullable: true, + }) + factorCausa: FactorCausa; + + @Column({ name: 'analisis_causas', type: 'text', nullable: true }) + analisisCausas: string; + + @Column({ type: 'text', nullable: true }) + conclusiones: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Incidente, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'incidente_id' }) + incidente: Incidente; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'investigador_id' }) + investigador: Employee; +} diff --git a/src/modules/hse/entities/incidente-involucrado.entity.ts b/src/modules/hse/entities/incidente-involucrado.entity.ts new file mode 100644 index 0000000..b92eb67 --- /dev/null +++ b/src/modules/hse/entities/incidente-involucrado.entity.ts @@ -0,0 +1,58 @@ +/** + * IncidenteInvolucrado Entity + * Personas involucradas en incidentes + * + * @module HSE + * @table hse.incidente_involucrados + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-001 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Incidente } from './incidente.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type RolInvolucrado = 'lesionado' | 'testigo' | 'responsable'; + +@Entity({ schema: 'hse', name: 'incidente_involucrados' }) +export class IncidenteInvolucrado { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'incidente_id', type: 'uuid' }) + incidenteId: string; + + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + @Column({ type: 'enum', enum: ['lesionado', 'testigo', 'responsable'] }) + rol: RolInvolucrado; + + @Column({ name: 'descripcion_lesion', type: 'text', nullable: true }) + descripcionLesion: string; + + @Column({ name: 'parte_cuerpo', type: 'varchar', length: 100, nullable: true }) + parteCuerpo: string; + + @Column({ name: 'dias_incapacidad', type: 'integer', default: 0 }) + diasIncapacidad: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Incidente, (i) => i.involucrados, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'incidente_id' }) + incidente: Incidente; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; +} diff --git a/src/modules/hse/entities/incidente.entity.ts b/src/modules/hse/entities/incidente.entity.ts new file mode 100644 index 0000000..249c5a6 --- /dev/null +++ b/src/modules/hse/entities/incidente.entity.ts @@ -0,0 +1,111 @@ +/** + * Incidente Entity + * Gestión de incidentes de seguridad + * + * @module HSE + * @table hse.incidentes + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-001 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { IncidenteInvolucrado } from './incidente-involucrado.entity'; +import { IncidenteAccion } from './incidente-accion.entity'; + +export type TipoIncidente = 'accidente' | 'incidente' | 'casi_accidente'; +export type GravedadIncidente = 'leve' | 'moderado' | 'grave' | 'fatal'; +export type EstadoIncidente = 'abierto' | 'en_investigacion' | 'cerrado'; + +@Entity({ schema: 'hse', name: 'incidentes' }) +@Index(['tenantId', 'folio'], { unique: true }) +export class Incidente { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + folio: string; + + @Column({ name: 'fecha_hora', type: 'timestamptz' }) + fechaHora: Date; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'ubicacion_descripcion', type: 'text', nullable: true }) + ubicacionDescripcion: string; + + @Column({ + name: 'ubicacion_geo', + type: 'geometry', + spatialFeatureType: 'Point', + srid: 4326, + nullable: true + }) + ubicacionGeo: string; + + @Column({ type: 'enum', enum: ['accidente', 'incidente', 'casi_accidente'] }) + tipo: TipoIncidente; + + @Column({ type: 'enum', enum: ['leve', 'moderado', 'grave', 'fatal'] }) + gravedad: GravedadIncidente; + + @Column({ type: 'text' }) + descripcion: string; + + @Column({ name: 'causa_inmediata', type: 'text', nullable: true }) + causaInmediata: string; + + @Column({ name: 'causa_basica', type: 'text', nullable: true }) + causaBasica: string; + + @Column({ + type: 'enum', + enum: ['abierto', 'en_investigacion', 'cerrado'], + default: 'abierto' + }) + estado: EstadoIncidente; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => IncidenteInvolucrado, (ii) => ii.incidente) + involucrados: IncidenteInvolucrado[]; + + @OneToMany(() => IncidenteAccion, (ia) => ia.incidente) + acciones: IncidenteAccion[]; +} diff --git a/src/modules/hse/entities/index.ts b/src/modules/hse/entities/index.ts new file mode 100644 index 0000000..ae62006 --- /dev/null +++ b/src/modules/hse/entities/index.ts @@ -0,0 +1,82 @@ +/** + * HSE Entities Index + * @module HSE + * + * Entities for Health, Safety & Environment module + * Based on RF-MAA017-001 to RF-MAA017-008 + * Updated: 2025-12-18 + */ + +// RF-MAA017-001: Gestión de Incidentes +export * from './incidente.entity'; +export * from './incidente-involucrado.entity'; +export * from './incidente-accion.entity'; +export * from './incidente-investigacion.entity'; +export * from './incidente-evidencia.entity'; + +// RF-MAA017-002: Control de Capacitaciones +export * from './capacitacion.entity'; +export * from './capacitacion-matriz.entity'; +export * from './instructor.entity'; +export * from './capacitacion-sesion.entity'; +export * from './capacitacion-asistente.entity'; +export * from './constancia-dc3.entity'; + +// RF-MAA017-003: Inspecciones de Seguridad +export * from './tipo-inspeccion.entity'; +export * from './checklist-item.entity'; +export * from './programa-inspeccion.entity'; +export * from './inspeccion.entity'; +export * from './inspeccion-evaluacion.entity'; +export * from './hallazgo.entity'; +export * from './hallazgo-evidencia.entity'; + +// RF-MAA017-004: Control de EPP +export * from './epp-catalogo.entity'; +export * from './epp-matriz-puesto.entity'; +export * from './epp-asignacion.entity'; +export * from './epp-inspeccion.entity'; +export * from './epp-baja.entity'; +export * from './epp-inventario.entity'; +export * from './epp-movimiento.entity'; + +// RF-MAA017-005: Cumplimiento STPS +export * from './norma-stps.entity'; +export * from './norma-requisito.entity'; +export * from './cumplimiento-obra.entity'; +export * from './comision-seguridad.entity'; +export * from './comision-integrante.entity'; +export * from './comision-recorrido.entity'; +export * from './programa-seguridad.entity'; +export * from './programa-actividad.entity'; +export * from './documento-stps.entity'; +export * from './auditoria.entity'; + +// RF-MAA017-006: Gestión Ambiental +export * from './residuo-catalogo.entity'; +export * from './residuo-generacion.entity'; +export * from './almacen-temporal.entity'; +export * from './proveedor-ambiental.entity'; +export * from './manifiesto-residuos.entity'; +export * from './manifiesto-detalle.entity'; +export * from './impacto-ambiental.entity'; +export * from './queja-ambiental.entity'; + +// RF-MAA017-007: Permisos de Trabajo +export * from './tipo-permiso-trabajo.entity'; +export * from './permiso-trabajo.entity'; +export * from './permiso-personal.entity'; +export * from './permiso-autorizacion.entity'; +export * from './permiso-checklist.entity'; +export * from './permiso-monitoreo.entity'; +export * from './permiso-evento.entity'; +export * from './permiso-documento.entity'; + +// RF-MAA017-008: Indicadores HSE +export * from './indicador-config.entity'; +export * from './indicador-meta-obra.entity'; +export * from './indicador-valor.entity'; +export * from './horas-trabajadas.entity'; +export * from './dias-sin-accidente.entity'; +export * from './reporte-programado.entity'; +export * from './alerta-indicador.entity'; diff --git a/src/modules/hse/entities/indicador-config.entity.ts b/src/modules/hse/entities/indicador-config.entity.ts new file mode 100644 index 0000000..f7829d2 --- /dev/null +++ b/src/modules/hse/entities/indicador-config.entity.ts @@ -0,0 +1,85 @@ +/** + * IndicadorConfig Entity + * Configuración de indicadores HSE + * + * @module HSE + * @table hse.indicadores_config + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-008 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; + +export type TipoIndicador = 'reactivo' | 'proactivo' | 'ambiental'; +export type FrecuenciaCalculo = 'diario' | 'semanal' | 'mensual'; + +@Entity({ schema: 'hse', name: 'indicadores_config' }) +@Index(['tenantId', 'codigo'], { unique: true }) +export class IndicadorConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + codigo: string; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ type: 'text', nullable: true }) + formula: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + unidad: string; + + @Column({ + type: 'enum', + enum: ['reactivo', 'proactivo', 'ambiental'], + }) + tipo: TipoIndicador; + + @Column({ name: 'meta_global', type: 'decimal', precision: 10, scale: 4, nullable: true }) + metaGlobal: number; + + @Column({ name: 'umbral_verde', type: 'decimal', precision: 10, scale: 4, nullable: true }) + umbralVerde: number; + + @Column({ name: 'umbral_amarillo', type: 'decimal', precision: 10, scale: 4, nullable: true }) + umbralAmarillo: number; + + @Column({ name: 'umbral_rojo', type: 'decimal', precision: 10, scale: 4, nullable: true }) + umbralRojo: number; + + @Column({ + name: 'frecuencia_calculo', + type: 'enum', + enum: ['diario', 'semanal', 'mensual'], + default: 'mensual', + }) + frecuenciaCalculo: FrecuenciaCalculo; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; +} diff --git a/src/modules/hse/entities/indicador-meta-obra.entity.ts b/src/modules/hse/entities/indicador-meta-obra.entity.ts new file mode 100644 index 0000000..c308242 --- /dev/null +++ b/src/modules/hse/entities/indicador-meta-obra.entity.ts @@ -0,0 +1,54 @@ +/** + * IndicadorMetaObra Entity + * Metas de indicadores por obra/fraccionamiento + * + * @module HSE + * @table hse.indicadores_meta_obra + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-008 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { IndicadorConfig } from './indicador-config.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +@Entity({ schema: 'hse', name: 'indicadores_meta_obra' }) +export class IndicadorMetaObra { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'indicador_id', type: 'uuid' }) + indicadorId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ type: 'integer' }) + anio: number; + + @Column({ type: 'decimal', precision: 10, scale: 4 }) + meta: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => IndicadorConfig) + @JoinColumn({ name: 'indicador_id' }) + indicador: IndicadorConfig; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; +} diff --git a/src/modules/hse/entities/indicador-valor.entity.ts b/src/modules/hse/entities/indicador-valor.entity.ts new file mode 100644 index 0000000..e6fc4bb --- /dev/null +++ b/src/modules/hse/entities/indicador-valor.entity.ts @@ -0,0 +1,86 @@ +/** + * IndicadorValor Entity + * Valores calculados de indicadores HSE + * + * @module HSE + * @table hse.indicadores_valores + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-008 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { IndicadorConfig } from './indicador-config.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +export type PeriodoTipo = 'diario' | 'semanal' | 'mensual' | 'anual'; +export type EstadoSemaforo = 'verde' | 'amarillo' | 'rojo'; + +@Entity({ schema: 'hse', name: 'indicadores_valores' }) +@Index(['periodoFecha']) +export class IndicadorValor { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'indicador_id', type: 'uuid' }) + indicadorId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true }) + fraccionamientoId: string; + + @Column({ + name: 'periodo_tipo', + type: 'enum', + enum: ['diario', 'semanal', 'mensual', 'anual'], + }) + periodoTipo: PeriodoTipo; + + @Column({ name: 'periodo_fecha', type: 'date' }) + periodoFecha: Date; + + @Column({ type: 'decimal', precision: 15, scale: 4, nullable: true }) + valor: number; + + @Column({ type: 'decimal', precision: 15, scale: 4, nullable: true }) + numerador: number; + + @Column({ type: 'decimal', precision: 15, scale: 4, nullable: true }) + denominador: number; + + @Column({ + type: 'enum', + enum: ['verde', 'amarillo', 'rojo'], + nullable: true, + }) + estado: EstadoSemaforo; + + @Column({ name: 'calculado_at', type: 'timestamptz', default: () => 'NOW()' }) + calculadoAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => IndicadorConfig) + @JoinColumn({ name: 'indicador_id' }) + indicador: IndicadorConfig; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; +} diff --git a/src/modules/hse/entities/inspeccion-evaluacion.entity.ts b/src/modules/hse/entities/inspeccion-evaluacion.entity.ts new file mode 100644 index 0000000..9c8bfc9 --- /dev/null +++ b/src/modules/hse/entities/inspeccion-evaluacion.entity.ts @@ -0,0 +1,58 @@ +/** + * InspeccionEvaluacion Entity + * Evaluaciones de items en inspecciones + * + * @module HSE + * @table hse.inspeccion_evaluaciones + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-003 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Inspeccion } from './inspeccion.entity'; +import { ChecklistItem } from './checklist-item.entity'; + +export type ResultadoEvaluacion = 'cumple' | 'no_cumple' | 'no_aplica'; + +@Entity({ schema: 'hse', name: 'inspeccion_evaluaciones' }) +export class InspeccionEvaluacion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'inspeccion_id', type: 'uuid' }) + inspeccionId: string; + + @Column({ name: 'checklist_item_id', type: 'uuid' }) + checklistItemId: string; + + @Column({ + type: 'enum', + enum: ['cumple', 'no_cumple', 'no_aplica'], + }) + resultado: ResultadoEvaluacion; + + @Column({ type: 'text', nullable: true }) + observacion: string; + + @Column({ name: 'genera_hallazgo', type: 'boolean', default: false }) + generaHallazgo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Inspeccion, (i) => i.evaluaciones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'inspeccion_id' }) + inspeccion: Inspeccion; + + @ManyToOne(() => ChecklistItem) + @JoinColumn({ name: 'checklist_item_id' }) + checklistItem: ChecklistItem; +} diff --git a/src/modules/hse/entities/inspeccion.entity.ts b/src/modules/hse/entities/inspeccion.entity.ts new file mode 100644 index 0000000..2111696 --- /dev/null +++ b/src/modules/hse/entities/inspeccion.entity.ts @@ -0,0 +1,124 @@ +/** + * Inspeccion Entity + * Inspecciones de seguridad ejecutadas + * + * @module HSE + * @table hse.inspecciones + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-003 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { TipoInspeccion } from './tipo-inspeccion.entity'; +import { ProgramaInspeccion } from './programa-inspeccion.entity'; +import { Employee } from '../../hr/entities/employee.entity'; +import { InspeccionEvaluacion } from './inspeccion-evaluacion.entity'; +import { Hallazgo } from './hallazgo.entity'; + +@Entity({ schema: 'hse', name: 'inspecciones' }) +@Index(['tenantId']) +@Index(['fraccionamientoId']) +@Index(['fechaInicio']) +export class Inspeccion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'programa_id', type: 'uuid', nullable: true }) + programaId: string; + + @Column({ name: 'tipo_inspeccion_id', type: 'uuid' }) + tipoInspeccionId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'inspector_id', type: 'uuid' }) + inspectorId: string; + + @Column({ name: 'fecha_inicio', type: 'timestamptz' }) + fechaInicio: Date; + + @Column({ name: 'fecha_fin', type: 'timestamptz', nullable: true }) + fechaFin: Date; + + @Column({ + name: 'ubicacion_geo', + type: 'geometry', + spatialFeatureType: 'Point', + srid: 4326, + nullable: true, + }) + ubicacionGeo: string; + + @Column({ name: 'items_evaluados', type: 'integer', default: 0 }) + itemsEvaluados: number; + + @Column({ name: 'items_cumple', type: 'integer', default: 0 }) + itemsCumple: number; + + @Column({ name: 'items_no_cumple', type: 'integer', default: 0 }) + itemsNoCumple: number; + + @Column({ name: 'items_no_aplica', type: 'integer', default: 0 }) + itemsNoAplica: number; + + @Column({ name: 'porcentaje_cumplimiento', type: 'decimal', precision: 5, scale: 2, nullable: true }) + porcentajeCumplimiento: number; + + @Column({ name: 'observaciones_generales', type: 'text', nullable: true }) + observacionesGenerales: string; + + @Column({ name: 'firma_inspector', type: 'text', nullable: true }) + firmaInspector: string; + + @Column({ type: 'varchar', length: 20, default: 'borrador' }) + estado: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => ProgramaInspeccion) + @JoinColumn({ name: 'programa_id' }) + programa: ProgramaInspeccion; + + @ManyToOne(() => TipoInspeccion) + @JoinColumn({ name: 'tipo_inspeccion_id' }) + tipoInspeccion: TipoInspeccion; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'inspector_id' }) + inspector: Employee; + + @OneToMany(() => InspeccionEvaluacion, (e) => e.inspeccion) + evaluaciones: InspeccionEvaluacion[]; + + @OneToMany(() => Hallazgo, (h) => h.inspeccion) + hallazgos: Hallazgo[]; +} diff --git a/src/modules/hse/entities/instructor.entity.ts b/src/modules/hse/entities/instructor.entity.ts new file mode 100644 index 0000000..d69102e --- /dev/null +++ b/src/modules/hse/entities/instructor.entity.ts @@ -0,0 +1,69 @@ +/** + * Instructor Entity + * Instructores de capacitación HSE + * + * @module HSE + * @table hse.instructores + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-002 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +@Entity({ schema: 'hse', name: 'instructores' }) +export class Instructor { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ name: 'registro_stps', type: 'varchar', length: 50, nullable: true }) + registroStps: string; + + @Column({ type: 'text', nullable: true }) + especialidades: string; + + @Column({ name: 'es_interno', type: 'boolean', default: false }) + esInterno: boolean; + + @Column({ name: 'employee_id', type: 'uuid', nullable: true }) + employeeId: string; + + @Column({ name: 'contacto_telefono', type: 'varchar', length: 20, nullable: true }) + contactoTelefono: string; + + @Column({ name: 'contacto_email', type: 'varchar', length: 100, nullable: true }) + contactoEmail: string; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; +} diff --git a/src/modules/hse/entities/manifiesto-detalle.entity.ts b/src/modules/hse/entities/manifiesto-detalle.entity.ts new file mode 100644 index 0000000..6c8f4d8 --- /dev/null +++ b/src/modules/hse/entities/manifiesto-detalle.entity.ts @@ -0,0 +1,60 @@ +/** + * ManifiestoDetalle Entity + * Detalle de residuos por manifiesto + * + * @module HSE + * @table hse.manifiesto_detalle + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-006 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ManifiestoResiduos } from './manifiesto-residuos.entity'; +import { ResiduoCatalogo } from './residuo-catalogo.entity'; +import { UnidadResiduo } from './residuo-generacion.entity'; + +@Entity({ schema: 'hse', name: 'manifiesto_detalle' }) +export class ManifiestoDetalle { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'manifiesto_id', type: 'uuid' }) + manifiestoId: string; + + @Column({ name: 'residuo_id', type: 'uuid' }) + residuoId: string; + + @Column({ name: 'generacion_ids', type: 'uuid', array: true, nullable: true }) + generacionIds: string[]; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + cantidad: number; + + @Column({ + type: 'enum', + enum: ['kg', 'litros', 'm3', 'piezas'], + }) + unidad: UnidadResiduo; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => ManifiestoResiduos, (m) => m.detalles, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'manifiesto_id' }) + manifiesto: ManifiestoResiduos; + + @ManyToOne(() => ResiduoCatalogo) + @JoinColumn({ name: 'residuo_id' }) + residuo: ResiduoCatalogo; +} diff --git a/src/modules/hse/entities/manifiesto-residuos.entity.ts b/src/modules/hse/entities/manifiesto-residuos.entity.ts new file mode 100644 index 0000000..164f4bb --- /dev/null +++ b/src/modules/hse/entities/manifiesto-residuos.entity.ts @@ -0,0 +1,95 @@ +/** + * ManifiestoResiduos Entity + * Manifiestos de entrega de residuos peligrosos + * + * @module HSE + * @table hse.manifiestos_residuos + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-006 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { ProveedorAmbiental } from './proveedor-ambiental.entity'; +import { ManifiestoDetalle } from './manifiesto-detalle.entity'; + +export type EstadoManifiesto = 'emitido' | 'en_transito' | 'entregado' | 'cerrado'; + +@Entity({ schema: 'hse', name: 'manifiestos_residuos' }) +@Index(['tenantId', 'folio'], { unique: true }) +@Index(['estado']) +export class ManifiestoResiduos { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 30 }) + folio: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'transportista_id', type: 'uuid' }) + transportistaId: string; + + @Column({ name: 'destino_id', type: 'uuid' }) + destinoId: string; + + @Column({ name: 'fecha_recoleccion', type: 'date' }) + fechaRecoleccion: Date; + + @Column({ name: 'fecha_entrega', type: 'date', nullable: true }) + fechaEntrega: Date; + + @Column({ + type: 'enum', + enum: ['emitido', 'en_transito', 'entregado', 'cerrado'], + default: 'emitido', + }) + estado: EstadoManifiesto; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @Column({ name: 'documento_url', type: 'varchar', length: 500, nullable: true }) + documentoUrl: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => ProveedorAmbiental) + @JoinColumn({ name: 'transportista_id' }) + transportista: ProveedorAmbiental; + + @ManyToOne(() => ProveedorAmbiental) + @JoinColumn({ name: 'destino_id' }) + destino: ProveedorAmbiental; + + @OneToMany(() => ManifiestoDetalle, (d) => d.manifiesto) + detalles: ManifiestoDetalle[]; +} diff --git a/src/modules/hse/entities/norma-requisito.entity.ts b/src/modules/hse/entities/norma-requisito.entity.ts new file mode 100644 index 0000000..bfc99ae --- /dev/null +++ b/src/modules/hse/entities/norma-requisito.entity.ts @@ -0,0 +1,51 @@ +/** + * NormaRequisito Entity + * Requisitos específicos por norma STPS + * + * @module HSE + * @table hse.norma_requisitos + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-005 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { NormaStps } from './norma-stps.entity'; + +@Entity({ schema: 'hse', name: 'norma_requisitos' }) +export class NormaRequisito { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'norma_id', type: 'uuid' }) + normaId: string; + + @Column({ type: 'varchar', length: 20 }) + numero: string; + + @Column({ type: 'text' }) + descripcion: string; + + @Column({ name: 'tipo_evidencia', type: 'varchar', length: 200, nullable: true }) + tipoEvidencia: string; + + @Column({ name: 'es_critico', type: 'boolean', default: false }) + esCritico: boolean; + + @Column({ name: 'aplica_a', type: 'varchar', length: 100, nullable: true }) + aplicaA: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => NormaStps, (n) => n.requisitos, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'norma_id' }) + norma: NormaStps; +} diff --git a/src/modules/hse/entities/norma-stps.entity.ts b/src/modules/hse/entities/norma-stps.entity.ts new file mode 100644 index 0000000..ff70e4a --- /dev/null +++ b/src/modules/hse/entities/norma-stps.entity.ts @@ -0,0 +1,61 @@ +/** + * NormaStps Entity + * Catálogo de normas STPS + * + * @module HSE + * @table hse.normas_stps + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-005 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { NormaRequisito } from './norma-requisito.entity'; + +@Entity({ schema: 'hse', name: 'normas_stps' }) +@Index(['codigo'], { unique: true }) +export class NormaStps { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 30, unique: true }) + codigo: string; + + @Column({ type: 'varchar', length: 300 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ name: 'fecha_publicacion', type: 'date', nullable: true }) + fechaPublicacion: Date; + + @Column({ name: 'ultima_actualizacion', type: 'date', nullable: true }) + ultimaActualizacion: Date; + + @Column({ name: 'aplica_construccion', type: 'boolean', default: true }) + aplicaConstruccion: boolean; + + @Column({ name: 'documento_url', type: 'varchar', length: 500, nullable: true }) + documentoUrl: string; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @OneToMany(() => NormaRequisito, (r) => r.norma) + requisitos: NormaRequisito[]; +} diff --git a/src/modules/hse/entities/permiso-autorizacion.entity.ts b/src/modules/hse/entities/permiso-autorizacion.entity.ts new file mode 100644 index 0000000..8ac84fe --- /dev/null +++ b/src/modules/hse/entities/permiso-autorizacion.entity.ts @@ -0,0 +1,67 @@ +/** + * PermisoAutorizacion Entity + * Autorizaciones de permisos de trabajo + * + * @module HSE + * @table hse.permiso_autorizaciones + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-007 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PermisoTrabajo } from './permiso-trabajo.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type DecisionAutorizacion = 'aprobado' | 'rechazado'; + +@Entity({ schema: 'hse', name: 'permiso_autorizaciones' }) +export class PermisoAutorizacion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'permiso_id', type: 'uuid' }) + permisoId: string; + + @Column({ type: 'integer' }) + nivel: number; + + @Column({ name: 'autorizador_id', type: 'uuid' }) + autorizadorId: string; + + @Column({ name: 'rol_autorizador', type: 'varchar', length: 100, nullable: true }) + rolAutorizador: string; + + @Column({ + type: 'enum', + enum: ['aprobado', 'rechazado'], + }) + decision: DecisionAutorizacion; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @Column({ name: 'firma_digital', type: 'text', nullable: true }) + firmaDigital: string; + + @Column({ name: 'fecha_decision', type: 'timestamptz', default: () => 'NOW()' }) + fechaDecision: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => PermisoTrabajo, (p) => p.autorizaciones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'permiso_id' }) + permiso: PermisoTrabajo; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'autorizador_id' }) + autorizador: Employee; +} diff --git a/src/modules/hse/entities/permiso-checklist.entity.ts b/src/modules/hse/entities/permiso-checklist.entity.ts new file mode 100644 index 0000000..e97f505 --- /dev/null +++ b/src/modules/hse/entities/permiso-checklist.entity.ts @@ -0,0 +1,64 @@ +/** + * PermisoChecklist Entity + * Checklist de verificación de permisos de trabajo + * + * @module HSE + * @table hse.permiso_checklist + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-007 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PermisoTrabajo } from './permiso-trabajo.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type MomentoChecklist = 'pre_trabajo' | 'durante' | 'post_trabajo'; + +@Entity({ schema: 'hse', name: 'permiso_checklist' }) +export class PermisoChecklist { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'permiso_id', type: 'uuid' }) + permisoId: string; + + @Column({ + type: 'enum', + enum: ['pre_trabajo', 'durante', 'post_trabajo'], + }) + momento: MomentoChecklist; + + @Column({ name: 'item_verificacion', type: 'varchar', length: 300 }) + itemVerificacion: string; + + @Column({ type: 'boolean', nullable: true }) + cumple: boolean; + + @Column({ type: 'text', nullable: true }) + observacion: string; + + @Column({ name: 'verificador_id', type: 'uuid', nullable: true }) + verificadorId: string; + + @Column({ name: 'fecha_verificacion', type: 'timestamptz', nullable: true }) + fechaVerificacion: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => PermisoTrabajo, (p) => p.checklist, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'permiso_id' }) + permiso: PermisoTrabajo; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'verificador_id' }) + verificador: Employee; +} diff --git a/src/modules/hse/entities/permiso-documento.entity.ts b/src/modules/hse/entities/permiso-documento.entity.ts new file mode 100644 index 0000000..ef0654a --- /dev/null +++ b/src/modules/hse/entities/permiso-documento.entity.ts @@ -0,0 +1,52 @@ +/** + * PermisoDocumento Entity + * Documentos adjuntos a permisos de trabajo + * + * @module HSE + * @table hse.permiso_documentos + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-007 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PermisoTrabajo } from './permiso-trabajo.entity'; +import { User } from '../../core/entities/user.entity'; + +@Entity({ schema: 'hse', name: 'permiso_documentos' }) +export class PermisoDocumento { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'permiso_id', type: 'uuid' }) + permisoId: string; + + @Column({ name: 'tipo_documento', type: 'varchar', length: 100 }) + tipoDocumento: string; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ name: 'archivo_url', type: 'varchar', length: 500 }) + archivoUrl: string; + + @Column({ name: 'fecha_subida', type: 'timestamptz', default: () => 'NOW()' }) + fechaSubida: Date; + + @Column({ name: 'subido_por', type: 'uuid', nullable: true }) + subidoPorId: string; + + // Relations + @ManyToOne(() => PermisoTrabajo, (p) => p.documentos, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'permiso_id' }) + permiso: PermisoTrabajo; + + @ManyToOne(() => User) + @JoinColumn({ name: 'subido_por' }) + subidoPor: User; +} diff --git a/src/modules/hse/entities/permiso-evento.entity.ts b/src/modules/hse/entities/permiso-evento.entity.ts new file mode 100644 index 0000000..4b976ff --- /dev/null +++ b/src/modules/hse/entities/permiso-evento.entity.ts @@ -0,0 +1,62 @@ +/** + * PermisoEvento Entity + * Eventos durante ejecución de permisos de trabajo + * + * @module HSE + * @table hse.permiso_eventos + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-007 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PermisoTrabajo } from './permiso-trabajo.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type TipoEventoPermiso = 'inicio' | 'suspension' | 'reanudacion' | 'extension' | 'anomalia' | 'cierre'; + +@Entity({ schema: 'hse', name: 'permiso_eventos' }) +export class PermisoEvento { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'permiso_id', type: 'uuid' }) + permisoId: string; + + @Column({ name: 'fecha_hora', type: 'timestamptz', default: () => 'NOW()' }) + fechaHora: Date; + + @Column({ + name: 'tipo_evento', + type: 'enum', + enum: ['inicio', 'suspension', 'reanudacion', 'extension', 'anomalia', 'cierre'], + }) + tipoEvento: TipoEventoPermiso; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ name: 'accion_tomada', type: 'text', nullable: true }) + accionTomada: string; + + @Column({ name: 'responsable_id', type: 'uuid' }) + responsableId: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => PermisoTrabajo, (p) => p.eventos, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'permiso_id' }) + permiso: PermisoTrabajo; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'responsable_id' }) + responsable: Employee; +} diff --git a/src/modules/hse/entities/permiso-monitoreo.entity.ts b/src/modules/hse/entities/permiso-monitoreo.entity.ts new file mode 100644 index 0000000..c5c3ccd --- /dev/null +++ b/src/modules/hse/entities/permiso-monitoreo.entity.ts @@ -0,0 +1,62 @@ +/** + * PermisoMonitoreo Entity + * Monitoreos durante ejecución de permisos de trabajo + * + * @module HSE + * @table hse.permiso_monitoreos + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-007 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PermisoTrabajo } from './permiso-trabajo.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +@Entity({ schema: 'hse', name: 'permiso_monitoreos' }) +export class PermisoMonitoreo { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'permiso_id', type: 'uuid' }) + permisoId: string; + + @Column({ name: 'fecha_hora', type: 'timestamptz' }) + fechaHora: Date; + + @Column({ type: 'varchar', length: 100 }) + tipo: string; + + @Column({ name: 'valor_medicion', type: 'varchar', length: 50, nullable: true }) + valorMedicion: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + unidad: string; + + @Column({ name: 'dentro_rango', type: 'boolean', default: true }) + dentroRango: boolean; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @Column({ name: 'responsable_id', type: 'uuid' }) + responsableId: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => PermisoTrabajo, (p) => p.monitoreos, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'permiso_id' }) + permiso: PermisoTrabajo; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'responsable_id' }) + responsable: Employee; +} diff --git a/src/modules/hse/entities/permiso-personal.entity.ts b/src/modules/hse/entities/permiso-personal.entity.ts new file mode 100644 index 0000000..a3dcf92 --- /dev/null +++ b/src/modules/hse/entities/permiso-personal.entity.ts @@ -0,0 +1,64 @@ +/** + * PermisoPersonal Entity + * Personal asignado a permisos de trabajo + * + * @module HSE + * @table hse.permiso_personal + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-007 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PermisoTrabajo } from './permiso-trabajo.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type RolPermiso = 'ejecutor' | 'supervisor' | 'vigia' | 'operador' | 'senalero'; + +@Entity({ schema: 'hse', name: 'permiso_personal' }) +export class PermisoPersonal { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'permiso_id', type: 'uuid' }) + permisoId: string; + + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + @Column({ + type: 'enum', + enum: ['ejecutor', 'supervisor', 'vigia', 'operador', 'senalero'], + }) + rol: RolPermiso; + + @Column({ name: 'verificacion_capacitacion', type: 'boolean', default: false }) + verificacionCapacitacion: boolean; + + @Column({ name: 'verificacion_epp', type: 'boolean', default: false }) + verificacionEpp: boolean; + + @Column({ name: 'verificacion_aptitud', type: 'boolean', default: false }) + verificacionAptitud: boolean; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => PermisoTrabajo, (p) => p.personal, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'permiso_id' }) + permiso: PermisoTrabajo; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; +} diff --git a/src/modules/hse/entities/permiso-trabajo.entity.ts b/src/modules/hse/entities/permiso-trabajo.entity.ts new file mode 100644 index 0000000..1433524 --- /dev/null +++ b/src/modules/hse/entities/permiso-trabajo.entity.ts @@ -0,0 +1,141 @@ +/** + * PermisoTrabajo Entity + * Permisos de trabajo de alto riesgo + * + * @module HSE + * @table hse.permisos_trabajo + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-007 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { TipoPermisoTrabajo } from './tipo-permiso-trabajo.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { Employee } from '../../hr/entities/employee.entity'; +import { PermisoPersonal } from './permiso-personal.entity'; +import { PermisoAutorizacion } from './permiso-autorizacion.entity'; +import { PermisoChecklist } from './permiso-checklist.entity'; +import { PermisoMonitoreo } from './permiso-monitoreo.entity'; +import { PermisoEvento } from './permiso-evento.entity'; +import { PermisoDocumento } from './permiso-documento.entity'; + +export type EstadoPermiso = 'borrador' | 'solicitado' | 'aprobado_parcial' | 'autorizado' | 'en_ejecucion' | 'suspendido' | 'cerrado' | 'rechazado' | 'vencido'; + +@Entity({ schema: 'hse', name: 'permisos_trabajo' }) +@Index(['tenantId', 'folio'], { unique: true }) +@Index(['estado']) +@Index(['fechaInicioProgramada']) +export class PermisoTrabajo { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 30 }) + folio: string; + + @Column({ name: 'tipo_permiso_id', type: 'uuid' }) + tipoPermisoId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'solicitante_id', type: 'uuid' }) + solicitanteId: string; + + @Column({ name: 'descripcion_trabajo', type: 'text' }) + descripcionTrabajo: string; + + @Column({ type: 'varchar', length: 300 }) + ubicacion: string; + + @Column({ + name: 'ubicacion_geo', + type: 'geometry', + spatialFeatureType: 'Point', + srid: 4326, + nullable: true, + }) + ubicacionGeo: string; + + @Column({ name: 'fecha_inicio_programada', type: 'timestamptz' }) + fechaInicioProgramada: Date; + + @Column({ name: 'fecha_fin_programada', type: 'timestamptz' }) + fechaFinProgramada: Date; + + @Column({ name: 'fecha_inicio_real', type: 'timestamptz', nullable: true }) + fechaInicioReal: Date; + + @Column({ name: 'fecha_fin_real', type: 'timestamptz', nullable: true }) + fechaFinReal: Date; + + @Column({ + type: 'enum', + enum: ['borrador', 'solicitado', 'aprobado_parcial', 'autorizado', 'en_ejecucion', 'suspendido', 'cerrado', 'rechazado', 'vencido'], + default: 'borrador', + }) + estado: EstadoPermiso; + + @Column({ name: 'motivo_rechazo', type: 'text', nullable: true }) + motivoRechazo: string; + + @Column({ name: 'motivo_suspension', type: 'text', nullable: true }) + motivoSuspension: string; + + @Column({ type: 'text', nullable: true }) + observaciones: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => TipoPermisoTrabajo) + @JoinColumn({ name: 'tipo_permiso_id' }) + tipoPermiso: TipoPermisoTrabajo; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'solicitante_id' }) + solicitante: Employee; + + @OneToMany(() => PermisoPersonal, (p) => p.permiso) + personal: PermisoPersonal[]; + + @OneToMany(() => PermisoAutorizacion, (a) => a.permiso) + autorizaciones: PermisoAutorizacion[]; + + @OneToMany(() => PermisoChecklist, (c) => c.permiso) + checklist: PermisoChecklist[]; + + @OneToMany(() => PermisoMonitoreo, (m) => m.permiso) + monitoreos: PermisoMonitoreo[]; + + @OneToMany(() => PermisoEvento, (e) => e.permiso) + eventos: PermisoEvento[]; + + @OneToMany(() => PermisoDocumento, (d) => d.permiso) + documentos: PermisoDocumento[]; +} diff --git a/src/modules/hse/entities/programa-actividad.entity.ts b/src/modules/hse/entities/programa-actividad.entity.ts new file mode 100644 index 0000000..ee8c20e --- /dev/null +++ b/src/modules/hse/entities/programa-actividad.entity.ts @@ -0,0 +1,79 @@ +/** + * ProgramaActividad Entity + * Actividades del programa de seguridad + * + * @module HSE + * @table hse.programa_actividades + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-005 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ProgramaSeguridad } from './programa-seguridad.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type TipoActividadPrograma = 'capacitacion' | 'inspeccion' | 'simulacro' | 'campana' | 'otro'; +export type EstadoActividad = 'pendiente' | 'en_progreso' | 'completada' | 'cancelada'; + +@Entity({ schema: 'hse', name: 'programa_actividades' }) +export class ProgramaActividad { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'programa_id', type: 'uuid' }) + programaId: string; + + @Column({ type: 'varchar', length: 300 }) + actividad: string; + + @Column({ + type: 'enum', + enum: ['capacitacion', 'inspeccion', 'simulacro', 'campana', 'otro'], + }) + tipo: TipoActividadPrograma; + + @Column({ name: 'fecha_programada', type: 'date' }) + fechaProgramada: Date; + + @Column({ name: 'fecha_realizada', type: 'date', nullable: true }) + fechaRealizada: Date; + + @Column({ name: 'responsable_id', type: 'uuid', nullable: true }) + responsableId: string; + + @Column({ type: 'text', nullable: true }) + recursos: string; + + @Column({ + type: 'enum', + enum: ['pendiente', 'en_progreso', 'completada', 'cancelada'], + default: 'pendiente', + }) + estado: EstadoActividad; + + @Column({ name: 'evidencia_url', type: 'varchar', length: 500, nullable: true }) + evidenciaUrl: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => ProgramaSeguridad, (p) => p.actividades, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'programa_id' }) + programa: ProgramaSeguridad; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'responsable_id' }) + responsable: Employee; +} diff --git a/src/modules/hse/entities/programa-inspeccion.entity.ts b/src/modules/hse/entities/programa-inspeccion.entity.ts new file mode 100644 index 0000000..3fcefe8 --- /dev/null +++ b/src/modules/hse/entities/programa-inspeccion.entity.ts @@ -0,0 +1,85 @@ +/** + * ProgramaInspeccion Entity + * Programa de inspecciones de seguridad + * + * @module HSE + * @table hse.programa_inspecciones + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-003 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { TipoInspeccion } from './tipo-inspeccion.entity'; +import { Employee } from '../../hr/entities/employee.entity'; + +export type EstadoInspeccion = 'programada' | 'en_progreso' | 'completada' | 'cancelada' | 'vencida'; + +@Entity({ schema: 'hse', name: 'programa_inspecciones' }) +export class ProgramaInspeccion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'tipo_inspeccion_id', type: 'uuid' }) + tipoInspeccionId: string; + + @Column({ name: 'inspector_id', type: 'uuid', nullable: true }) + inspectorId: string; + + @Column({ name: 'fecha_programada', type: 'date' }) + fechaProgramada: Date; + + @Column({ name: 'hora_programada', type: 'time', nullable: true }) + horaProgramada: string; + + @Column({ name: 'zona_area', type: 'varchar', length: 200, nullable: true }) + zonaArea: string; + + @Column({ + type: 'enum', + enum: ['programada', 'en_progreso', 'completada', 'cancelada', 'vencida'], + default: 'programada', + }) + estado: EstadoInspeccion; + + @Column({ name: 'motivo_cancelacion', type: 'text', nullable: true }) + motivoCancelacion: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => TipoInspeccion) + @JoinColumn({ name: 'tipo_inspeccion_id' }) + tipoInspeccion: TipoInspeccion; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'inspector_id' }) + inspector: Employee; +} diff --git a/src/modules/hse/entities/programa-seguridad.entity.ts b/src/modules/hse/entities/programa-seguridad.entity.ts new file mode 100644 index 0000000..bc53dce --- /dev/null +++ b/src/modules/hse/entities/programa-seguridad.entity.ts @@ -0,0 +1,85 @@ +/** + * ProgramaSeguridad Entity + * Programa anual de seguridad + * + * @module HSE + * @table hse.programa_seguridad + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-005 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { Employee } from '../../hr/entities/employee.entity'; +import { ProgramaActividad } from './programa-actividad.entity'; + +export type EstadoPrograma = 'borrador' | 'activo' | 'finalizado'; + +@Entity({ schema: 'hse', name: 'programa_seguridad' }) +export class ProgramaSeguridad { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ type: 'integer' }) + anio: number; + + @Column({ name: 'objetivo_general', type: 'text', nullable: true }) + objetivoGeneral: string; + + @Column({ type: 'jsonb', nullable: true }) + metas: Record; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + presupuesto: number; + + @Column({ + type: 'enum', + enum: ['borrador', 'activo', 'finalizado'], + default: 'borrador', + }) + estado: EstadoPrograma; + + @Column({ name: 'aprobado_por', type: 'uuid', nullable: true }) + aprobadoPorId: string; + + @Column({ name: 'fecha_aprobacion', type: 'date', nullable: true }) + fechaAprobacion: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => Employee) + @JoinColumn({ name: 'aprobado_por' }) + aprobadoPor: Employee; + + @OneToMany(() => ProgramaActividad, (a) => a.programa) + actividades: ProgramaActividad[]; +} diff --git a/src/modules/hse/entities/proveedor-ambiental.entity.ts b/src/modules/hse/entities/proveedor-ambiental.entity.ts new file mode 100644 index 0000000..463e702 --- /dev/null +++ b/src/modules/hse/entities/proveedor-ambiental.entity.ts @@ -0,0 +1,78 @@ +/** + * ProveedorAmbiental Entity + * Proveedores autorizados para manejo de residuos + * + * @module HSE + * @table hse.proveedores_ambientales + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-006 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; + +export type TipoProveedorAmbiental = 'transportista' | 'reciclador' | 'confinamiento'; + +@Entity({ schema: 'hse', name: 'proveedores_ambientales' }) +export class ProveedorAmbiental { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'razon_social', type: 'varchar', length: 300 }) + razonSocial: string; + + @Column({ type: 'varchar', length: 13, nullable: true }) + rfc: string; + + @Column({ + type: 'enum', + enum: ['transportista', 'reciclador', 'confinamiento'], + }) + tipo: TipoProveedorAmbiental; + + @Column({ name: 'numero_autorizacion', type: 'varchar', length: 50, nullable: true }) + numeroAutorizacion: string; + + @Column({ name: 'entidad_autorizadora', type: 'varchar', length: 100, nullable: true }) + entidadAutorizadora: string; + + @Column({ name: 'fecha_autorizacion', type: 'date', nullable: true }) + fechaAutorizacion: Date; + + @Column({ name: 'fecha_vencimiento', type: 'date', nullable: true }) + fechaVencimiento: Date; + + @Column({ type: 'text', nullable: true }) + servicios: string; + + @Column({ name: 'contacto_nombre', type: 'varchar', length: 200, nullable: true }) + contactoNombre: string; + + @Column({ name: 'contacto_telefono', type: 'varchar', length: 20, nullable: true }) + contactoTelefono: string; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; +} diff --git a/src/modules/hse/entities/queja-ambiental.entity.ts b/src/modules/hse/entities/queja-ambiental.entity.ts new file mode 100644 index 0000000..2f9a496 --- /dev/null +++ b/src/modules/hse/entities/queja-ambiental.entity.ts @@ -0,0 +1,89 @@ +/** + * QuejaAmbiental Entity + * Quejas ambientales de vecinos y autoridades + * + * @module HSE + * @table hse.quejas_ambientales + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-006 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +export type OrigenQueja = 'vecino' | 'autoridad' | 'interno' | 'anonimo'; +export type TipoQueja = 'ruido' | 'polvo' | 'olores' | 'agua' | 'otro'; +export type EstadoQueja = 'recibida' | 'atendiendo' | 'cerrada'; + +@Entity({ schema: 'hse', name: 'quejas_ambientales' }) +export class QuejaAmbiental { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'fecha_queja', type: 'timestamptz' }) + fechaQueja: Date; + + @Column({ + type: 'enum', + enum: ['vecino', 'autoridad', 'interno', 'anonimo'], + }) + origen: OrigenQueja; + + @Column({ + type: 'enum', + enum: ['ruido', 'polvo', 'olores', 'agua', 'otro'], + }) + tipo: TipoQueja; + + @Column({ type: 'text' }) + descripcion: string; + + @Column({ name: 'nombre_quejoso', type: 'varchar', length: 200, nullable: true }) + nombreQuejoso: string; + + @Column({ name: 'contacto_quejoso', type: 'varchar', length: 100, nullable: true }) + contactoQuejoso: string; + + @Column({ name: 'acciones_tomadas', type: 'text', nullable: true }) + accionesTomadas: string; + + @Column({ + type: 'enum', + enum: ['recibida', 'atendiendo', 'cerrada'], + default: 'recibida', + }) + estado: EstadoQueja; + + @Column({ name: 'fecha_cierre', type: 'date', nullable: true }) + fechaCierre: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; +} diff --git a/src/modules/hse/entities/reporte-programado.entity.ts b/src/modules/hse/entities/reporte-programado.entity.ts new file mode 100644 index 0000000..49bc26b --- /dev/null +++ b/src/modules/hse/entities/reporte-programado.entity.ts @@ -0,0 +1,77 @@ +/** + * ReporteProgramado Entity + * Reportes HSE programados para envío automático + * + * @module HSE + * @table hse.reportes_programados + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-008 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; + +export type TipoReporteHse = 'semanal' | 'mensual' | 'trimestral' | 'anual'; +export type FormatoReporte = 'pdf' | 'excel' | 'ambos'; + +@Entity({ schema: 'hse', name: 'reportes_programados' }) +export class ReporteProgramado { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ + name: 'tipo_reporte', + type: 'enum', + enum: ['semanal', 'mensual', 'trimestral', 'anual'], + }) + tipoReporte: TipoReporteHse; + + @Column({ type: 'uuid', array: true, nullable: true }) + indicadores: string[]; + + @Column({ type: 'uuid', array: true, nullable: true }) + fraccionamientos: string[]; + + @Column({ type: 'varchar', array: true, nullable: true }) + destinatarios: string[]; + + @Column({ name: 'dia_envio', type: 'integer', nullable: true }) + diaEnvio: number; + + @Column({ name: 'hora_envio', type: 'time', nullable: true }) + horaEnvio: string; + + @Column({ + type: 'enum', + enum: ['pdf', 'excel', 'ambos'], + default: 'pdf', + }) + formato: FormatoReporte; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @Column({ name: 'ultimo_envio', type: 'timestamptz', nullable: true }) + ultimoEnvio: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; +} diff --git a/src/modules/hse/entities/residuo-catalogo.entity.ts b/src/modules/hse/entities/residuo-catalogo.entity.ts new file mode 100644 index 0000000..d5779d6 --- /dev/null +++ b/src/modules/hse/entities/residuo-catalogo.entity.ts @@ -0,0 +1,59 @@ +/** + * ResiduoCatalogo Entity + * Catálogo de tipos de residuos + * + * @module HSE + * @table hse.residuos_catalogo + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-006 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type CategoriaResiduo = 'peligroso' | 'manejo_especial' | 'urbano'; + +@Entity({ schema: 'hse', name: 'residuos_catalogo' }) +@Index(['codigo'], { unique: true }) +export class ResiduoCatalogo { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 20, unique: true }) + codigo: string; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ + type: 'enum', + enum: ['peligroso', 'manejo_especial', 'urbano'], + }) + categoria: CategoriaResiduo; + + @Column({ name: 'caracteristicas_cretib', type: 'varchar', length: 6, nullable: true }) + caracteristicasCretib: string; + + @Column({ name: 'norma_referencia', type: 'varchar', length: 50, nullable: true }) + normaReferencia: string; + + @Column({ name: 'manejo_requerido', type: 'text', nullable: true }) + manejoRequerido: string; + + @Column({ name: 'tiempo_max_almacen_dias', type: 'integer', nullable: true }) + tiempoMaxAlmacenDias: number; + + @Column({ name: 'requiere_manifiesto', type: 'boolean', default: false }) + requiereManifiesto: boolean; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/hse/entities/residuo-generacion.entity.ts b/src/modules/hse/entities/residuo-generacion.entity.ts new file mode 100644 index 0000000..4fe7ed5 --- /dev/null +++ b/src/modules/hse/entities/residuo-generacion.entity.ts @@ -0,0 +1,105 @@ +/** + * ResiduoGeneracion Entity + * Registro de generación de residuos + * + * @module HSE + * @table hse.residuos_generacion + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-006 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { ResiduoCatalogo } from './residuo-catalogo.entity'; +import { User } from '../../core/entities/user.entity'; + +export type UnidadResiduo = 'kg' | 'litros' | 'm3' | 'piezas'; +export type EstadoResiduo = 'almacenado' | 'en_transito' | 'dispuesto'; + +@Entity({ schema: 'hse', name: 'residuos_generacion' }) +@Index(['fechaGeneracion']) +export class ResiduoGeneracion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'residuo_id', type: 'uuid' }) + residuoId: string; + + @Column({ name: 'fecha_generacion', type: 'date' }) + fechaGeneracion: Date; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + cantidad: number; + + @Column({ + type: 'enum', + enum: ['kg', 'litros', 'm3', 'piezas'], + }) + unidad: UnidadResiduo; + + @Column({ name: 'area_generacion', type: 'varchar', length: 200, nullable: true }) + areaGeneracion: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + fuente: string; + + @Column({ name: 'contenedor_id', type: 'varchar', length: 50, nullable: true }) + contenedorId: string; + + @Column({ name: 'foto_url', type: 'varchar', length: 500, nullable: true }) + fotoUrl: string; + + @Column({ + name: 'ubicacion_geo', + type: 'geometry', + spatialFeatureType: 'Point', + srid: 4326, + nullable: true, + }) + ubicacionGeo: string; + + @Column({ + type: 'enum', + enum: ['almacenado', 'en_transito', 'dispuesto'], + default: 'almacenado', + }) + estado: EstadoResiduo; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => ResiduoCatalogo) + @JoinColumn({ name: 'residuo_id' }) + residuo: ResiduoCatalogo; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/hse/entities/tipo-inspeccion.entity.ts b/src/modules/hse/entities/tipo-inspeccion.entity.ts new file mode 100644 index 0000000..26ac20c --- /dev/null +++ b/src/modules/hse/entities/tipo-inspeccion.entity.ts @@ -0,0 +1,76 @@ +/** + * TipoInspeccion Entity + * Tipos de inspección de seguridad + * + * @module HSE + * @table hse.tipos_inspeccion + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-003 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { ChecklistItem } from './checklist-item.entity'; + +export type Frecuencia = 'diaria' | 'semanal' | 'quincenal' | 'mensual' | 'eventual'; + +@Entity({ schema: 'hse', name: 'tipos_inspeccion' }) +@Index(['tenantId', 'codigo'], { unique: true }) +export class TipoInspeccion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + codigo: string; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ + type: 'enum', + enum: ['diaria', 'semanal', 'quincenal', 'mensual', 'eventual'], + }) + frecuencia: Frecuencia; + + @Column({ name: 'norma_referencia', type: 'varchar', length: 50, nullable: true }) + normaReferencia: string; + + @Column({ name: 'duracion_estimada_min', type: 'integer', nullable: true }) + duracionEstimadaMin: number; + + @Column({ name: 'requiere_firma', type: 'boolean', default: true }) + requiereFirma: boolean; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @OneToMany(() => ChecklistItem, (item) => item.tipoInspeccion) + checklistItems: ChecklistItem[]; +} diff --git a/src/modules/hse/entities/tipo-permiso-trabajo.entity.ts b/src/modules/hse/entities/tipo-permiso-trabajo.entity.ts new file mode 100644 index 0000000..ca5b622 --- /dev/null +++ b/src/modules/hse/entities/tipo-permiso-trabajo.entity.ts @@ -0,0 +1,68 @@ +/** + * TipoPermisoTrabajo Entity + * Tipos de permisos de trabajo de alto riesgo + * + * @module HSE + * @table hse.tipos_permiso_trabajo + * @ddl schemas/03-hse-schema-ddl.sql + * @rf RF-MAA017-007 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; + +@Entity({ schema: 'hse', name: 'tipos_permiso_trabajo' }) +@Index(['tenantId', 'codigo'], { unique: true }) +export class TipoPermisoTrabajo { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + codigo: string; + + @Column({ type: 'varchar', length: 200 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ name: 'norma_referencia', type: 'varchar', length: 50, nullable: true }) + normaReferencia: string; + + @Column({ name: 'vigencia_max_horas', type: 'integer' }) + vigenciaMaxHoras: number; + + @Column({ name: 'requiere_autorizacion_nivel', type: 'integer', default: 2 }) + requiereAutorizacionNivel: number; + + @Column({ name: 'documentos_requeridos', type: 'jsonb', nullable: true }) + documentosRequeridos: Record; + + @Column({ name: 'requisitos_personal', type: 'jsonb', nullable: true }) + requisitosPersonal: Record; + + @Column({ name: 'equipos_requeridos', type: 'jsonb', nullable: true }) + equiposRequeridos: Record; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; +} diff --git a/src/modules/hse/services/ambiental.service.ts b/src/modules/hse/services/ambiental.service.ts new file mode 100644 index 0000000..afa5498 --- /dev/null +++ b/src/modules/hse/services/ambiental.service.ts @@ -0,0 +1,632 @@ +/** + * AmbientalService - Servicio para gestión ambiental + * + * RF-MAA017-006: Gestión de residuos, manifiestos e impactos + * + * @module HSE + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { ResiduoCatalogo, CategoriaResiduo } from '../entities/residuo-catalogo.entity'; +import { ResiduoGeneracion, UnidadResiduo, EstadoResiduo } from '../entities/residuo-generacion.entity'; +import { AlmacenTemporal } from '../entities/almacen-temporal.entity'; +import { ProveedorAmbiental } from '../entities/proveedor-ambiental.entity'; +import { ManifiestoResiduos, EstadoManifiesto } from '../entities/manifiesto-residuos.entity'; +import { ManifiestoDetalle } from '../entities/manifiesto-detalle.entity'; +import { ImpactoAmbiental, TipoImpacto, NivelRiesgo, EstadoImpacto } from '../entities/impacto-ambiental.entity'; +import { QuejaAmbiental, OrigenQueja, TipoQueja, EstadoQueja } from '../entities/queja-ambiental.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface ResiduoFilters { + categoria?: CategoriaResiduo; + activo?: boolean; + search?: string; +} + +export interface GeneracionFilters { + fraccionamientoId?: string; + residuoId?: string; + estado?: EstadoResiduo; + dateFrom?: Date; + dateTo?: Date; +} + +export interface ManifiestoFilters { + fraccionamientoId?: string; + estado?: EstadoManifiesto; + transportistaId?: string; + dateFrom?: Date; + dateTo?: Date; +} + +export interface ImpactoFilters { + fraccionamientoId?: string; + tipoImpacto?: TipoImpacto; + nivelRiesgo?: NivelRiesgo; + estado?: EstadoImpacto; +} + +export interface QuejaFilters { + fraccionamientoId?: string; + tipo?: TipoQueja; + estado?: EstadoQueja; + dateFrom?: Date; + dateTo?: Date; +} + +export interface CreateResiduoCatalogoDto { + codigo: string; + nombre: string; + categoria: CategoriaResiduo; + caracteristicasCretib?: string; + normaReferencia?: string; + manejoRequerido?: string; + tiempoMaxAlmacenDias?: number; + requiereManifiesto?: boolean; +} + +export interface CreateGeneracionDto { + fraccionamientoId: string; + residuoId: string; + fechaGeneracion: Date; + cantidad: number; + unidad: UnidadResiduo; + areaGeneracion?: string; + fuente?: string; + contenedorId?: string; +} + +export interface CreateManifiestoDto { + fraccionamientoId: string; + transportistaId: string; + destinoId: string; + fechaRecoleccion: Date; + observaciones?: string; +} + +export interface AddDetalleManifiestoDto { + residuoId: string; + generacionIds?: string[]; + cantidad: number; + unidad: UnidadResiduo; + descripcion?: string; +} + +export interface CreateImpactoDto { + fraccionamientoId: string; + aspecto: string; + tipoImpacto: TipoImpacto; + severidad: 'bajo' | 'medio' | 'alto'; + probabilidad: 'baja' | 'media' | 'alta'; + medidasMitigacion?: string; + responsableId?: string; +} + +export interface CreateQuejaDto { + fraccionamientoId: string; + origen: OrigenQueja; + tipo: TipoQueja; + descripcion: string; + nombreQuejoso?: string; + contactoQuejoso?: string; +} + +export interface AmbientalStats { + residuosGenerados: number; + residuosPendientes: number; + manifiestosPendientes: number; + impactosIdentificados: number; + impactosSignificativos: number; + quejasAbiertas: number; + residuosPorCategoria: { categoria: string; cantidad: number }[]; +} + +export class AmbientalService { + constructor( + private readonly residuoCatalogoRepository: Repository, + private readonly generacionRepository: Repository, + private readonly almacenRepository: Repository, + private readonly proveedorRepository: Repository, + private readonly manifiestoRepository: Repository, + private readonly detalleRepository: Repository, + private readonly impactoRepository: Repository, + private readonly quejaRepository: Repository + ) {} + + private generateFolio(prefix: string): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `${prefix}-${year}${month}-${random}`; + } + + // ========== Catálogo de Residuos ========== + async findResiduos( + _ctx: ServiceContext, + filters: ResiduoFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.residuoCatalogoRepository + .createQueryBuilder('r'); + + if (filters.categoria) { + queryBuilder.andWhere('r.categoria = :categoria', { categoria: filters.categoria }); + } + + if (filters.activo !== undefined) { + queryBuilder.andWhere('r.activo = :activo', { activo: filters.activo }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(r.codigo ILIKE :search OR r.nombre ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + queryBuilder + .orderBy('r.nombre', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + async createResiduo(dto: CreateResiduoCatalogoDto): Promise { + const residuo = this.residuoCatalogoRepository.create({ + codigo: dto.codigo, + nombre: dto.nombre, + categoria: dto.categoria, + caracteristicasCretib: dto.caracteristicasCretib, + normaReferencia: dto.normaReferencia, + manejoRequerido: dto.manejoRequerido, + tiempoMaxAlmacenDias: dto.tiempoMaxAlmacenDias, + requiereManifiesto: dto.requiereManifiesto ?? false, + activo: true, + }); + return this.residuoCatalogoRepository.save(residuo); + } + + // ========== Generación de Residuos ========== + async findGeneraciones( + ctx: ServiceContext, + filters: GeneracionFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.generacionRepository + .createQueryBuilder('g') + .leftJoinAndSelect('g.residuo', 'residuo') + .leftJoinAndSelect('g.fraccionamiento', 'fraccionamiento') + .where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('g.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.residuoId) { + queryBuilder.andWhere('g.residuo_id = :residuoId', { residuoId: filters.residuoId }); + } + + if (filters.estado) { + queryBuilder.andWhere('g.estado = :estado', { estado: filters.estado }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('g.fecha_generacion >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('g.fecha_generacion <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('g.fecha_generacion', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + async createGeneracion(ctx: ServiceContext, dto: CreateGeneracionDto): Promise { + const generacion = this.generacionRepository.create({ + tenantId: ctx.tenantId, + fraccionamientoId: dto.fraccionamientoId, + residuoId: dto.residuoId, + fechaGeneracion: dto.fechaGeneracion, + cantidad: dto.cantidad, + unidad: dto.unidad, + areaGeneracion: dto.areaGeneracion, + fuente: dto.fuente, + contenedorId: dto.contenedorId, + estado: 'almacenado', + createdById: ctx.userId, + }); + + return this.generacionRepository.save(generacion); + } + + async updateGeneracionEstado(ctx: ServiceContext, id: string, estado: EstadoResiduo): Promise { + const generacion = await this.generacionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!generacion) return null; + + generacion.estado = estado; + return this.generacionRepository.save(generacion); + } + + // ========== Almacenes Temporales ========== + async findAlmacenes(ctx: ServiceContext, fraccionamientoId?: string): Promise { + const where: FindOptionsWhere = { tenantId: ctx.tenantId }; + if (fraccionamientoId) { + where.fraccionamientoId = fraccionamientoId; + } + + return this.almacenRepository.find({ + where, + relations: ['fraccionamiento'], + }); + } + + // ========== Proveedores Ambientales ========== + async findProveedores(ctx: ServiceContext): Promise { + return this.proveedorRepository.find({ + where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere, + }); + } + + // ========== Manifiestos ========== + async findManifiestos( + ctx: ServiceContext, + filters: ManifiestoFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.manifiestoRepository + .createQueryBuilder('m') + .leftJoinAndSelect('m.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('m.transportista', 'transportista') + .leftJoinAndSelect('m.destino', 'destino') + .where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('m.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.estado) { + queryBuilder.andWhere('m.estado = :estado', { estado: filters.estado }); + } + + if (filters.transportistaId) { + queryBuilder.andWhere('m.transportista_id = :transportistaId', { transportistaId: filters.transportistaId }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('m.fecha_recoleccion >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('m.fecha_recoleccion <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('m.fecha_recoleccion', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + async findManifiestoWithDetalles(ctx: ServiceContext, id: string): Promise { + return this.manifiestoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento', 'transportista', 'destino', 'detalles', 'detalles.residuo'], + }); + } + + async createManifiesto(ctx: ServiceContext, dto: CreateManifiestoDto): Promise { + const manifiesto = this.manifiestoRepository.create({ + tenantId: ctx.tenantId, + folio: this.generateFolio('MAN'), + fraccionamientoId: dto.fraccionamientoId, + transportistaId: dto.transportistaId, + destinoId: dto.destinoId, + fechaRecoleccion: dto.fechaRecoleccion, + observaciones: dto.observaciones, + estado: 'emitido', + }); + + return this.manifiestoRepository.save(manifiesto); + } + + async addDetalleManifiesto(ctx: ServiceContext, manifiestoId: string, dto: AddDetalleManifiestoDto): Promise { + const manifiesto = await this.manifiestoRepository.findOne({ + where: { id: manifiestoId, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!manifiesto) throw new Error('Manifiesto no encontrado'); + + const detalle = this.detalleRepository.create({ + manifiestoId, + residuoId: dto.residuoId, + generacionIds: dto.generacionIds, + cantidad: dto.cantidad, + unidad: dto.unidad, + descripcion: dto.descripcion, + }); + + return this.detalleRepository.save(detalle); + } + + async updateManifiestoEstado(ctx: ServiceContext, id: string, estado: EstadoManifiesto): Promise { + const manifiesto = await this.manifiestoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!manifiesto) return null; + + manifiesto.estado = estado; + if (estado === 'entregado') { + manifiesto.fechaEntrega = new Date(); + } + + return this.manifiestoRepository.save(manifiesto); + } + + // ========== Impactos Ambientales ========== + async findImpactos( + ctx: ServiceContext, + filters: ImpactoFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.impactoRepository + .createQueryBuilder('i') + .leftJoinAndSelect('i.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('i.responsable', 'responsable') + .where('i.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('i.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.tipoImpacto) { + queryBuilder.andWhere('i.tipo_impacto = :tipoImpacto', { tipoImpacto: filters.tipoImpacto }); + } + + if (filters.nivelRiesgo) { + queryBuilder.andWhere('i.nivel_riesgo = :nivelRiesgo', { nivelRiesgo: filters.nivelRiesgo }); + } + + if (filters.estado) { + queryBuilder.andWhere('i.estado = :estado', { estado: filters.estado }); + } + + queryBuilder + .orderBy('i.created_at', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + async createImpacto(ctx: ServiceContext, dto: CreateImpactoDto): Promise { + // Calculate risk level + const riskMatrix: Record> = { + bajo: { baja: 'tolerable', media: 'tolerable', alta: 'moderado' }, + medio: { baja: 'tolerable', media: 'moderado', alta: 'significativo' }, + alto: { baja: 'moderado', media: 'significativo', alta: 'significativo' }, + }; + + const nivelRiesgo = riskMatrix[dto.severidad][dto.probabilidad]; + + const impacto = this.impactoRepository.create({ + tenantId: ctx.tenantId, + fraccionamientoId: dto.fraccionamientoId, + aspecto: dto.aspecto, + tipoImpacto: dto.tipoImpacto, + severidad: dto.severidad, + probabilidad: dto.probabilidad, + nivelRiesgo, + medidasMitigacion: dto.medidasMitigacion, + responsableId: dto.responsableId, + estado: 'identificado', + }); + + return this.impactoRepository.save(impacto); + } + + async updateImpactoEstado(ctx: ServiceContext, id: string, estado: EstadoImpacto): Promise { + const impacto = await this.impactoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!impacto) return null; + + impacto.estado = estado; + return this.impactoRepository.save(impacto); + } + + // ========== Quejas Ambientales ========== + async findQuejas( + ctx: ServiceContext, + filters: QuejaFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.quejaRepository + .createQueryBuilder('q') + .leftJoinAndSelect('q.fraccionamiento', 'fraccionamiento') + .where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('q.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.tipo) { + queryBuilder.andWhere('q.tipo = :tipo', { tipo: filters.tipo }); + } + + if (filters.estado) { + queryBuilder.andWhere('q.estado = :estado', { estado: filters.estado }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('q.fecha_queja >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('q.fecha_queja <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('q.fecha_queja', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + async createQueja(ctx: ServiceContext, dto: CreateQuejaDto): Promise { + const queja = this.quejaRepository.create({ + tenantId: ctx.tenantId, + fraccionamientoId: dto.fraccionamientoId, + fechaQueja: new Date(), + origen: dto.origen, + tipo: dto.tipo, + descripcion: dto.descripcion, + nombreQuejoso: dto.nombreQuejoso, + contactoQuejoso: dto.contactoQuejoso, + estado: 'recibida', + }); + + return this.quejaRepository.save(queja); + } + + async atenderQueja(ctx: ServiceContext, id: string, accionesTomadas: string): Promise { + const queja = await this.quejaRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!queja) return null; + + queja.accionesTomadas = accionesTomadas; + queja.estado = 'atendiendo'; + + return this.quejaRepository.save(queja); + } + + async cerrarQueja(ctx: ServiceContext, id: string): Promise { + const queja = await this.quejaRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!queja) return null; + + queja.fechaCierre = new Date(); + queja.estado = 'cerrada'; + + return this.quejaRepository.save(queja); + } + + // ========== Estadísticas ========== + async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise { + const genQuery = this.generacionRepository + .createQueryBuilder('g') + .where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + if (fraccionamientoId) { + genQuery.andWhere('g.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + const residuosGenerados = await genQuery.getCount(); + + const residuosPendientes = await this.generacionRepository + .createQueryBuilder('g') + .where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('g.estado = :estado', { estado: 'almacenado' }) + .getCount(); + + const manifiestosPendientes = await this.manifiestoRepository + .createQueryBuilder('m') + .where('m.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('m.estado IN (:...estados)', { estados: ['emitido', 'en_transito'] }) + .getCount(); + + const impactosIdentificados = await this.impactoRepository.count({ + where: { tenantId: ctx.tenantId } as FindOptionsWhere, + }); + + const impactosSignificativos = await this.impactoRepository.count({ + where: { tenantId: ctx.tenantId, nivelRiesgo: 'significativo' } as FindOptionsWhere, + }); + + const quejasAbiertas = await this.quejaRepository + .createQueryBuilder('q') + .where('q.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('q.estado != :cerrada', { cerrada: 'cerrada' }) + .getCount(); + + // Residuos por categoría + const residuosPorCategoria = await this.generacionRepository + .createQueryBuilder('g') + .innerJoin('g.residuo', 'r') + .select('r.categoria', 'categoria') + .addSelect('SUM(g.cantidad)', 'cantidad') + .where('g.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .groupBy('r.categoria') + .getRawMany(); + + return { + residuosGenerados, + residuosPendientes, + manifiestosPendientes, + impactosIdentificados, + impactosSignificativos, + quejasAbiertas, + residuosPorCategoria: residuosPorCategoria.map((r) => ({ + categoria: r.categoria, + cantidad: parseFloat(r.cantidad || '0'), + })), + }; + } +} diff --git a/src/modules/hse/services/capacitacion.service.ts b/src/modules/hse/services/capacitacion.service.ts new file mode 100644 index 0000000..62740cc --- /dev/null +++ b/src/modules/hse/services/capacitacion.service.ts @@ -0,0 +1,159 @@ +/** + * CapacitacionService - Servicio para catálogo de capacitaciones HSE + * + * Gestión de capacitaciones con CRUD y filtros. + * + * @module HSE + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Capacitacion, TipoCapacitacion } from '../entities/capacitacion.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateCapacitacionDto { + codigo: string; + nombre: string; + descripcion?: string; + tipo: TipoCapacitacion; + duracionHoras?: number; + vigenciaMeses?: number; + requiereEvaluacion?: boolean; + calificacionMinima?: number; +} + +export interface UpdateCapacitacionDto { + nombre?: string; + descripcion?: string; + tipo?: TipoCapacitacion; + duracionHoras?: number; + vigenciaMeses?: number; + requiereEvaluacion?: boolean; + calificacionMinima?: number; + activo?: boolean; +} + +export interface CapacitacionFilters { + tipo?: TipoCapacitacion; + activo?: boolean; + search?: string; +} + +export class CapacitacionService { + constructor(private readonly repository: Repository) {} + + async findAll( + ctx: ServiceContext, + filters: CapacitacionFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.repository + .createQueryBuilder('capacitacion') + .where('capacitacion.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.tipo) { + queryBuilder.andWhere('capacitacion.tipo = :tipo', { tipo: filters.tipo }); + } + + if (filters.activo !== undefined) { + queryBuilder.andWhere('capacitacion.activo = :activo', { activo: filters.activo }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(capacitacion.codigo ILIKE :search OR capacitacion.nombre ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + queryBuilder + .orderBy('capacitacion.nombre', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + } + + async findByCodigo(ctx: ServiceContext, codigo: string): Promise { + return this.repository.findOne({ + where: { + codigo, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + } + + async create(ctx: ServiceContext, dto: CreateCapacitacionDto): Promise { + const existing = await this.findByCodigo(ctx, dto.codigo); + if (existing) { + throw new Error(`Capacitacion with codigo ${dto.codigo} already exists`); + } + + const capacitacion = this.repository.create({ + tenantId: ctx.tenantId, + codigo: dto.codigo, + nombre: dto.nombre, + descripcion: dto.descripcion, + tipo: dto.tipo, + duracionHoras: dto.duracionHoras || 1, + vigenciaMeses: dto.vigenciaMeses, + requiereEvaluacion: dto.requiereEvaluacion || false, + calificacionMinima: dto.calificacionMinima, + activo: true, + }); + + return this.repository.save(capacitacion); + } + + async update(ctx: ServiceContext, id: string, dto: UpdateCapacitacionDto): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return null; + } + + const updated = this.repository.merge(existing, dto); + return this.repository.save(updated); + } + + async toggleActive(ctx: ServiceContext, id: string): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return null; + } + + existing.activo = !existing.activo; + return this.repository.save(existing); + } + + async getByTipo(ctx: ServiceContext, tipo: TipoCapacitacion): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + tipo, + activo: true, + } as FindOptionsWhere, + order: { nombre: 'ASC' }, + }); + } +} diff --git a/src/modules/hse/services/epp.service.ts b/src/modules/hse/services/epp.service.ts new file mode 100644 index 0000000..d5567ef --- /dev/null +++ b/src/modules/hse/services/epp.service.ts @@ -0,0 +1,442 @@ +/** + * EppService - Servicio para control de EPP + * + * RF-MAA017-004: Gestión de equipo de protección personal + * + * @module HSE + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { EppCatalogo, CategoriaEpp } from '../entities/epp-catalogo.entity'; +import { EppAsignacion, EstadoEpp } from '../entities/epp-asignacion.entity'; +import { EppInspeccion, EstadoInspeccionEpp } from '../entities/epp-inspeccion.entity'; +import { EppBaja, MotivoBajaEpp } from '../entities/epp-baja.entity'; +import { EppMatrizPuesto } from '../entities/epp-matriz-puesto.entity'; +import { EppInventario } from '../entities/epp-inventario.entity'; +import { EppMovimiento, TipoMovimientoEpp } from '../entities/epp-movimiento.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateEppCatalogoDto { + codigo: string; + nombre: string; + descripcion?: string; + categoria: CategoriaEpp; + especificaciones?: string; + vidaUtilDias: number; + normaReferencia?: string; + requiereCertificacion?: boolean; + requiereInspeccionPeriodica?: boolean; + frecuenciaInspeccionDias?: number; + alertaDiasAntes?: number; + imagenUrl?: string; +} + +export interface CreateAsignacionDto { + eppId: string; + employeeId: string; + fraccionamientoId?: string; + fechaEntrega: Date; + fechaVencimiento: Date; + numeroSerie?: string; + numeroLote?: string; + capacitacionUso?: boolean; + costoUnitario?: number; +} + +export interface CreateInspeccionEppDto { + asignacionId: string; + inspectorId: string; + estadoEpp: EstadoInspeccionEpp; + observaciones?: string; + requiereReemplazo?: boolean; + fotoUrl?: string; +} + +export interface CreateBajaDto { + asignacionId: string; + motivo: MotivoBajaEpp; + descripcion?: string; + descuentoAplicado?: boolean; + montoDescuento?: number; + autorizadoPorId?: string; +} + +export interface EppFilters { + categoria?: CategoriaEpp; + activo?: boolean; + search?: string; +} + +export interface AsignacionFilters { + employeeId?: string; + eppId?: string; + estado?: EstadoEpp; + fraccionamientoId?: string; + vencidos?: boolean; +} + +export interface EppStats { + totalCatalogo: number; + totalAsignaciones: number; + asignacionesActivas: number; + proximasAVencer: number; + bajasPorMotivo: { motivo: string; count: number }[]; +} + +export class EppService { + constructor( + private readonly catalogoRepository: Repository, + private readonly asignacionRepository: Repository, + private readonly inspeccionRepository: Repository, + private readonly bajaRepository: Repository, + private readonly matrizRepository: Repository, + private readonly inventarioRepository: Repository, + private readonly movimientoRepository: Repository + ) {} + + // ========== Catálogo EPP ========== + async findCatalogo( + ctx: ServiceContext, + filters: EppFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.catalogoRepository + .createQueryBuilder('epp') + .where('epp.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.categoria) { + queryBuilder.andWhere('epp.categoria = :categoria', { categoria: filters.categoria }); + } + + if (filters.activo !== undefined) { + queryBuilder.andWhere('epp.activo = :activo', { activo: filters.activo }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(epp.codigo ILIKE :search OR epp.nombre ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + queryBuilder + .orderBy('epp.nombre', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + async findCatalogoById(ctx: ServiceContext, id: string): Promise { + return this.catalogoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + } + + async createCatalogo(ctx: ServiceContext, dto: CreateEppCatalogoDto): Promise { + const epp = this.catalogoRepository.create({ + tenantId: ctx.tenantId, + codigo: dto.codigo, + nombre: dto.nombre, + descripcion: dto.descripcion, + categoria: dto.categoria, + especificaciones: dto.especificaciones, + vidaUtilDias: dto.vidaUtilDias, + normaReferencia: dto.normaReferencia, + requiereCertificacion: dto.requiereCertificacion ?? false, + requiereInspeccionPeriodica: dto.requiereInspeccionPeriodica ?? false, + frecuenciaInspeccionDias: dto.frecuenciaInspeccionDias, + alertaDiasAntes: dto.alertaDiasAntes ?? 15, + imagenUrl: dto.imagenUrl, + activo: true, + }); + return this.catalogoRepository.save(epp); + } + + // ========== Matriz por Puesto ========== + async getMatrizByPuesto(ctx: ServiceContext, puestoId: string): Promise { + return this.matrizRepository.find({ + where: { tenantId: ctx.tenantId, puestoId } as FindOptionsWhere, + relations: ['epp'], + }); + } + + async setMatrizPuesto( + ctx: ServiceContext, + puestoId: string, + eppId: string, + esObligatorio: boolean, + actividadEspecifica?: string + ): Promise { + let matriz = await this.matrizRepository.findOne({ + where: { tenantId: ctx.tenantId, puestoId, eppId } as FindOptionsWhere, + }); + + if (matriz) { + matriz.esObligatorio = esObligatorio; + if (actividadEspecifica !== undefined) { + matriz.actividadEspecifica = actividadEspecifica; + } + } else { + matriz = this.matrizRepository.create({ + tenantId: ctx.tenantId, + puestoId, + eppId, + esObligatorio, + actividadEspecifica, + }); + } + + return this.matrizRepository.save(matriz); + } + + // ========== Asignaciones ========== + async findAsignaciones( + ctx: ServiceContext, + filters: AsignacionFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.asignacionRepository + .createQueryBuilder('asignacion') + .leftJoinAndSelect('asignacion.epp', 'epp') + .leftJoinAndSelect('asignacion.employee', 'employee') + .where('asignacion.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.employeeId) { + queryBuilder.andWhere('asignacion.employee_id = :employeeId', { + employeeId: filters.employeeId, + }); + } + + if (filters.eppId) { + queryBuilder.andWhere('asignacion.epp_id = :eppId', { + eppId: filters.eppId, + }); + } + + if (filters.estado) { + queryBuilder.andWhere('asignacion.estado = :estado', { estado: filters.estado }); + } + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('asignacion.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.vencidos) { + queryBuilder.andWhere('asignacion.fecha_vencimiento < :today', { today: new Date() }); + queryBuilder.andWhere('asignacion.estado = :activo', { activo: 'activo' }); + } + + queryBuilder + .orderBy('asignacion.fecha_entrega', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + async createAsignacion(ctx: ServiceContext, dto: CreateAsignacionDto): Promise { + const asignacion = this.asignacionRepository.create({ + tenantId: ctx.tenantId, + eppId: dto.eppId, + employeeId: dto.employeeId, + fraccionamientoId: dto.fraccionamientoId, + fechaEntrega: dto.fechaEntrega, + fechaVencimiento: dto.fechaVencimiento, + numeroSerie: dto.numeroSerie, + numeroLote: dto.numeroLote, + capacitacionUso: dto.capacitacionUso ?? false, + costoUnitario: dto.costoUnitario, + estado: 'activo', + createdById: ctx.userId, + }); + + return this.asignacionRepository.save(asignacion); + } + + async findAsignacionById(ctx: ServiceContext, id: string): Promise { + return this.asignacionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['epp', 'employee', 'fraccionamiento'], + }); + } + + async updateAsignacionEstado(ctx: ServiceContext, id: string, estado: EstadoEpp): Promise { + const asignacion = await this.asignacionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!asignacion) return null; + + asignacion.estado = estado; + return this.asignacionRepository.save(asignacion); + } + + // ========== Inspecciones ========== + async createInspeccion(ctx: ServiceContext, dto: CreateInspeccionEppDto): Promise { + const asignacion = await this.findAsignacionById(ctx, dto.asignacionId); + if (!asignacion) throw new Error('Asignación no encontrada'); + + const inspeccion = this.inspeccionRepository.create({ + asignacionId: dto.asignacionId, + inspectorId: dto.inspectorId, + fechaInspeccion: new Date(), + estadoEpp: dto.estadoEpp, + observaciones: dto.observaciones, + requiereReemplazo: dto.requiereReemplazo ?? false, + fotoUrl: dto.fotoUrl, + }); + + const saved = await this.inspeccionRepository.save(inspeccion); + + // If requires replacement, update assignment status + if (dto.requiereReemplazo || dto.estadoEpp === 'danado' || dto.estadoEpp === 'malo') { + asignacion.estado = 'danado'; + await this.asignacionRepository.save(asignacion); + } + + return saved; + } + + async getInspeccionesByAsignacion(_ctx: ServiceContext, asignacionId: string): Promise { + return this.inspeccionRepository.find({ + where: { asignacionId } as FindOptionsWhere, + relations: ['inspector'], + order: { fechaInspeccion: 'DESC' }, + }); + } + + // ========== Bajas ========== + async createBaja(ctx: ServiceContext, dto: CreateBajaDto): Promise { + const asignacion = await this.findAsignacionById(ctx, dto.asignacionId); + if (!asignacion) throw new Error('Asignación no encontrada'); + + if (asignacion.estado === 'devuelto') { + throw new Error('EPP ya fue dado de baja'); + } + + const baja = this.bajaRepository.create({ + asignacionId: dto.asignacionId, + fechaBaja: new Date(), + motivo: dto.motivo, + descripcion: dto.descripcion, + descuentoAplicado: dto.descuentoAplicado ?? false, + montoDescuento: dto.montoDescuento, + autorizadoPorId: dto.autorizadoPorId, + }); + + const saved = await this.bajaRepository.save(baja); + + // Update assignment status based on motivo + const estadoMap: Record = { + vencimiento: 'vencido', + danado: 'danado', + perdido: 'perdido', + terminacion_laboral: 'devuelto', + }; + asignacion.estado = estadoMap[dto.motivo]; + await this.asignacionRepository.save(asignacion); + + return saved; + } + + // ========== Inventario ========== + async getInventario(ctx: ServiceContext, almacenId?: string): Promise { + const where: FindOptionsWhere = { tenantId: ctx.tenantId }; + if (almacenId) { + where.almacenId = almacenId; + } + + return this.inventarioRepository.find({ + where, + relations: ['epp'], + }); + } + + async registrarMovimiento( + ctx: ServiceContext, + eppId: string, + tipo: TipoMovimientoEpp, + cantidad: number, + almacenOrigenId?: string, + almacenDestinoId?: string, + referencia?: string + ): Promise { + const movimiento = this.movimientoRepository.create({ + tenantId: ctx.tenantId, + eppId, + tipo, + cantidad, + almacenOrigenId, + almacenDestinoId, + referencia, + createdById: ctx.userId, + }); + + return this.movimientoRepository.save(movimiento); + } + + // ========== Estadísticas ========== + async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise { + const totalCatalogo = await this.catalogoRepository.count({ + where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere, + }); + + const asignacionWhere: FindOptionsWhere = { tenantId: ctx.tenantId }; + if (fraccionamientoId) { + asignacionWhere.fraccionamientoId = fraccionamientoId; + } + + const totalAsignaciones = await this.asignacionRepository.count({ where: asignacionWhere }); + + const asignacionesActivas = await this.asignacionRepository.count({ + where: { ...asignacionWhere, estado: 'activo' } as FindOptionsWhere, + }); + + // Próximas a vencer (30 días) + const fechaLimite = new Date(); + fechaLimite.setDate(fechaLimite.getDate() + 30); + const proximasAVencer = await this.asignacionRepository + .createQueryBuilder('a') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.estado = :estado', { estado: 'activo' }) + .andWhere('a.fecha_vencimiento <= :fechaLimite', { fechaLimite }) + .andWhere('a.fecha_vencimiento >= :today', { today: new Date() }) + .getCount(); + + // Bajas por motivo + const bajasPorMotivo = await this.bajaRepository + .createQueryBuilder('b') + .innerJoin('b.asignacion', 'a') + .select('b.motivo', 'motivo') + .addSelect('COUNT(*)', 'count') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .groupBy('b.motivo') + .getRawMany(); + + return { + totalCatalogo, + totalAsignaciones, + asignacionesActivas, + proximasAVencer, + bajasPorMotivo: bajasPorMotivo.map((b) => ({ motivo: b.motivo, count: parseInt(b.count, 10) })), + }; + } +} diff --git a/src/modules/hse/services/incidente.service.ts b/src/modules/hse/services/incidente.service.ts new file mode 100644 index 0000000..030a263 --- /dev/null +++ b/src/modules/hse/services/incidente.service.ts @@ -0,0 +1,396 @@ +/** + * IncidenteService - Servicio para gestión de incidentes HSE + * + * Gestión de incidentes de seguridad con workflow y relaciones. + * Workflow: abierto -> en_investigacion -> cerrado + * + * @module HSE + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { + Incidente, + TipoIncidente, + GravedadIncidente, + EstadoIncidente, +} from '../entities/incidente.entity'; +import { IncidenteInvolucrado, RolInvolucrado } from '../entities/incidente-involucrado.entity'; +import { IncidenteAccion, EstadoAccion } from '../entities/incidente-accion.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateIncidenteDto { + fechaHora: Date; + fraccionamientoId: string; + ubicacionDescripcion?: string; + tipo: TipoIncidente; + gravedad: GravedadIncidente; + descripcion: string; + causaInmediata?: string; + causaBasica?: string; +} + +export interface UpdateIncidenteDto { + ubicacionDescripcion?: string; + tipo?: TipoIncidente; + gravedad?: GravedadIncidente; + descripcion?: string; + causaInmediata?: string; + causaBasica?: string; +} + +export interface AddInvolucradoDto { + employeeId: string; + rol: RolInvolucrado; + descripcionLesion?: string; + parteCuerpo?: string; + diasIncapacidad?: number; +} + +export interface AddAccionDto { + descripcion: string; + tipo: string; + responsableId?: string; + fechaCompromiso: Date; +} + +export interface UpdateAccionDto { + descripcion?: string; + responsableId?: string; + fechaCompromiso?: Date; + estado?: EstadoAccion; + evidenciaUrl?: string; + observaciones?: string; +} + +export interface IncidenteFilters { + fraccionamientoId?: string; + tipo?: TipoIncidente; + gravedad?: GravedadIncidente; + estado?: EstadoIncidente; + dateFrom?: Date; + dateTo?: Date; +} + +export interface IncidenteStats { + total: number; + porTipo: { tipo: string; count: number }[]; + porGravedad: { gravedad: string; count: number }[]; + porEstado: { estado: string; count: number }[]; + diasSinAccidente: number; +} + +export class IncidenteService { + constructor( + private readonly incidenteRepository: Repository, + private readonly involucradoRepository: Repository, + private readonly accionRepository: Repository + ) {} + + private generateFolio(): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `INC-${year}${month}-${random}`; + } + + async findWithFilters( + ctx: ServiceContext, + filters: IncidenteFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.incidenteRepository + .createQueryBuilder('incidente') + .leftJoinAndSelect('incidente.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('incidente.createdBy', 'createdBy') + .where('incidente.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('incidente.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.tipo) { + queryBuilder.andWhere('incidente.tipo = :tipo', { tipo: filters.tipo }); + } + + if (filters.gravedad) { + queryBuilder.andWhere('incidente.gravedad = :gravedad', { gravedad: filters.gravedad }); + } + + if (filters.estado) { + queryBuilder.andWhere('incidente.estado = :estado', { estado: filters.estado }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('incidente.fecha_hora >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('incidente.fecha_hora <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('incidente.fecha_hora', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.incidenteRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + } + + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.incidenteRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + relations: [ + 'fraccionamiento', + 'createdBy', + 'involucrados', + 'involucrados.employee', + 'acciones', + 'acciones.responsable', + ], + }); + } + + async create(ctx: ServiceContext, dto: CreateIncidenteDto): Promise { + const incidente = this.incidenteRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + folio: this.generateFolio(), + fechaHora: dto.fechaHora, + fraccionamientoId: dto.fraccionamientoId, + ubicacionDescripcion: dto.ubicacionDescripcion, + tipo: dto.tipo, + gravedad: dto.gravedad, + descripcion: dto.descripcion, + causaInmediata: dto.causaInmediata, + causaBasica: dto.causaBasica, + estado: 'abierto', + }); + + return this.incidenteRepository.save(incidente); + } + + async update(ctx: ServiceContext, id: string, dto: UpdateIncidenteDto): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return null; + } + + if (existing.estado === 'cerrado') { + throw new Error('Cannot update a closed incident'); + } + + const updated = this.incidenteRepository.merge(existing, dto); + return this.incidenteRepository.save(updated); + } + + async startInvestigation(ctx: ServiceContext, id: string): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return null; + } + + if (existing.estado !== 'abierto') { + throw new Error('Can only start investigation on open incidents'); + } + + existing.estado = 'en_investigacion'; + return this.incidenteRepository.save(existing); + } + + async closeIncident(ctx: ServiceContext, id: string): Promise { + const existing = await this.findWithDetails(ctx, id); + if (!existing) { + return null; + } + + if (existing.estado === 'cerrado') { + throw new Error('Incident is already closed'); + } + + // Check if all actions are completed or verified + const pendingActions = existing.acciones?.filter( + (a) => a.estado !== 'completada' && a.estado !== 'verificada' + ); + + if (pendingActions && pendingActions.length > 0) { + throw new Error('Cannot close incident with pending actions'); + } + + existing.estado = 'cerrado'; + return this.incidenteRepository.save(existing); + } + + async addInvolucrado( + ctx: ServiceContext, + incidenteId: string, + dto: AddInvolucradoDto + ): Promise { + const incidente = await this.findById(ctx, incidenteId); + if (!incidente) { + throw new Error('Incidente not found'); + } + + const involucrado = this.involucradoRepository.create({ + incidenteId, + employeeId: dto.employeeId, + rol: dto.rol, + descripcionLesion: dto.descripcionLesion, + parteCuerpo: dto.parteCuerpo, + diasIncapacidad: dto.diasIncapacidad || 0, + }); + + return this.involucradoRepository.save(involucrado); + } + + async removeInvolucrado(ctx: ServiceContext, incidenteId: string, involucradoId: string): Promise { + const incidente = await this.findById(ctx, incidenteId); + if (!incidente) { + return false; + } + + const result = await this.involucradoRepository.delete({ + id: involucradoId, + incidenteId, + }); + + return (result.affected ?? 0) > 0; + } + + async addAccion(ctx: ServiceContext, incidenteId: string, dto: AddAccionDto): Promise { + const incidente = await this.findById(ctx, incidenteId); + if (!incidente) { + throw new Error('Incidente not found'); + } + + if (incidente.estado === 'cerrado') { + throw new Error('Cannot add actions to a closed incident'); + } + + const accion = this.accionRepository.create({ + incidenteId, + descripcion: dto.descripcion, + tipo: dto.tipo, + responsableId: dto.responsableId, + fechaCompromiso: dto.fechaCompromiso, + estado: 'pendiente', + }); + + return this.accionRepository.save(accion); + } + + async updateAccion( + ctx: ServiceContext, + incidenteId: string, + accionId: string, + dto: UpdateAccionDto + ): Promise { + const incidente = await this.findById(ctx, incidenteId); + if (!incidente) { + return null; + } + + const accion = await this.accionRepository.findOne({ + where: { id: accionId, incidenteId }, + }); + + if (!accion) { + return null; + } + + // If completing the action, set the close date + if (dto.estado === 'completada' && accion.estado !== 'completada') { + dto.fechaCompromiso = dto.fechaCompromiso; // keep existing + accion.fechaCierre = new Date(); + } + + const updated = this.accionRepository.merge(accion, dto); + return this.accionRepository.save(updated); + } + + async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise { + const queryBuilder = this.incidenteRepository + .createQueryBuilder('incidente') + .where('incidente.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (fraccionamientoId) { + queryBuilder.andWhere('incidente.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + const total = await queryBuilder.getCount(); + + const porTipo = await this.incidenteRepository + .createQueryBuilder('incidente') + .select('incidente.tipo', 'tipo') + .addSelect('COUNT(*)', 'count') + .where('incidente.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .groupBy('incidente.tipo') + .getRawMany(); + + const porGravedad = await this.incidenteRepository + .createQueryBuilder('incidente') + .select('incidente.gravedad', 'gravedad') + .addSelect('COUNT(*)', 'count') + .where('incidente.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .groupBy('incidente.gravedad') + .getRawMany(); + + const porEstado = await this.incidenteRepository + .createQueryBuilder('incidente') + .select('incidente.estado', 'estado') + .addSelect('COUNT(*)', 'count') + .where('incidente.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .groupBy('incidente.estado') + .getRawMany(); + + // Calculate days since last accident + const lastAccident = await this.incidenteRepository.findOne({ + where: { + tenantId: ctx.tenantId, + tipo: 'accidente', + } as FindOptionsWhere, + order: { fechaHora: 'DESC' }, + }); + + let diasSinAccidente = 0; + if (lastAccident) { + const diff = Date.now() - new Date(lastAccident.fechaHora).getTime(); + diasSinAccidente = Math.floor(diff / (1000 * 60 * 60 * 24)); + } + + return { + total, + porTipo: porTipo.map((p) => ({ tipo: p.tipo, count: parseInt(p.count, 10) })), + porGravedad: porGravedad.map((p) => ({ gravedad: p.gravedad, count: parseInt(p.count, 10) })), + porEstado: porEstado.map((p) => ({ estado: p.estado, count: parseInt(p.count, 10) })), + diasSinAccidente, + }; + } +} diff --git a/src/modules/hse/services/index.ts b/src/modules/hse/services/index.ts new file mode 100644 index 0000000..ef62c53 --- /dev/null +++ b/src/modules/hse/services/index.ts @@ -0,0 +1,32 @@ +/** + * HSE Services Index + * @module HSE + * + * Services for Health, Safety & Environment module + * Based on RF-MAA017-001 to RF-MAA017-008 + * Updated: 2025-12-18 + */ + +// RF-MAA017-001: Gestión de Incidentes +export * from './incidente.service'; + +// RF-MAA017-002: Control de Capacitaciones +export * from './capacitacion.service'; + +// RF-MAA017-003: Inspecciones de Seguridad +export * from './inspeccion.service'; + +// RF-MAA017-004: Control de EPP +export * from './epp.service'; + +// RF-MAA017-005: Cumplimiento STPS +export * from './stps.service'; + +// RF-MAA017-006: Gestión Ambiental +export * from './ambiental.service'; + +// RF-MAA017-007: Permisos de Trabajo +export * from './permiso-trabajo.service'; + +// RF-MAA017-008: Indicadores HSE +export * from './indicador.service'; diff --git a/src/modules/hse/services/indicador.service.ts b/src/modules/hse/services/indicador.service.ts new file mode 100644 index 0000000..bc2c97d --- /dev/null +++ b/src/modules/hse/services/indicador.service.ts @@ -0,0 +1,282 @@ +/** + * IndicadorService - Servicio para indicadores HSE + * + * RF-MAA017-008: Gestión de indicadores y reportes HSE + * + * @module HSE + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { IndicadorConfig, TipoIndicador, FrecuenciaCalculo } from '../entities/indicador-config.entity'; +import { IndicadorMetaObra } from '../entities/indicador-meta-obra.entity'; +import { IndicadorValor } from '../entities/indicador-valor.entity'; +import { HorasTrabajadas } from '../entities/horas-trabajadas.entity'; +import { DiasSinAccidente } from '../entities/dias-sin-accidente.entity'; +import { ReporteProgramado } from '../entities/reporte-programado.entity'; +import { AlertaIndicador } from '../entities/alerta-indicador.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface IndicadorFilters { + tipo?: TipoIndicador; + activo?: boolean; + search?: string; +} + +export interface CreateIndicadorDto { + codigo: string; + nombre: string; + descripcion?: string; + tipo: TipoIndicador; + formula?: string; + unidad?: string; + metaGlobal?: number; + umbralVerde?: number; + umbralAmarillo?: number; + umbralRojo?: number; + frecuenciaCalculo?: FrecuenciaCalculo; +} + +export interface IndicadorStats { + totalIndicadores: number; + indicadoresActivos: number; + valoresRegistrados: number; + alertasActivas: number; +} + +export class IndicadorService { + constructor( + private readonly indicadorRepository: Repository, + private readonly metaRepository: Repository, + private readonly valorRepository: Repository, + private readonly horasRepository: Repository, + private readonly diasRepository: Repository, + private readonly reporteRepository: Repository, + private readonly alertaRepository: Repository + ) {} + + // ========== Configuración de Indicadores ========== + async findIndicadores( + ctx: ServiceContext, + filters: IndicadorFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.indicadorRepository + .createQueryBuilder('i') + .where('i.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.tipo) { + queryBuilder.andWhere('i.tipo = :tipo', { tipo: filters.tipo }); + } + + if (filters.activo !== undefined) { + queryBuilder.andWhere('i.activo = :activo', { activo: filters.activo }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(i.codigo ILIKE :search OR i.nombre ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + queryBuilder + .orderBy('i.nombre', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + async findIndicadorById(ctx: ServiceContext, id: string): Promise { + return this.indicadorRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + } + + async createIndicador(ctx: ServiceContext, dto: CreateIndicadorDto): Promise { + const indicador = this.indicadorRepository.create({ + tenantId: ctx.tenantId, + codigo: dto.codigo, + nombre: dto.nombre, + descripcion: dto.descripcion, + tipo: dto.tipo, + formula: dto.formula, + unidad: dto.unidad, + metaGlobal: dto.metaGlobal, + umbralVerde: dto.umbralVerde, + umbralAmarillo: dto.umbralAmarillo, + umbralRojo: dto.umbralRojo, + frecuenciaCalculo: dto.frecuenciaCalculo || 'mensual', + activo: true, + }); + return this.indicadorRepository.save(indicador); + } + + async updateIndicador(ctx: ServiceContext, id: string, dto: Partial): Promise { + const indicador = await this.findIndicadorById(ctx, id); + if (!indicador) return null; + + Object.assign(indicador, dto); + return this.indicadorRepository.save(indicador); + } + + async toggleIndicadorActivo(ctx: ServiceContext, id: string): Promise { + const indicador = await this.findIndicadorById(ctx, id); + if (!indicador) return null; + + indicador.activo = !indicador.activo; + return this.indicadorRepository.save(indicador); + } + + // ========== Metas por Obra ========== + async findMetas(ctx: ServiceContext, fraccionamientoId?: string, indicadorId?: string): Promise { + const queryBuilder = this.metaRepository + .createQueryBuilder('m') + .leftJoinAndSelect('m.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('m.indicador', 'indicador') + .innerJoin('m.indicador', 'ind') + .where('ind.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (fraccionamientoId) { + queryBuilder.andWhere('m.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + if (indicadorId) { + queryBuilder.andWhere('m.indicador_id = :indicadorId', { indicadorId }); + } + + return queryBuilder.getMany(); + } + + // ========== Valores de Indicadores ========== + async findValores( + ctx: ServiceContext, + indicadorId: string, + fraccionamientoId?: string, + page: number = 1, + limit: number = 50 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.valorRepository + .createQueryBuilder('v') + .leftJoinAndSelect('v.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('v.indicador', 'indicador') + .innerJoin('v.indicador', 'ind') + .where('ind.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('v.indicador_id = :indicadorId', { indicadorId }); + + if (fraccionamientoId) { + queryBuilder.andWhere('v.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + queryBuilder + .orderBy('v.created_at', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + // ========== Horas Trabajadas ========== + async findHorasTrabajadas(ctx: ServiceContext, fraccionamientoId: string, year?: number): Promise { + const queryBuilder = this.horasRepository + .createQueryBuilder('h') + .where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('h.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + + if (year) { + queryBuilder.andWhere('EXTRACT(YEAR FROM h.fecha) = :year', { year }); + } + + return queryBuilder.orderBy('h.fecha', 'DESC').getMany(); + } + + // ========== Días Sin Accidente ========== + async findDiasSinAccidente(ctx: ServiceContext, fraccionamientoId: string): Promise { + return this.diasRepository.findOne({ + where: { tenantId: ctx.tenantId, fraccionamientoId } as FindOptionsWhere, + }); + } + + // ========== Reportes Programados ========== + async findReportes(ctx: ServiceContext): Promise { + return this.reporteRepository.find({ + where: { tenantId: ctx.tenantId } as FindOptionsWhere, + order: { createdAt: 'DESC' }, + }); + } + + // ========== Alertas ========== + async findAlertas( + ctx: ServiceContext, + fraccionamientoId?: string, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.alertaRepository + .createQueryBuilder('a') + .leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('a.indicador', 'indicador') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (fraccionamientoId) { + queryBuilder.andWhere('a.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + queryBuilder + .orderBy('a.created_at', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + // ========== Estadísticas ========== + async getStats(ctx: ServiceContext): Promise { + const totalIndicadores = await this.indicadorRepository.count({ + where: { tenantId: ctx.tenantId } as FindOptionsWhere, + }); + + const indicadoresActivos = await this.indicadorRepository.count({ + where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere, + }); + + const valoresRegistrados = await this.valorRepository + .createQueryBuilder('v') + .innerJoin('v.indicador', 'i') + .where('i.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .getCount(); + + const alertasActivas = await this.alertaRepository.count({ + where: { tenantId: ctx.tenantId } as FindOptionsWhere, + }); + + return { + totalIndicadores, + indicadoresActivos, + valoresRegistrados, + alertasActivas, + }; + } +} diff --git a/src/modules/hse/services/inspeccion.service.ts b/src/modules/hse/services/inspeccion.service.ts new file mode 100644 index 0000000..9f3d190 --- /dev/null +++ b/src/modules/hse/services/inspeccion.service.ts @@ -0,0 +1,354 @@ +/** + * InspeccionService - Servicio para inspecciones de seguridad + * + * RF-MAA017-003: Gestión de inspecciones, hallazgos y seguimiento + * + * @module HSE + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Inspeccion } from '../entities/inspeccion.entity'; +import { InspeccionEvaluacion } from '../entities/inspeccion-evaluacion.entity'; +import { Hallazgo, GravedadHallazgo, EstadoHallazgo } from '../entities/hallazgo.entity'; +import { HallazgoEvidencia } from '../entities/hallazgo-evidencia.entity'; +import { TipoInspeccion } from '../entities/tipo-inspeccion.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface InspeccionFilters { + fraccionamientoId?: string; + tipoInspeccionId?: string; + estado?: string; + inspectorId?: string; + dateFrom?: Date; + dateTo?: Date; +} + +export interface HallazgoFilters { + fraccionamientoId?: string; + gravedad?: GravedadHallazgo; + estado?: EstadoHallazgo; + responsableId?: string; + vencidos?: boolean; +} + +export interface CreateInspeccionDto { + tipoInspeccionId: string; + fraccionamientoId: string; + inspectorId: string; + programaId?: string; +} + +export interface CreateHallazgoDto { + evaluacionId?: string; + gravedad: GravedadHallazgo; + tipo: 'acto_inseguro' | 'condicion_insegura'; + descripcion: string; + ubicacionDescripcion?: string; + responsableCorreccionId?: string; + fechaLimite: Date; +} + +export interface InspeccionStats { + totalInspecciones: number; + hallazgosAbiertos: number; + hallazgosVencidos: number; +} + +export class InspeccionService { + constructor( + private readonly inspeccionRepository: Repository, + private readonly evaluacionRepository: Repository, + private readonly hallazgoRepository: Repository, + private readonly evidenciaRepository: Repository, + private readonly tipoInspeccionRepository: Repository + ) {} + + // Getters para repositorios (para uso futuro) + getEvaluacionRepository() { + return this.evaluacionRepository; + } + + getEvidenciaRepository() { + return this.evidenciaRepository; + } + + private generateFolio(prefix: string): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `${prefix}-${year}${month}-${random}`; + } + + // ========== Tipos de Inspección ========== + async findTiposInspeccion(ctx: ServiceContext): Promise { + return this.tipoInspeccionRepository.find({ + where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere, + order: { nombre: 'ASC' }, + }); + } + + // ========== Inspecciones ========== + async findInspecciones( + ctx: ServiceContext, + filters: InspeccionFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.inspeccionRepository + .createQueryBuilder('inspeccion') + .leftJoinAndSelect('inspeccion.tipoInspeccion', 'tipoInspeccion') + .leftJoinAndSelect('inspeccion.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('inspeccion.inspector', 'inspector') + .where('inspeccion.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('inspeccion.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.tipoInspeccionId) { + queryBuilder.andWhere('inspeccion.tipo_inspeccion_id = :tipoInspeccionId', { + tipoInspeccionId: filters.tipoInspeccionId, + }); + } + + if (filters.estado) { + queryBuilder.andWhere('inspeccion.estado = :estado', { estado: filters.estado }); + } + + if (filters.inspectorId) { + queryBuilder.andWhere('inspeccion.inspector_id = :inspectorId', { + inspectorId: filters.inspectorId, + }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('inspeccion.fecha_inicio >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('inspeccion.fecha_inicio <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('inspeccion.fecha_inicio', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findInspeccionById(ctx: ServiceContext, id: string): Promise { + return this.inspeccionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['tipoInspeccion', 'fraccionamiento', 'inspector'], + }); + } + + async findInspeccionWithDetails(ctx: ServiceContext, id: string): Promise { + return this.inspeccionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: [ + 'tipoInspeccion', + 'fraccionamiento', + 'inspector', + 'evaluaciones', + 'evaluaciones.checklistItem', + 'hallazgos', + 'hallazgos.responsableCorreccion', + ], + }); + } + + async createInspeccion(ctx: ServiceContext, dto: CreateInspeccionDto): Promise { + const inspeccion = this.inspeccionRepository.create({ + tenantId: ctx.tenantId, + tipoInspeccionId: dto.tipoInspeccionId, + fraccionamientoId: dto.fraccionamientoId, + inspectorId: dto.inspectorId, + programaId: dto.programaId, + fechaInicio: new Date(), + estado: 'borrador', + }); + + return this.inspeccionRepository.save(inspeccion); + } + + async updateInspeccionEstado(ctx: ServiceContext, id: string, estado: string): Promise { + const inspeccion = await this.findInspeccionById(ctx, id); + if (!inspeccion) return null; + + inspeccion.estado = estado; + if (estado === 'completado' || estado === 'finalizado') { + inspeccion.fechaFin = new Date(); + } + + return this.inspeccionRepository.save(inspeccion); + } + + async updateInspeccionObservaciones(ctx: ServiceContext, id: string, observaciones: string): Promise { + const inspeccion = await this.findInspeccionById(ctx, id); + if (!inspeccion) return null; + + inspeccion.observacionesGenerales = observaciones; + return this.inspeccionRepository.save(inspeccion); + } + + // ========== Hallazgos ========== + async findHallazgos( + ctx: ServiceContext, + filters: HallazgoFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.hallazgoRepository + .createQueryBuilder('hallazgo') + .leftJoinAndSelect('hallazgo.inspeccion', 'inspeccion') + .leftJoinAndSelect('inspeccion.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('hallazgo.responsableCorreccion', 'responsable') + .where('hallazgo.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('inspeccion.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.gravedad) { + queryBuilder.andWhere('hallazgo.gravedad = :gravedad', { gravedad: filters.gravedad }); + } + + if (filters.estado) { + queryBuilder.andWhere('hallazgo.estado = :estado', { estado: filters.estado }); + } + + if (filters.responsableId) { + queryBuilder.andWhere('hallazgo.responsable_correccion_id = :responsableId', { + responsableId: filters.responsableId, + }); + } + + if (filters.vencidos) { + queryBuilder.andWhere('hallazgo.fecha_limite < :today', { today: new Date() }); + queryBuilder.andWhere('hallazgo.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] }); + } + + queryBuilder + .orderBy('hallazgo.fecha_limite', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async createHallazgo(ctx: ServiceContext, inspeccionId: string, dto: CreateHallazgoDto): Promise { + const inspeccion = await this.findInspeccionById(ctx, inspeccionId); + if (!inspeccion) throw new Error('Inspección no encontrada'); + + const hallazgo = this.hallazgoRepository.create({ + tenantId: ctx.tenantId, + inspeccionId, + folio: this.generateFolio('HAL'), + evaluacionId: dto.evaluacionId, + gravedad: dto.gravedad, + tipo: dto.tipo, + descripcion: dto.descripcion, + ubicacionDescripcion: dto.ubicacionDescripcion, + responsableCorreccionId: dto.responsableCorreccionId, + fechaLimite: dto.fechaLimite, + estado: 'abierto', + }); + + return this.hallazgoRepository.save(hallazgo); + } + + async registrarCorreccion( + ctx: ServiceContext, + hallazgoId: string, + descripcionCorreccion: string + ): Promise { + const hallazgo = await this.hallazgoRepository.findOne({ + where: { id: hallazgoId, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!hallazgo) return null; + + hallazgo.estado = 'verificando'; + hallazgo.descripcionCorreccion = descripcionCorreccion; + hallazgo.fechaCorreccion = new Date(); + + return this.hallazgoRepository.save(hallazgo); + } + + async verificarHallazgo( + ctx: ServiceContext, + hallazgoId: string, + verificadorId: string, + aprobado: boolean + ): Promise { + const hallazgo = await this.hallazgoRepository.findOne({ + where: { id: hallazgoId, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!hallazgo) return null; + + hallazgo.verificadorId = verificadorId; + hallazgo.fechaVerificacion = new Date(); + hallazgo.estado = aprobado ? 'cerrado' : 'reabierto'; + + return this.hallazgoRepository.save(hallazgo); + } + + // ========== Estadísticas ========== + async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise { + const inspQuery = this.inspeccionRepository.createQueryBuilder('i') + .where('i.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + if (fraccionamientoId) { + inspQuery.andWhere('i.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + const totalInspecciones = await inspQuery.getCount(); + + const hallazgosAbiertos = await this.hallazgoRepository + .createQueryBuilder('h') + .where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('h.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] }) + .getCount(); + + const hallazgosVencidos = await this.hallazgoRepository + .createQueryBuilder('h') + .where('h.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('h.fecha_limite < :today', { today: new Date() }) + .andWhere('h.estado NOT IN (:...cerrados)', { cerrados: ['cerrado'] }) + .getCount(); + + return { + totalInspecciones, + hallazgosAbiertos, + hallazgosVencidos, + }; + } +} diff --git a/src/modules/hse/services/permiso-trabajo.service.ts b/src/modules/hse/services/permiso-trabajo.service.ts new file mode 100644 index 0000000..05a538e --- /dev/null +++ b/src/modules/hse/services/permiso-trabajo.service.ts @@ -0,0 +1,298 @@ +/** + * PermisoTrabajoService - Servicio para permisos de trabajo + * + * RF-MAA017-007: Gestión de permisos de trabajo de alto riesgo + * + * @module HSE + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { TipoPermisoTrabajo } from '../entities/tipo-permiso-trabajo.entity'; +import { PermisoTrabajo, EstadoPermiso } from '../entities/permiso-trabajo.entity'; +import { PermisoPersonal } from '../entities/permiso-personal.entity'; +import { PermisoAutorizacion } from '../entities/permiso-autorizacion.entity'; +import { PermisoChecklist } from '../entities/permiso-checklist.entity'; +import { PermisoMonitoreo } from '../entities/permiso-monitoreo.entity'; +import { PermisoEvento } from '../entities/permiso-evento.entity'; +import { PermisoDocumento } from '../entities/permiso-documento.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface PermisoFilters { + fraccionamientoId?: string; + tipoPermisoId?: string; + estado?: EstadoPermiso; + solicitanteId?: string; + vigentes?: boolean; + dateFrom?: Date; + dateTo?: Date; +} + +export interface CreatePermisoDto { + fraccionamientoId: string; + tipoPermisoId: string; + descripcionTrabajo: string; + ubicacion: string; + fechaInicioProgramada: Date; + fechaFinProgramada: Date; + observaciones?: string; +} + +export interface PermisoStats { + totalPermisos: number; + permisosVigentes: number; + permisosPorEstado: { estado: string; count: number }[]; +} + +export class PermisoTrabajoService { + constructor( + private readonly tipoPermisoRepository: Repository, + private readonly permisoRepository: Repository, + private readonly personalRepository: Repository, + private readonly autorizacionRepository: Repository, + private readonly checklistRepository: Repository, + private readonly monitoreoRepository: Repository, + private readonly eventoRepository: Repository, + private readonly documentoRepository: Repository + ) {} + + private generateFolio(): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `PT-${year}${month}-${random}`; + } + + // ========== Tipos de Permiso ========== + async findTiposPermiso(ctx: ServiceContext): Promise { + return this.tipoPermisoRepository.find({ + where: { tenantId: ctx.tenantId, activo: true } as FindOptionsWhere, + order: { nombre: 'ASC' }, + }); + } + + // ========== Permisos de Trabajo ========== + async findPermisos( + ctx: ServiceContext, + filters: PermisoFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.permisoRepository + .createQueryBuilder('p') + .leftJoinAndSelect('p.tipoPermiso', 'tipo') + .leftJoinAndSelect('p.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('p.solicitante', 'solicitante') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('p.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.tipoPermisoId) { + queryBuilder.andWhere('p.tipo_permiso_id = :tipoPermisoId', { + tipoPermisoId: filters.tipoPermisoId, + }); + } + + if (filters.estado) { + queryBuilder.andWhere('p.estado = :estado', { estado: filters.estado }); + } + + if (filters.solicitanteId) { + queryBuilder.andWhere('p.solicitante_id = :solicitanteId', { + solicitanteId: filters.solicitanteId, + }); + } + + if (filters.vigentes) { + const now = new Date(); + queryBuilder.andWhere('p.fecha_inicio_programada <= :now', { now }); + queryBuilder.andWhere('p.fecha_fin_programada >= :now', { now }); + queryBuilder.andWhere('p.estado IN (:...activos)', { activos: ['autorizado', 'en_ejecucion'] }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('p.fecha_inicio_programada >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('p.fecha_fin_programada <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('p.fecha_inicio_programada', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + async findPermisoById(ctx: ServiceContext, id: string): Promise { + return this.permisoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['tipoPermiso', 'fraccionamiento', 'solicitante'], + }); + } + + async findPermisoWithDetails(ctx: ServiceContext, id: string): Promise { + return this.permisoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: [ + 'tipoPermiso', + 'fraccionamiento', + 'solicitante', + 'personal', + 'personal.employee', + 'autorizaciones', + 'autorizaciones.autorizador', + 'checklist', + 'monitoreos', + 'eventos', + 'documentos', + ], + }); + } + + async createPermiso(ctx: ServiceContext, dto: CreatePermisoDto): Promise { + const permiso = this.permisoRepository.create({ + tenantId: ctx.tenantId, + folio: this.generateFolio(), + tipoPermisoId: dto.tipoPermisoId, + fraccionamientoId: dto.fraccionamientoId, + solicitanteId: ctx.userId, + descripcionTrabajo: dto.descripcionTrabajo, + ubicacion: dto.ubicacion, + fechaInicioProgramada: dto.fechaInicioProgramada, + fechaFinProgramada: dto.fechaFinProgramada, + observaciones: dto.observaciones, + estado: 'borrador', + }); + + return this.permisoRepository.save(permiso); + } + + async updatePermisoEstado(ctx: ServiceContext, id: string, estado: EstadoPermiso, motivo?: string): Promise { + const permiso = await this.findPermisoById(ctx, id); + if (!permiso) return null; + + permiso.estado = estado; + + if (estado === 'rechazado' && motivo) { + permiso.motivoRechazo = motivo; + } + if (estado === 'suspendido' && motivo) { + permiso.motivoSuspension = motivo; + } + if (estado === 'en_ejecucion') { + permiso.fechaInicioReal = new Date(); + } + if (estado === 'cerrado') { + permiso.fechaFinReal = new Date(); + } + + return this.permisoRepository.save(permiso); + } + + // ========== Personal Autorizado ========== + async getPersonalByPermiso(ctx: ServiceContext, permisoId: string): Promise { + const permiso = await this.findPermisoById(ctx, permisoId); + if (!permiso) return []; + + return this.personalRepository.find({ + where: { permisoId } as FindOptionsWhere, + relations: ['employee'], + }); + } + + // ========== Autorizaciones ========== + async getAutorizacionesByPermiso(ctx: ServiceContext, permisoId: string): Promise { + const permiso = await this.findPermisoById(ctx, permisoId); + if (!permiso) return []; + + return this.autorizacionRepository.find({ + where: { permisoId } as FindOptionsWhere, + relations: ['autorizador'], + order: { createdAt: 'ASC' }, + }); + } + + // ========== Checklist ========== + async getChecklistByPermiso(_ctx: ServiceContext, permisoId: string): Promise { + return this.checklistRepository.find({ + where: { permisoId } as FindOptionsWhere, + order: { createdAt: 'ASC' }, + }); + } + + // ========== Monitoreo ========== + async getMonitoreosByPermiso(_ctx: ServiceContext, permisoId: string): Promise { + return this.monitoreoRepository.find({ + where: { permisoId } as FindOptionsWhere, + order: { createdAt: 'DESC' }, + }); + } + + // ========== Eventos ========== + async getEventosByPermiso(_ctx: ServiceContext, permisoId: string): Promise { + return this.eventoRepository.find({ + where: { permisoId } as FindOptionsWhere, + order: { createdAt: 'DESC' }, + }); + } + + // ========== Documentos ========== + async getDocumentosByPermiso(_ctx: ServiceContext, permisoId: string): Promise { + return this.documentoRepository.find({ + where: { permisoId } as FindOptionsWhere, + order: { fechaSubida: 'DESC' }, + }); + } + + // ========== Estadísticas ========== + async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise { + const baseQuery = this.permisoRepository + .createQueryBuilder('p') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (fraccionamientoId) { + baseQuery.andWhere('p.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + const totalPermisos = await baseQuery.getCount(); + + // Permisos vigentes + const now = new Date(); + const permisosVigentes = await this.permisoRepository + .createQueryBuilder('p') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('p.fecha_inicio_programada <= :now', { now }) + .andWhere('p.fecha_fin_programada >= :now', { now }) + .andWhere('p.estado IN (:...activos)', { activos: ['autorizado', 'en_ejecucion'] }) + .getCount(); + + // Por estado + const permisosPorEstado = await this.permisoRepository + .createQueryBuilder('p') + .select('p.estado', 'estado') + .addSelect('COUNT(*)', 'count') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .groupBy('p.estado') + .getRawMany(); + + return { + totalPermisos, + permisosVigentes, + permisosPorEstado: permisosPorEstado.map((p) => ({ estado: p.estado, count: parseInt(p.count, 10) })), + }; + } +} diff --git a/src/modules/hse/services/stps.service.ts b/src/modules/hse/services/stps.service.ts new file mode 100644 index 0000000..06cec5c --- /dev/null +++ b/src/modules/hse/services/stps.service.ts @@ -0,0 +1,675 @@ +/** + * StpsService - Servicio para cumplimiento STPS + * + * RF-MAA017-005: Gestión de normas, comisiones y auditorías + * + * @module HSE + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { NormaStps } from '../entities/norma-stps.entity'; +import { NormaRequisito } from '../entities/norma-requisito.entity'; +import { CumplimientoObra, EstadoCumplimiento } from '../entities/cumplimiento-obra.entity'; +import { ComisionSeguridad, EstadoComision } from '../entities/comision-seguridad.entity'; +import { ComisionIntegrante, RolComision, Representacion } from '../entities/comision-integrante.entity'; +import { ComisionRecorrido } from '../entities/comision-recorrido.entity'; +import { ProgramaSeguridad } from '../entities/programa-seguridad.entity'; +import { ProgramaActividad, TipoActividadPrograma, EstadoActividad } from '../entities/programa-actividad.entity'; +import { DocumentoStps, TipoDocumentoStps } from '../entities/documento-stps.entity'; +import { Auditoria, TipoAuditoria, ResultadoAuditoria } from '../entities/auditoria.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CumplimientoFilters { + fraccionamientoId?: string; + normaId?: string; + estado?: EstadoCumplimiento; +} + +export interface ComisionFilters { + fraccionamientoId?: string; + estado?: EstadoComision; +} + +export interface AuditoriaFilters { + fraccionamientoId?: string; + tipo?: TipoAuditoria; + resultado?: ResultadoAuditoria; + dateFrom?: Date; + dateTo?: Date; +} + +export interface CreateCumplimientoDto { + fraccionamientoId: string; + normaId: string; + requisitoId?: string; + evaluadorId?: string; + fechaEvaluacion: Date; + estado?: EstadoCumplimiento; + evidenciaUrl?: string; + observaciones?: string; + fechaCompromiso?: Date; +} + +export interface CreateComisionDto { + fraccionamientoId: string; + fechaConstitucion: Date; + numeroActa?: string; + vigenciaInicio: Date; + vigenciaFin: Date; + documentoActaUrl?: string; +} + +export interface AddIntegranteDto { + employeeId: string; + rol: RolComision; + representacion: Representacion; + fechaNombramiento: Date; +} + +export interface CreateRecorridoDto { + comisionId: string; + fechaProgramada: Date; + areasRecorridas?: string; +} + +export interface CreateProgramaDto { + fraccionamientoId: string; + anio: number; + objetivoGeneral?: string; + metas?: Record; + presupuesto?: number; +} + +export interface CreateActividadDto { + programaId: string; + actividad: string; + tipo: TipoActividadPrograma; + fechaProgramada: Date; + responsableId?: string; + recursos?: string; +} + +export interface CreateAuditoriaDto { + fraccionamientoId: string; + tipo: TipoAuditoria; + fechaProgramada: Date; + auditor?: string; +} + +export interface CreateDocumentoDto { + tipo: TipoDocumentoStps; + folio: string; + fraccionamientoId?: string; + employeeId?: string; + fechaEmision: Date; + fechaVencimiento?: Date; + datosDocumento?: Record; + documentoUrl?: string; +} + +export interface StpsStats { + totalNormas: number; + cumplimientoPorEstado: { estado: string; count: number }[]; + porcentajeCumplimiento: number; + comisionesActivas: number; + recorridosPendientes: number; + actividadesPendientes: number; + auditoriasProgramadas: number; +} + +export class StpsService { + constructor( + private readonly normaRepository: Repository, + private readonly requisitoRepository: Repository, + private readonly cumplimientoRepository: Repository, + private readonly comisionRepository: Repository, + private readonly integranteRepository: Repository, + private readonly recorridoRepository: Repository, + private readonly programaRepository: Repository, + private readonly actividadRepository: Repository, + private readonly documentoRepository: Repository, + private readonly auditoriaRepository: Repository + ) {} + + // ========== Normas y Requisitos ========== + async findNormas(): Promise { + // NormaStps is a shared catalog (no tenant_id) + return this.normaRepository.find({ + where: { activo: true } as FindOptionsWhere, + order: { codigo: 'ASC' }, + }); + } + + async findNormaById(id: string): Promise { + return this.normaRepository.findOne({ + where: { id } as FindOptionsWhere, + relations: ['requisitos'], + }); + } + + async findRequisitosByNorma(normaId: string): Promise { + return this.requisitoRepository.find({ + where: { normaId } as FindOptionsWhere, + order: { numero: 'ASC' }, + }); + } + + // ========== Cumplimiento por Obra ========== + async findCumplimientos( + ctx: ServiceContext, + filters: CumplimientoFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.cumplimientoRepository + .createQueryBuilder('c') + .leftJoinAndSelect('c.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('c.norma', 'norma') + .leftJoinAndSelect('c.requisito', 'requisito') + .leftJoinAndSelect('c.evaluador', 'evaluador') + .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.normaId) { + queryBuilder.andWhere('c.norma_id = :normaId', { normaId: filters.normaId }); + } + + if (filters.estado) { + queryBuilder.andWhere('c.estado = :estado', { estado: filters.estado }); + } + + queryBuilder + .orderBy('norma.codigo', 'ASC') + .addOrderBy('requisito.numero', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + async createCumplimiento(ctx: ServiceContext, dto: CreateCumplimientoDto): Promise { + const cumplimiento = this.cumplimientoRepository.create({ + tenantId: ctx.tenantId, + fraccionamientoId: dto.fraccionamientoId, + normaId: dto.normaId, + requisitoId: dto.requisitoId, + evaluadorId: dto.evaluadorId, + fechaEvaluacion: dto.fechaEvaluacion, + estado: dto.estado || 'no_cumple', + evidenciaUrl: dto.evidenciaUrl, + observaciones: dto.observaciones, + fechaCompromiso: dto.fechaCompromiso, + }); + + return this.cumplimientoRepository.save(cumplimiento); + } + + async updateCumplimientoEstado( + ctx: ServiceContext, + id: string, + estado: EstadoCumplimiento, + evidenciaUrl?: string, + observaciones?: string + ): Promise { + const cumplimiento = await this.cumplimientoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!cumplimiento) return null; + + cumplimiento.estado = estado; + cumplimiento.fechaEvaluacion = new Date(); + if (evidenciaUrl) cumplimiento.evidenciaUrl = evidenciaUrl; + if (observaciones) cumplimiento.observaciones = observaciones; + + return this.cumplimientoRepository.save(cumplimiento); + } + + // ========== Comisiones de Seguridad ========== + async findComisiones( + ctx: ServiceContext, + filters: ComisionFilters = {} + ): Promise { + const queryBuilder = this.comisionRepository + .createQueryBuilder('c') + .leftJoinAndSelect('c.fraccionamiento', 'fraccionamiento') + .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('c.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.estado) { + queryBuilder.andWhere('c.estado = :estado', { estado: filters.estado }); + } + + return queryBuilder.orderBy('c.fecha_constitucion', 'DESC').getMany(); + } + + async findComisionById(ctx: ServiceContext, id: string): Promise { + return this.comisionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento', 'integrantes', 'integrantes.employee', 'recorridos'], + }); + } + + async createComision(ctx: ServiceContext, dto: CreateComisionDto): Promise { + const comision = this.comisionRepository.create({ + tenantId: ctx.tenantId, + fraccionamientoId: dto.fraccionamientoId, + fechaConstitucion: dto.fechaConstitucion, + numeroActa: dto.numeroActa, + vigenciaInicio: dto.vigenciaInicio, + vigenciaFin: dto.vigenciaFin, + documentoActaUrl: dto.documentoActaUrl, + estado: 'activa', + }); + + return this.comisionRepository.save(comision); + } + + async updateComisionEstado(ctx: ServiceContext, id: string, estado: EstadoComision): Promise { + const comision = await this.comisionRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!comision) return null; + + comision.estado = estado; + return this.comisionRepository.save(comision); + } + + async addIntegrante(_ctx: ServiceContext, comisionId: string, dto: AddIntegranteDto): Promise { + const integrante = this.integranteRepository.create({ + comisionId, + employeeId: dto.employeeId, + rol: dto.rol, + representacion: dto.representacion, + fechaNombramiento: dto.fechaNombramiento, + activo: true, + }); + + return this.integranteRepository.save(integrante); + } + + async removeIntegrante(_ctx: ServiceContext, integranteId: string): Promise { + const integrante = await this.integranteRepository.findOne({ + where: { id: integranteId } as FindOptionsWhere, + }); + if (!integrante) return false; + + integrante.activo = false; + await this.integranteRepository.save(integrante); + return true; + } + + async createRecorrido(_ctx: ServiceContext, dto: CreateRecorridoDto): Promise { + const recorrido = this.recorridoRepository.create({ + comisionId: dto.comisionId, + fechaProgramada: dto.fechaProgramada, + areasRecorridas: dto.areasRecorridas, + estado: 'programado', + }); + + return this.recorridoRepository.save(recorrido); + } + + async completarRecorrido( + _ctx: ServiceContext, + id: string, + hallazgos?: string, + recomendaciones?: string, + numeroActa?: string, + documentoActaUrl?: string + ): Promise { + const recorrido = await this.recorridoRepository.findOne({ + where: { id } as FindOptionsWhere, + }); + if (!recorrido) return null; + + recorrido.fechaRealizada = new Date(); + if (hallazgos) recorrido.hallazgos = hallazgos; + if (recomendaciones) recorrido.recomendaciones = recomendaciones; + if (numeroActa) recorrido.numeroActa = numeroActa; + if (documentoActaUrl) recorrido.documentoActaUrl = documentoActaUrl; + recorrido.estado = 'realizado'; + + return this.recorridoRepository.save(recorrido); + } + + async cancelarRecorrido(_ctx: ServiceContext, id: string): Promise { + const recorrido = await this.recorridoRepository.findOne({ + where: { id } as FindOptionsWhere, + }); + if (!recorrido) return null; + + recorrido.estado = 'cancelado'; + return this.recorridoRepository.save(recorrido); + } + + // ========== Programas de Seguridad ========== + async findProgramas(ctx: ServiceContext, fraccionamientoId?: string, anio?: number): Promise { + const queryBuilder = this.programaRepository + .createQueryBuilder('p') + .leftJoinAndSelect('p.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('p.aprobadoPor', 'aprobadoPor') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (fraccionamientoId) { + queryBuilder.andWhere('p.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + if (anio) { + queryBuilder.andWhere('p.anio = :anio', { anio }); + } + + return queryBuilder.orderBy('p.anio', 'DESC').getMany(); + } + + async findProgramaById(ctx: ServiceContext, id: string): Promise { + return this.programaRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento', 'aprobadoPor', 'actividades', 'actividades.responsable'], + }); + } + + async createPrograma(ctx: ServiceContext, dto: CreateProgramaDto): Promise { + const programa = this.programaRepository.create({ + tenantId: ctx.tenantId, + fraccionamientoId: dto.fraccionamientoId, + anio: dto.anio, + objetivoGeneral: dto.objetivoGeneral, + metas: dto.metas, + presupuesto: dto.presupuesto, + estado: 'borrador', + }); + + return this.programaRepository.save(programa); + } + + async aprobarPrograma(ctx: ServiceContext, id: string): Promise { + const programa = await this.programaRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!programa) return null; + + programa.estado = 'activo'; + if (ctx.userId) programa.aprobadoPorId = ctx.userId; + programa.fechaAprobacion = new Date(); + return this.programaRepository.save(programa); + } + + async finalizarPrograma(ctx: ServiceContext, id: string): Promise { + const programa = await this.programaRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!programa) return null; + + programa.estado = 'finalizado'; + return this.programaRepository.save(programa); + } + + async createActividad(_ctx: ServiceContext, dto: CreateActividadDto): Promise { + const actividad = this.actividadRepository.create({ + programaId: dto.programaId, + actividad: dto.actividad, + tipo: dto.tipo, + fechaProgramada: dto.fechaProgramada, + responsableId: dto.responsableId, + recursos: dto.recursos, + estado: 'pendiente', + }); + + return this.actividadRepository.save(actividad); + } + + async updateActividadEstado(_ctx: ServiceContext, id: string, estado: EstadoActividad): Promise { + const actividad = await this.actividadRepository.findOne({ + where: { id } as FindOptionsWhere, + }); + if (!actividad) return null; + + actividad.estado = estado; + if (estado === 'completada') { + actividad.fechaRealizada = new Date(); + } + return this.actividadRepository.save(actividad); + } + + async completarActividad(_ctx: ServiceContext, id: string, evidenciaUrl?: string): Promise { + const actividad = await this.actividadRepository.findOne({ + where: { id } as FindOptionsWhere, + }); + if (!actividad) return null; + + actividad.fechaRealizada = new Date(); + if (evidenciaUrl) actividad.evidenciaUrl = evidenciaUrl; + actividad.estado = 'completada'; + + return this.actividadRepository.save(actividad); + } + + // ========== Documentos STPS ========== + async createDocumento(ctx: ServiceContext, dto: CreateDocumentoDto): Promise { + const documento = this.documentoRepository.create({ + tenantId: ctx.tenantId, + tipo: dto.tipo, + folio: dto.folio, + fraccionamientoId: dto.fraccionamientoId, + employeeId: dto.employeeId, + fechaEmision: dto.fechaEmision, + fechaVencimiento: dto.fechaVencimiento, + datosDocumento: dto.datosDocumento, + documentoUrl: dto.documentoUrl, + createdById: ctx.userId, + }); + + return this.documentoRepository.save(documento); + } + + async findDocumentos(ctx: ServiceContext, fraccionamientoId?: string, tipo?: TipoDocumentoStps): Promise { + const queryBuilder = this.documentoRepository + .createQueryBuilder('d') + .leftJoinAndSelect('d.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('d.employee', 'employee') + .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (fraccionamientoId) { + queryBuilder.andWhere('d.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + if (tipo) { + queryBuilder.andWhere('d.tipo = :tipo', { tipo }); + } + + return queryBuilder.orderBy('d.created_at', 'DESC').getMany(); + } + + async findDocumentoById(ctx: ServiceContext, id: string): Promise { + return this.documentoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento', 'employee', 'createdBy'], + }); + } + + async marcarDocumentoFirmado(ctx: ServiceContext, id: string): Promise { + const documento = await this.documentoRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!documento) return null; + + documento.firmado = true; + return this.documentoRepository.save(documento); + } + + // ========== Auditorías ========== + async findAuditorias( + ctx: ServiceContext, + filters: AuditoriaFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.auditoriaRepository + .createQueryBuilder('a') + .leftJoinAndSelect('a.fraccionamiento', 'fraccionamiento') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('a.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.tipo) { + queryBuilder.andWhere('a.tipo = :tipo', { tipo: filters.tipo }); + } + + if (filters.resultado) { + queryBuilder.andWhere('a.resultado = :resultado', { resultado: filters.resultado }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('a.fecha_programada >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('a.fecha_programada <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('a.fecha_programada', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }; + } + + async findAuditoriaById(ctx: ServiceContext, id: string): Promise { + return this.auditoriaRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['fraccionamiento'], + }); + } + + async createAuditoria(ctx: ServiceContext, dto: CreateAuditoriaDto): Promise { + const auditoria = this.auditoriaRepository.create({ + tenantId: ctx.tenantId, + fraccionamientoId: dto.fraccionamientoId, + tipo: dto.tipo, + fechaProgramada: dto.fechaProgramada, + auditor: dto.auditor, + }); + + return this.auditoriaRepository.save(auditoria); + } + + async completarAuditoria( + ctx: ServiceContext, + id: string, + resultado: ResultadoAuditoria, + noConformidades: number, + observaciones?: string, + informeUrl?: string + ): Promise { + const auditoria = await this.auditoriaRepository.findOne({ + where: { id, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (!auditoria) return null; + + auditoria.fechaRealizada = new Date(); + auditoria.resultado = resultado; + auditoria.noConformidades = noConformidades; + if (observaciones) auditoria.observaciones = observaciones; + if (informeUrl) auditoria.informeUrl = informeUrl; + + return this.auditoriaRepository.save(auditoria); + } + + // ========== Estadísticas ========== + async getStats(ctx: ServiceContext, fraccionamientoId?: string): Promise { + // Total normas (shared catalog) + const totalNormas = await this.normaRepository.count({ + where: { activo: true } as FindOptionsWhere, + }); + + // Cumplimiento por estado + const cumplQuery = this.cumplimientoRepository + .createQueryBuilder('c') + .select('c.estado', 'estado') + .addSelect('COUNT(*)', 'count') + .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + if (fraccionamientoId) { + cumplQuery.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + const cumplimientoPorEstado = await cumplQuery.groupBy('c.estado').getRawMany(); + + // Porcentaje cumplimiento + const totalCumplimientos = cumplimientoPorEstado.reduce((sum, c) => sum + parseInt(c.count, 10), 0); + const cumple = cumplimientoPorEstado.find((c) => c.estado === 'cumple'); + const porcentajeCumplimiento = totalCumplimientos > 0 + ? Math.round((parseInt(cumple?.count || '0', 10) / totalCumplimientos) * 100) + : 0; + + // Comisiones activas + const comisionQuery = this.comisionRepository + .createQueryBuilder('c') + .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('c.estado = :estado', { estado: 'activa' }); + if (fraccionamientoId) { + comisionQuery.andWhere('c.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + const comisionesActivas = await comisionQuery.getCount(); + + // Recorridos pendientes + const recorridosPendientes = await this.recorridoRepository + .createQueryBuilder('r') + .innerJoin('r.comision', 'c') + .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('r.estado IN (:...estados)', { estados: ['programado', 'pendiente'] }) + .getCount(); + + // Actividades pendientes + const actividadesPendientes = await this.actividadRepository + .createQueryBuilder('a') + .innerJoin('a.programa', 'p') + .where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.estado IN (:...estados)', { estados: ['pendiente', 'en_progreso'] }) + .getCount(); + + // Auditorías programadas (sin resultado) + const auditoriasProgramadas = await this.auditoriaRepository + .createQueryBuilder('a') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.resultado IS NULL') + .getCount(); + + return { + totalNormas, + cumplimientoPorEstado: cumplimientoPorEstado.map((c) => ({ estado: c.estado, count: parseInt(c.count, 10) })), + porcentajeCumplimiento, + comisionesActivas, + recorridosPendientes, + actividadesPendientes, + auditoriasProgramadas, + }; + } +} diff --git a/src/modules/infonavit/controllers/asignacion.controller.ts b/src/modules/infonavit/controllers/asignacion.controller.ts new file mode 100644 index 0000000..57a5117 --- /dev/null +++ b/src/modules/infonavit/controllers/asignacion.controller.ts @@ -0,0 +1,240 @@ +/** + * AsignacionController - REST API for housing assignments + * + * Endpoints para gestión de asignaciones de vivienda INFONAVIT. + * + * @module Infonavit + * @routes /api/asignaciones + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AsignacionService, AsignacionFilters } from '../services/asignacion.service'; +import { AsignacionVivienda } from '../entities/asignacion-vivienda.entity'; +import { OfertaVivienda } from '../entities/oferta-vivienda.entity'; +import { Derechohabiente } from '../entities/derechohabiente.entity'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createAsignacionController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const asignacionRepo = dataSource.getRepository(AsignacionVivienda); + const ofertaRepo = dataSource.getRepository(OfertaVivienda); + const derechohabienteRepo = dataSource.getRepository(Derechohabiente); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const service = new AsignacionService(asignacionRepo, ofertaRepo, derechohabienteRepo); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper for service context + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /api/asignaciones + * List assignments with filters + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: AsignacionFilters = {}; + if (req.query.ofertaId) filters.ofertaId = req.query.ofertaId as string; + if (req.query.derechohabienteId) filters.derechohabienteId = req.query.derechohabienteId as string; + if (req.query.status) filters.status = req.query.status as AsignacionFilters['status']; + if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string); + if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string); + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await service.findWithFilters(getContext(req), filters, page, limit); + res.status(200).json({ success: true, data: result.data, pagination: result.meta }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/asignaciones/:id + * Get assignment with details + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const asignacion = await service.findWithDetails(getContext(req), req.params.id); + if (!asignacion) { + res.status(404).json({ error: 'Not Found', message: 'Assignment not found' }); + return; + } + + res.status(200).json({ success: true, data: asignacion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/asignaciones + * Create new assignment + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'infonavit'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const asignacion = await service.create(getContext(req), { + ofertaId: req.body.ofertaId, + derechohabienteId: req.body.derechohabienteId, + assignmentDate: new Date(req.body.assignmentDate), + creditAmount: req.body.creditAmount, + subsidyAmount: req.body.subsidyAmount, + downPayment: req.body.downPayment, + notes: req.body.notes, + }); + + res.status(201).json({ success: true, data: asignacion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/asignaciones/:id/approve + * Approve assignment + */ + router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'infonavit'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const asignacion = await service.approve(getContext(req), req.params.id); + if (!asignacion) { + res.status(404).json({ error: 'Not Found', message: 'Assignment not found' }); + return; + } + + res.status(200).json({ success: true, data: asignacion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/asignaciones/:id/formalize + * Formalize assignment + */ + router.post('/:id/formalize', authMiddleware.authenticate, authMiddleware.authorize('admin', 'infonavit'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const asignacion = await service.formalize(getContext(req), req.params.id, { + formalizationDate: new Date(req.body.formalizationDate), + notaryName: req.body.notaryName, + notaryNumber: req.body.notaryNumber, + deedNumber: req.body.deedNumber, + deedDate: new Date(req.body.deedDate), + }); + + if (!asignacion) { + res.status(404).json({ error: 'Not Found', message: 'Assignment not found' }); + return; + } + + res.status(200).json({ success: true, data: asignacion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/asignaciones/:id/deliver + * Deliver housing + */ + router.post('/:id/deliver', authMiddleware.authenticate, authMiddleware.authorize('admin', 'infonavit'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const asignacion = await service.deliver( + getContext(req), + req.params.id, + new Date(req.body.deliveryDate) + ); + + if (!asignacion) { + res.status(404).json({ error: 'Not Found', message: 'Assignment not found' }); + return; + } + + res.status(200).json({ success: true, data: asignacion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/asignaciones/:id/cancel + * Cancel assignment + */ + router.post('/:id/cancel', authMiddleware.authenticate, authMiddleware.authorize('admin', 'infonavit'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const asignacion = await service.cancel(getContext(req), req.params.id, req.body.reason); + if (!asignacion) { + res.status(404).json({ error: 'Not Found', message: 'Assignment not found' }); + return; + } + + res.status(200).json({ success: true, data: asignacion }); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/src/modules/infonavit/controllers/derechohabiente.controller.ts b/src/modules/infonavit/controllers/derechohabiente.controller.ts new file mode 100644 index 0000000..3ea777a --- /dev/null +++ b/src/modules/infonavit/controllers/derechohabiente.controller.ts @@ -0,0 +1,291 @@ +/** + * DerechohabienteController - REST API for INFONAVIT workers + * + * Endpoints para gestión de derechohabientes INFONAVIT. + * + * @module Infonavit + * @routes /api/derechohabientes + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { DerechohabienteService, DerechohabienteFilters } from '../services/derechohabiente.service'; +import { Derechohabiente } from '../entities/derechohabiente.entity'; +import { HistoricoPuntos } from '../entities/historico-puntos.entity'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createDerechohabienteController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const derechohabienteRepo = dataSource.getRepository(Derechohabiente); + const historicoPuntosRepo = dataSource.getRepository(HistoricoPuntos); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const service = new DerechohabienteService(derechohabienteRepo, historicoPuntosRepo); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper for service context + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /api/derechohabientes + * List workers with filters + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: DerechohabienteFilters = {}; + if (req.query.nss) filters.nss = req.query.nss as string; + if (req.query.curp) filters.curp = req.query.curp as string; + if (req.query.status) filters.status = req.query.status as DerechohabienteFilters['status']; + if (req.query.search) filters.search = req.query.search as string; + if (req.query.minPoints) filters.minPoints = parseInt(req.query.minPoints as string); + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await service.findWithFilters(getContext(req), filters, page, limit); + res.status(200).json({ success: true, data: result.data, pagination: result.meta }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/derechohabientes/:id + * Get worker with details + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const derechohabiente = await service.findWithDetails(getContext(req), req.params.id); + if (!derechohabiente) { + res.status(404).json({ error: 'Not Found', message: 'Worker not found' }); + return; + } + + res.status(200).json({ success: true, data: derechohabiente }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/derechohabientes/nss/:nss + * Get worker by NSS + */ + router.get('/nss/:nss', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const derechohabiente = await service.findByNss(getContext(req), req.params.nss); + if (!derechohabiente) { + res.status(404).json({ error: 'Not Found', message: 'Worker not found' }); + return; + } + + res.status(200).json({ success: true, data: derechohabiente }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/derechohabientes + * Create new worker + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'infonavit'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const derechohabiente = await service.create(getContext(req), req.body); + res.status(201).json({ success: true, data: derechohabiente }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /api/derechohabientes/:id + * Update worker + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'infonavit'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const derechohabiente = await service.update(getContext(req), req.params.id, req.body); + if (!derechohabiente) { + res.status(404).json({ error: 'Not Found', message: 'Worker not found' }); + return; + } + + res.status(200).json({ success: true, data: derechohabiente }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/derechohabientes/:id/points + * Update worker points + */ + router.post('/:id/points', authMiddleware.authenticate, authMiddleware.authorize('admin', 'infonavit'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { points, source, notes } = req.body; + const derechohabiente = await service.updatePoints(getContext(req), req.params.id, points, source, notes); + if (!derechohabiente) { + res.status(404).json({ error: 'Not Found', message: 'Worker not found' }); + return; + } + + res.status(200).json({ success: true, data: derechohabiente }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/derechohabientes/:id/precalify + * Precalify worker + */ + router.post('/:id/precalify', authMiddleware.authenticate, authMiddleware.authorize('admin', 'infonavit'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const derechohabiente = await service.precalify(getContext(req), req.params.id, { + precalificationDate: new Date(req.body.precalificationDate), + precalificationAmount: req.body.precalificationAmount, + creditType: req.body.creditType, + creditPoints: req.body.creditPoints, + }); + + if (!derechohabiente) { + res.status(404).json({ error: 'Not Found', message: 'Worker not found' }); + return; + } + + res.status(200).json({ success: true, data: derechohabiente }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/derechohabientes/:id/qualify + * Qualify worker + */ + router.post('/:id/qualify', authMiddleware.authenticate, authMiddleware.authorize('admin', 'infonavit'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const derechohabiente = await service.qualify(getContext(req), req.params.id); + if (!derechohabiente) { + res.status(404).json({ error: 'Not Found', message: 'Worker not found' }); + return; + } + + res.status(200).json({ success: true, data: derechohabiente }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/derechohabientes/:id/points-history + * Get points history + */ + router.get('/:id/points-history', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const history = await service.getPointsHistory(getContext(req), req.params.id); + res.status(200).json({ success: true, data: history }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /api/derechohabientes/:id + * Soft delete worker + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await service.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Worker not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/src/modules/infonavit/controllers/index.ts b/src/modules/infonavit/controllers/index.ts new file mode 100644 index 0000000..acf23a2 --- /dev/null +++ b/src/modules/infonavit/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * Infonavit Controllers Index + * @module Infonavit + */ + +export * from './derechohabiente.controller'; +export * from './asignacion.controller'; diff --git a/src/modules/infonavit/entities/acta-vivienda.entity.ts b/src/modules/infonavit/entities/acta-vivienda.entity.ts new file mode 100644 index 0000000..a61cbf6 --- /dev/null +++ b/src/modules/infonavit/entities/acta-vivienda.entity.ts @@ -0,0 +1,88 @@ +/** + * ActaVivienda Entity + * Viviendas incluidas en actas oficiales + * + * @module Infonavit + * @table infonavit.acta_viviendas + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Acta } from './acta.entity'; +import { OfertaVivienda } from './oferta-vivienda.entity'; + +export type ActaViviendaResult = 'pending' | 'approved' | 'rejected' | 'observations'; + +@Entity({ schema: 'infonavit', name: 'acta_viviendas' }) +@Index(['tenantId', 'actaId', 'ofertaId'], { unique: true }) +export class ActaVivienda { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'acta_id', type: 'uuid' }) + actaId: string; + + @Column({ name: 'oferta_id', type: 'uuid' }) + ofertaId: string; + + @Column({ name: 'sequence_number', type: 'integer' }) + sequenceNumber: number; + + @Column({ type: 'varchar', length: 20, default: 'pending' }) + result: ActaViviendaResult; + + @Column({ type: 'text', nullable: true }) + observations: string; + + @Column({ name: 'inspected_at', type: 'timestamptz', nullable: true }) + inspectedAt: Date; + + @Column({ name: 'inspected_by', type: 'uuid', nullable: true }) + inspectedById: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Acta, (a) => a.viviendas, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'acta_id' }) + acta: Acta; + + @ManyToOne(() => OfertaVivienda) + @JoinColumn({ name: 'oferta_id' }) + oferta: OfertaVivienda; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'inspected_by' }) + inspectedBy: User; +} diff --git a/src/modules/infonavit/entities/acta.entity.ts b/src/modules/infonavit/entities/acta.entity.ts new file mode 100644 index 0000000..7ca60ff --- /dev/null +++ b/src/modules/infonavit/entities/acta.entity.ts @@ -0,0 +1,101 @@ +/** + * Acta Entity + * Actas oficiales INFONAVIT (entregas, inspecciones, etc.) + * + * @module Infonavit + * @table infonavit.actas + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { ActaVivienda } from './acta-vivienda.entity'; + +export type ActaTipo = 'delivery' | 'inspection' | 'verification' | 'closure' | 'other'; +export type ActaStatus = 'draft' | 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; + +@Entity({ schema: 'infonavit', name: 'actas' }) +@Index(['tenantId', 'actaNumber'], { unique: true }) +export class Acta { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'registro_id', type: 'uuid' }) + registroId: string; + + @Column({ name: 'acta_number', type: 'varchar', length: 50 }) + actaNumber: string; + + @Column({ name: 'acta_type', type: 'varchar', length: 20 }) + actaType: ActaTipo; + + @Column({ name: 'scheduled_date', type: 'date' }) + scheduledDate: Date; + + @Column({ name: 'execution_date', type: 'date', nullable: true }) + executionDate: Date; + + @Column({ type: 'varchar', length: 255, nullable: true }) + location: string; + + @Column({ name: 'infonavit_representative', type: 'varchar', length: 200, nullable: true }) + infonavitRepresentative: string; + + @Column({ name: 'developer_representative', type: 'varchar', length: 200, nullable: true }) + developerRepresentative: string; + + @Column({ name: 'total_units', type: 'integer', default: 0 }) + totalUnits: number; + + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: ActaStatus; + + @Column({ type: 'text', nullable: true }) + observations: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => ActaVivienda, (av) => av.acta) + viviendas: ActaVivienda[]; +} diff --git a/src/modules/infonavit/entities/asignacion-vivienda.entity.ts b/src/modules/infonavit/entities/asignacion-vivienda.entity.ts new file mode 100644 index 0000000..7fa6a65 --- /dev/null +++ b/src/modules/infonavit/entities/asignacion-vivienda.entity.ts @@ -0,0 +1,116 @@ +/** + * AsignacionVivienda Entity + * Asignaciones de vivienda a derechohabientes + * + * @module Infonavit + * @table infonavit.asignaciones_vivienda + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { OfertaVivienda } from './oferta-vivienda.entity'; +import { Derechohabiente } from './derechohabiente.entity'; + +export type AsignacionStatus = 'pending' | 'approved' | 'formalized' | 'delivered' | 'cancelled'; + +@Entity({ schema: 'infonavit', name: 'asignaciones_vivienda' }) +@Index(['tenantId', 'assignmentNumber'], { unique: true }) +export class AsignacionVivienda { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'oferta_id', type: 'uuid' }) + ofertaId: string; + + @Column({ name: 'derechohabiente_id', type: 'uuid' }) + derechohabienteId: string; + + @Column({ name: 'assignment_number', type: 'varchar', length: 50 }) + assignmentNumber: string; + + @Column({ name: 'assignment_date', type: 'date' }) + assignmentDate: Date; + + @Column({ name: 'approval_date', type: 'date', nullable: true }) + approvalDate: Date; + + @Column({ name: 'formalization_date', type: 'date', nullable: true }) + formalizationDate: Date; + + @Column({ name: 'delivery_date', type: 'date', nullable: true }) + deliveryDate: Date; + + @Column({ name: 'credit_amount', type: 'decimal', precision: 14, scale: 2 }) + creditAmount: number; + + @Column({ name: 'subsidy_amount', type: 'decimal', precision: 14, scale: 2, default: 0 }) + subsidyAmount: number; + + @Column({ name: 'down_payment', type: 'decimal', precision: 14, scale: 2, default: 0 }) + downPayment: number; + + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: AsignacionStatus; + + @Column({ name: 'notary_name', type: 'varchar', length: 200, nullable: true }) + notaryName: string; + + @Column({ name: 'notary_number', type: 'varchar', length: 20, nullable: true }) + notaryNumber: string; + + @Column({ name: 'deed_number', type: 'varchar', length: 50, nullable: true }) + deedNumber: string; + + @Column({ name: 'deed_date', type: 'date', nullable: true }) + deedDate: Date; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Computed property + get totalAmount(): number { + return Number(this.creditAmount) + Number(this.subsidyAmount) + Number(this.downPayment); + } + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => OfertaVivienda, (o) => o.asignaciones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'oferta_id' }) + oferta: OfertaVivienda; + + @ManyToOne(() => Derechohabiente, (d) => d.asignaciones) + @JoinColumn({ name: 'derechohabiente_id' }) + derechohabiente: Derechohabiente; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/infonavit/entities/derechohabiente.entity.ts b/src/modules/infonavit/entities/derechohabiente.entity.ts new file mode 100644 index 0000000..23d4d2a --- /dev/null +++ b/src/modules/infonavit/entities/derechohabiente.entity.ts @@ -0,0 +1,119 @@ +/** + * Derechohabiente Entity + * Derechohabientes INFONAVIT (trabajadores con crédito) + * + * @module Infonavit + * @table infonavit.derechohabientes + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { AsignacionVivienda } from './asignacion-vivienda.entity'; +import { HistoricoPuntos } from './historico-puntos.entity'; + +export type DerechohabienteStatus = 'active' | 'pre_qualified' | 'qualified' | 'assigned' | 'inactive'; + +@Entity({ schema: 'infonavit', name: 'derechohabientes' }) +@Index(['tenantId', 'nss'], { unique: true }) +@Index(['tenantId', 'curp'], { unique: true }) +export class Derechohabiente { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 11 }) + nss: string; + + @Column({ type: 'varchar', length: 18 }) + curp: string; + + @Column({ type: 'varchar', length: 13, nullable: true }) + rfc: string; + + @Column({ name: 'first_name', type: 'varchar', length: 100 }) + firstName: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100 }) + lastName: string; + + @Column({ name: 'second_last_name', type: 'varchar', length: 100, nullable: true }) + secondLastName: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ type: 'text', nullable: true }) + address: string; + + @Column({ name: 'credit_points', type: 'integer', default: 0 }) + creditPoints: number; + + @Column({ name: 'precalification_date', type: 'date', nullable: true }) + precalificationDate: Date; + + @Column({ name: 'precalification_amount', type: 'decimal', precision: 14, scale: 2, nullable: true }) + precalificationAmount: number; + + @Column({ name: 'credit_type', type: 'varchar', length: 50, nullable: true }) + creditType: string; + + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: DerechohabienteStatus; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Computed property + get fullName(): string { + return `${this.firstName} ${this.lastName}${this.secondLastName ? ' ' + this.secondLastName : ''}`; + } + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => AsignacionVivienda, (a) => a.derechohabiente) + asignaciones: AsignacionVivienda[]; + + @OneToMany(() => HistoricoPuntos, (h) => h.derechohabiente) + historicoPuntos: HistoricoPuntos[]; +} diff --git a/src/modules/infonavit/entities/historico-puntos.entity.ts b/src/modules/infonavit/entities/historico-puntos.entity.ts new file mode 100644 index 0000000..577b66a --- /dev/null +++ b/src/modules/infonavit/entities/historico-puntos.entity.ts @@ -0,0 +1,75 @@ +/** + * HistoricoPuntos Entity + * Histórico de puntos de crédito INFONAVIT + * + * @module Infonavit + * @table infonavit.historico_puntos + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Derechohabiente } from './derechohabiente.entity'; + +export type MovimientoTipo = 'initial' | 'update' | 'adjustment' | 'precalification'; + +@Entity({ schema: 'infonavit', name: 'historico_puntos' }) +@Index(['tenantId', 'derechohabienteId', 'recordDate']) +export class HistoricoPuntos { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'derechohabiente_id', type: 'uuid' }) + derechohabienteId: string; + + @Column({ name: 'record_date', type: 'date' }) + recordDate: Date; + + @Column({ name: 'previous_points', type: 'integer' }) + previousPoints: number; + + @Column({ name: 'new_points', type: 'integer' }) + newPoints: number; + + @Column({ name: 'points_change', type: 'integer' }) + pointsChange: number; + + @Column({ name: 'movement_type', type: 'varchar', length: 20 }) + movementType: MovimientoTipo; + + @Column({ type: 'varchar', length: 255, nullable: true }) + source: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Derechohabiente, (d) => d.historicoPuntos, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'derechohabiente_id' }) + derechohabiente: Derechohabiente; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/infonavit/entities/index.ts b/src/modules/infonavit/entities/index.ts new file mode 100644 index 0000000..3c57cc9 --- /dev/null +++ b/src/modules/infonavit/entities/index.ts @@ -0,0 +1,15 @@ +/** + * Infonavit Entities Index + * @module Infonavit + * + * Gestión de viviendas INFONAVIT (MAI-011) + */ + +export * from './registro-infonavit.entity'; +export * from './oferta-vivienda.entity'; +export * from './derechohabiente.entity'; +export * from './asignacion-vivienda.entity'; +export * from './acta.entity'; +export * from './acta-vivienda.entity'; +export * from './reporte-infonavit.entity'; +export * from './historico-puntos.entity'; diff --git a/src/modules/infonavit/entities/oferta-vivienda.entity.ts b/src/modules/infonavit/entities/oferta-vivienda.entity.ts new file mode 100644 index 0000000..d3364cd --- /dev/null +++ b/src/modules/infonavit/entities/oferta-vivienda.entity.ts @@ -0,0 +1,96 @@ +/** + * OfertaVivienda Entity + * Ofertas de vivienda registradas ante INFONAVIT + * + * @module Infonavit + * @table infonavit.ofertas_vivienda + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { RegistroInfonavit } from './registro-infonavit.entity'; +import { AsignacionVivienda } from './asignacion-vivienda.entity'; + +export type OfertaStatus = 'available' | 'reserved' | 'assigned' | 'delivered' | 'cancelled'; + +@Entity({ schema: 'infonavit', name: 'ofertas_vivienda' }) +@Index(['tenantId', 'infonavitCode'], { unique: true }) +export class OfertaVivienda { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'registro_id', type: 'uuid' }) + registroId: string; + + @Column({ name: 'lote_id', type: 'uuid' }) + loteId: string; + + @Column({ name: 'infonavit_code', type: 'varchar', length: 50 }) + infonavitCode: string; + + @Column({ name: 'offer_date', type: 'date' }) + offerDate: Date; + + @Column({ name: 'sale_price', type: 'decimal', precision: 14, scale: 2 }) + salePrice: number; + + @Column({ name: 'infonavit_value', type: 'decimal', precision: 14, scale: 2 }) + infonavitValue: number; + + @Column({ name: 'housing_type', type: 'varchar', length: 50 }) + housingType: string; + + @Column({ name: 'construction_area', type: 'decimal', precision: 10, scale: 2 }) + constructionArea: number; + + @Column({ name: 'land_area', type: 'decimal', precision: 10, scale: 2 }) + landArea: number; + + @Column({ type: 'varchar', length: 20, default: 'available' }) + status: OfertaStatus; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => RegistroInfonavit, (r) => r.ofertas, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'registro_id' }) + registro: RegistroInfonavit; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => AsignacionVivienda, (a) => a.oferta) + asignaciones: AsignacionVivienda[]; +} diff --git a/src/modules/infonavit/entities/registro-infonavit.entity.ts b/src/modules/infonavit/entities/registro-infonavit.entity.ts new file mode 100644 index 0000000..8bef050 --- /dev/null +++ b/src/modules/infonavit/entities/registro-infonavit.entity.ts @@ -0,0 +1,94 @@ +/** + * RegistroInfonavit Entity + * Registro de proyectos ante INFONAVIT + * + * @module Infonavit + * @table infonavit.registros_infonavit + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { OfertaVivienda } from './oferta-vivienda.entity'; + +export type RegistroStatus = 'draft' | 'submitted' | 'approved' | 'active' | 'closed'; + +@Entity({ schema: 'infonavit', name: 'registros_infonavit' }) +@Index(['tenantId', 'registrationNumber'], { unique: true }) +export class RegistroInfonavit { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'registration_number', type: 'varchar', length: 50 }) + registrationNumber: string; + + @Column({ name: 'registration_date', type: 'date' }) + registrationDate: Date; + + @Column({ name: 'approval_date', type: 'date', nullable: true }) + approvalDate: Date; + + @Column({ name: 'developer_code', type: 'varchar', length: 30, nullable: true }) + developerCode: string; + + @Column({ name: 'total_units', type: 'integer' }) + totalUnits: number; + + @Column({ name: 'registered_units', type: 'integer', default: 0 }) + registeredUnits: number; + + @Column({ name: 'assigned_units', type: 'integer', default: 0 }) + assignedUnits: number; + + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: RegistroStatus; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => OfertaVivienda, (oferta) => oferta.registro) + ofertas: OfertaVivienda[]; +} diff --git a/src/modules/infonavit/entities/reporte-infonavit.entity.ts b/src/modules/infonavit/entities/reporte-infonavit.entity.ts new file mode 100644 index 0000000..57db266 --- /dev/null +++ b/src/modules/infonavit/entities/reporte-infonavit.entity.ts @@ -0,0 +1,109 @@ +/** + * ReporteInfonavit Entity + * Reportes generados para INFONAVIT + * + * @module Infonavit + * @table infonavit.reportes_infonavit + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +export type ReporteTipo = 'monthly' | 'quarterly' | 'annual' | 'progress' | 'delivery' | 'custom'; +export type ReporteStatus = 'draft' | 'generated' | 'submitted' | 'accepted' | 'rejected'; + +@Entity({ schema: 'infonavit', name: 'reportes_infonavit' }) +@Index(['tenantId', 'reportNumber'], { unique: true }) +export class ReporteInfonavit { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'registro_id', type: 'uuid' }) + registroId: string; + + @Column({ name: 'report_number', type: 'varchar', length: 50 }) + reportNumber: string; + + @Column({ name: 'report_type', type: 'varchar', length: 20 }) + reportType: ReporteTipo; + + @Column({ name: 'period_start', type: 'date' }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd: Date; + + @Column({ name: 'generation_date', type: 'date' }) + generationDate: Date; + + @Column({ name: 'submission_date', type: 'date', nullable: true }) + submissionDate: Date; + + @Column({ name: 'total_units_reported', type: 'integer', default: 0 }) + totalUnitsReported: number; + + @Column({ name: 'units_in_progress', type: 'integer', default: 0 }) + unitsInProgress: number; + + @Column({ name: 'units_completed', type: 'integer', default: 0 }) + unitsCompleted: number; + + @Column({ name: 'units_delivered', type: 'integer', default: 0 }) + unitsDelivered: number; + + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: ReporteStatus; + + @Column({ name: 'file_path', type: 'varchar', length: 500, nullable: true }) + filePath: string; + + @Column({ type: 'jsonb', nullable: true }) + data: Record; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'submitted_by', type: 'uuid', nullable: true }) + submittedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'submitted_by' }) + submittedBy: User; +} diff --git a/src/modules/infonavit/services/asignacion.service.ts b/src/modules/infonavit/services/asignacion.service.ts new file mode 100644 index 0000000..c61f5ea --- /dev/null +++ b/src/modules/infonavit/services/asignacion.service.ts @@ -0,0 +1,290 @@ +/** + * AsignacionService - Servicio de asignaciones de vivienda INFONAVIT + * + * Gestión de asignaciones de vivienda a derechohabientes. + * + * @module Infonavit + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { AsignacionVivienda, AsignacionStatus } from '../entities/asignacion-vivienda.entity'; +import { OfertaVivienda } from '../entities/oferta-vivienda.entity'; +import { Derechohabiente } from '../entities/derechohabiente.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateAsignacionDto { + ofertaId: string; + derechohabienteId: string; + assignmentDate: Date; + creditAmount: number; + subsidyAmount?: number; + downPayment?: number; + notes?: string; +} + +export interface FormalizeDto { + formalizationDate: Date; + notaryName: string; + notaryNumber: string; + deedNumber: string; + deedDate: Date; +} + +export interface AsignacionFilters { + registroId?: string; + ofertaId?: string; + derechohabienteId?: string; + status?: AsignacionStatus; + dateFrom?: Date; + dateTo?: Date; +} + +export class AsignacionService { + constructor( + private readonly asignacionRepository: Repository, + private readonly ofertaRepository: Repository, + private readonly derechohabienteRepository: Repository + ) {} + + private generateAssignmentNumber(): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `ASG-${year}${month}-${random}`; + } + + async findWithFilters( + ctx: ServiceContext, + filters: AsignacionFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.asignacionRepository + .createQueryBuilder('asig') + .leftJoinAndSelect('asig.oferta', 'oferta') + .leftJoinAndSelect('asig.derechohabiente', 'derechohabiente') + .leftJoinAndSelect('asig.createdBy', 'createdBy') + .where('asig.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.ofertaId) { + queryBuilder.andWhere('asig.oferta_id = :ofertaId', { ofertaId: filters.ofertaId }); + } + + if (filters.derechohabienteId) { + queryBuilder.andWhere('asig.derechohabiente_id = :derechohabienteId', { + derechohabienteId: filters.derechohabienteId, + }); + } + + if (filters.status) { + queryBuilder.andWhere('asig.status = :status', { status: filters.status }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('asig.assignment_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('asig.assignment_date <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('asig.assignment_date', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.asignacionRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as unknown as FindOptionsWhere, + }); + } + + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.asignacionRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as unknown as FindOptionsWhere, + relations: ['oferta', 'oferta.registro', 'derechohabiente', 'createdBy'], + }); + } + + async create(ctx: ServiceContext, dto: CreateAsignacionDto): Promise { + // Validate oferta exists and is available + const oferta = await this.ofertaRepository.findOne({ + where: { + id: dto.ofertaId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (!oferta) { + throw new Error('Housing offer not found'); + } + + if (oferta.status !== 'available' && oferta.status !== 'reserved') { + throw new Error('Housing offer is not available for assignment'); + } + + // Validate derechohabiente exists and is qualified + const derechohabiente = await this.derechohabienteRepository.findOne({ + where: { + id: dto.derechohabienteId, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + }); + + if (!derechohabiente) { + throw new Error('Worker not found'); + } + + if (derechohabiente.status !== 'qualified' && derechohabiente.status !== 'pre_qualified') { + throw new Error('Worker must be pre-qualified or qualified for assignment'); + } + + // Create assignment + const asignacion = this.asignacionRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + ofertaId: dto.ofertaId, + derechohabienteId: dto.derechohabienteId, + assignmentNumber: this.generateAssignmentNumber(), + assignmentDate: dto.assignmentDate, + creditAmount: dto.creditAmount, + subsidyAmount: dto.subsidyAmount || 0, + downPayment: dto.downPayment || 0, + notes: dto.notes, + status: 'pending', + }); + + const saved = await this.asignacionRepository.save(asignacion); + + // Update oferta status + oferta.status = 'reserved'; + await this.ofertaRepository.save(oferta); + + return saved; + } + + async approve(ctx: ServiceContext, id: string): Promise { + const asignacion = await this.findWithDetails(ctx, id); + if (!asignacion) { + return null; + } + + if (asignacion.status !== 'pending') { + throw new Error('Can only approve pending assignments'); + } + + asignacion.status = 'approved'; + asignacion.approvalDate = new Date(); + asignacion.updatedById = ctx.userId || ''; + + // Update oferta status + asignacion.oferta.status = 'assigned'; + await this.ofertaRepository.save(asignacion.oferta); + + // Update derechohabiente status + await this.derechohabienteRepository.update( + { id: asignacion.derechohabienteId } as FindOptionsWhere, + { status: 'assigned', updatedById: ctx.userId || '' } + ); + + return this.asignacionRepository.save(asignacion); + } + + async formalize(ctx: ServiceContext, id: string, dto: FormalizeDto): Promise { + const asignacion = await this.findWithDetails(ctx, id); + if (!asignacion) { + return null; + } + + if (asignacion.status !== 'approved') { + throw new Error('Can only formalize approved assignments'); + } + + asignacion.status = 'formalized'; + asignacion.formalizationDate = dto.formalizationDate; + asignacion.notaryName = dto.notaryName; + asignacion.notaryNumber = dto.notaryNumber; + asignacion.deedNumber = dto.deedNumber; + asignacion.deedDate = dto.deedDate; + asignacion.updatedById = ctx.userId || ''; + + return this.asignacionRepository.save(asignacion); + } + + async deliver(ctx: ServiceContext, id: string, deliveryDate: Date): Promise { + const asignacion = await this.findWithDetails(ctx, id); + if (!asignacion) { + return null; + } + + if (asignacion.status !== 'formalized') { + throw new Error('Can only deliver formalized assignments'); + } + + asignacion.status = 'delivered'; + asignacion.deliveryDate = deliveryDate; + asignacion.updatedById = ctx.userId || ''; + + // Update oferta status + asignacion.oferta.status = 'delivered'; + await this.ofertaRepository.save(asignacion.oferta); + + return this.asignacionRepository.save(asignacion); + } + + async cancel(ctx: ServiceContext, id: string, reason: string): Promise { + const asignacion = await this.findWithDetails(ctx, id); + if (!asignacion) { + return null; + } + + if (asignacion.status === 'delivered') { + throw new Error('Cannot cancel delivered assignments'); + } + + const previousStatus = asignacion.status; + asignacion.status = 'cancelled'; + asignacion.notes = `${asignacion.notes || ''}\n[CANCELLED] ${reason}`; + asignacion.updatedById = ctx.userId || ''; + + // Revert oferta status if it was assigned/reserved + if (previousStatus === 'approved' || previousStatus === 'formalized') { + asignacion.oferta.status = 'available'; + await this.ofertaRepository.save(asignacion.oferta); + + // Revert derechohabiente status + await this.derechohabienteRepository.update( + { id: asignacion.derechohabienteId } as FindOptionsWhere, + { status: 'qualified', updatedById: ctx.userId || '' } + ); + } else if (previousStatus === 'pending') { + asignacion.oferta.status = 'available'; + await this.ofertaRepository.save(asignacion.oferta); + } + + return this.asignacionRepository.save(asignacion); + } +} diff --git a/src/modules/infonavit/services/derechohabiente.service.ts b/src/modules/infonavit/services/derechohabiente.service.ts new file mode 100644 index 0000000..6cdc874 --- /dev/null +++ b/src/modules/infonavit/services/derechohabiente.service.ts @@ -0,0 +1,328 @@ +/** + * DerechohabienteService - Servicio de gestión de derechohabientes INFONAVIT + * + * Gestión de trabajadores con crédito INFONAVIT. + * + * @module Infonavit + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Derechohabiente, DerechohabienteStatus } from '../entities/derechohabiente.entity'; +import { HistoricoPuntos } from '../entities/historico-puntos.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateDerechohabienteDto { + nss: string; + curp: string; + rfc?: string; + firstName: string; + lastName: string; + secondLastName?: string; + phone?: string; + email?: string; + address?: string; + creditPoints?: number; + notes?: string; +} + +export interface UpdateDerechohabienteDto { + phone?: string; + email?: string; + address?: string; + notes?: string; +} + +export interface PrecalificationDto { + precalificationDate: Date; + precalificationAmount: number; + creditType: string; + creditPoints: number; +} + +export interface DerechohabienteFilters { + nss?: string; + curp?: string; + status?: DerechohabienteStatus; + search?: string; + minPoints?: number; +} + +export class DerechohabienteService { + constructor( + private readonly derechohabienteRepository: Repository, + private readonly historicoPuntosRepository: Repository + ) {} + + async findWithFilters( + ctx: ServiceContext, + filters: DerechohabienteFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.derechohabienteRepository + .createQueryBuilder('dh') + .leftJoinAndSelect('dh.createdBy', 'createdBy') + .where('dh.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('dh.deleted_at IS NULL'); + + if (filters.nss) { + queryBuilder.andWhere('dh.nss = :nss', { nss: filters.nss }); + } + + if (filters.curp) { + queryBuilder.andWhere('dh.curp = :curp', { curp: filters.curp }); + } + + if (filters.status) { + queryBuilder.andWhere('dh.status = :status', { status: filters.status }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(dh.first_name ILIKE :search OR dh.last_name ILIKE :search OR dh.nss ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + if (filters.minPoints !== undefined) { + queryBuilder.andWhere('dh.credit_points >= :minPoints', { minPoints: filters.minPoints }); + } + + queryBuilder + .orderBy('dh.last_name', 'ASC') + .addOrderBy('dh.first_name', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.derechohabienteRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + }); + } + + async findByNss(ctx: ServiceContext, nss: string): Promise { + return this.derechohabienteRepository.findOne({ + where: { + nss, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + }); + } + + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.derechohabienteRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + relations: ['createdBy', 'asignaciones', 'asignaciones.oferta', 'historicoPuntos'], + }); + } + + async create(ctx: ServiceContext, dto: CreateDerechohabienteDto): Promise { + // Check for existing NSS or CURP + const existingNss = await this.derechohabienteRepository.findOne({ + where: { nss: dto.nss, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (existingNss) { + throw new Error('A worker with this NSS already exists'); + } + + const existingCurp = await this.derechohabienteRepository.findOne({ + where: { curp: dto.curp, tenantId: ctx.tenantId } as FindOptionsWhere, + }); + if (existingCurp) { + throw new Error('A worker with this CURP already exists'); + } + + const derechohabiente = this.derechohabienteRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + nss: dto.nss, + curp: dto.curp.toUpperCase(), + rfc: dto.rfc?.toUpperCase(), + firstName: dto.firstName, + lastName: dto.lastName, + secondLastName: dto.secondLastName, + phone: dto.phone, + email: dto.email, + address: dto.address, + creditPoints: dto.creditPoints || 0, + notes: dto.notes, + status: 'active', + }); + + const saved = await this.derechohabienteRepository.save(derechohabiente); + + // Record initial points if provided + if (dto.creditPoints && dto.creditPoints > 0) { + const historico = this.historicoPuntosRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + derechohabienteId: saved.id, + recordDate: new Date(), + previousPoints: 0, + newPoints: dto.creditPoints, + pointsChange: dto.creditPoints, + movementType: 'initial', + source: 'Registration', + }); + await this.historicoPuntosRepository.save(historico); + } + + return saved; + } + + async update(ctx: ServiceContext, id: string, dto: UpdateDerechohabienteDto): Promise { + const derechohabiente = await this.findById(ctx, id); + if (!derechohabiente) { + return null; + } + + Object.assign(derechohabiente, { + ...dto, + updatedById: ctx.userId || '', + }); + + return this.derechohabienteRepository.save(derechohabiente); + } + + async updatePoints( + ctx: ServiceContext, + id: string, + newPoints: number, + source: string, + notes?: string + ): Promise { + const derechohabiente = await this.findById(ctx, id); + if (!derechohabiente) { + return null; + } + + const previousPoints = derechohabiente.creditPoints; + + // Record points change + const historico = this.historicoPuntosRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + derechohabienteId: id, + recordDate: new Date(), + previousPoints, + newPoints, + pointsChange: newPoints - previousPoints, + movementType: 'update', + source, + notes, + }); + await this.historicoPuntosRepository.save(historico); + + // Update derechohabiente + derechohabiente.creditPoints = newPoints; + derechohabiente.updatedById = ctx.userId || ''; + + return this.derechohabienteRepository.save(derechohabiente); + } + + async precalify(ctx: ServiceContext, id: string, dto: PrecalificationDto): Promise { + const derechohabiente = await this.findById(ctx, id); + if (!derechohabiente) { + return null; + } + + if (derechohabiente.status !== 'active') { + throw new Error('Only active workers can be precalified'); + } + + const previousPoints = derechohabiente.creditPoints; + + // Record points change from precalification + const historico = this.historicoPuntosRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + derechohabienteId: id, + recordDate: dto.precalificationDate, + previousPoints, + newPoints: dto.creditPoints, + pointsChange: dto.creditPoints - previousPoints, + movementType: 'precalification', + source: 'INFONAVIT Precalification', + }); + await this.historicoPuntosRepository.save(historico); + + // Update derechohabiente + derechohabiente.creditPoints = dto.creditPoints; + derechohabiente.precalificationDate = dto.precalificationDate; + derechohabiente.precalificationAmount = dto.precalificationAmount; + derechohabiente.creditType = dto.creditType; + derechohabiente.status = 'pre_qualified'; + derechohabiente.updatedById = ctx.userId || ''; + + return this.derechohabienteRepository.save(derechohabiente); + } + + async qualify(ctx: ServiceContext, id: string): Promise { + const derechohabiente = await this.findById(ctx, id); + if (!derechohabiente) { + return null; + } + + if (derechohabiente.status !== 'pre_qualified') { + throw new Error('Only pre-qualified workers can be qualified'); + } + + derechohabiente.status = 'qualified'; + derechohabiente.updatedById = ctx.userId || ''; + + return this.derechohabienteRepository.save(derechohabiente); + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const derechohabiente = await this.findById(ctx, id); + if (!derechohabiente) { + return false; + } + + if (derechohabiente.status === 'assigned') { + throw new Error('Cannot delete workers with assigned housing'); + } + + await this.derechohabienteRepository.update( + { id, tenantId: ctx.tenantId } as FindOptionsWhere, + { deletedAt: new Date(), deletedById: ctx.userId || '' } + ); + + return true; + } + + async getPointsHistory(ctx: ServiceContext, derechohabienteId: string): Promise { + return this.historicoPuntosRepository.find({ + where: { + derechohabienteId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + order: { recordDate: 'DESC' }, + relations: ['createdBy'], + }); + } +} diff --git a/src/modules/infonavit/services/index.ts b/src/modules/infonavit/services/index.ts new file mode 100644 index 0000000..eb79b54 --- /dev/null +++ b/src/modules/infonavit/services/index.ts @@ -0,0 +1,7 @@ +/** + * Infonavit Services Index + * @module Infonavit + */ + +export * from './derechohabiente.service'; +export * from './asignacion.service'; diff --git a/src/modules/inventory/controllers/consumo-obra.controller.ts b/src/modules/inventory/controllers/consumo-obra.controller.ts new file mode 100644 index 0000000..91f5394 --- /dev/null +++ b/src/modules/inventory/controllers/consumo-obra.controller.ts @@ -0,0 +1,189 @@ +/** + * ConsumoObraController - Controller de consumos de materiales + * + * Endpoints REST para registro de consumos de materiales por obra. + * + * @module Inventory + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + ConsumoObraService, + CreateConsumoDto, + ConsumoFilters, +} from '../services/consumo-obra.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { ConsumoObra } from '../entities/consumo-obra.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de consumos + */ +export function createConsumoObraController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const consumoRepository = dataSource.getRepository(ConsumoObra); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const consumoService = new ConsumoObraService(consumoRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /consumos + * Listar consumos con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: ConsumoFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + loteId: req.query.loteId as string, + conceptoId: req.query.conceptoId as string, + productId: req.query.productId as string, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + }; + + const result = await consumoService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /consumos/stats/:fraccionamientoId + * Obtener estadísticas de consumos por fraccionamiento + */ + router.get('/stats/:fraccionamientoId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await consumoService.getStats(getContext(req), req.params.fraccionamientoId); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /consumos/:id + * Obtener consumo por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const consumo = await consumoService.findById(getContext(req), req.params.id); + if (!consumo) { + res.status(404).json({ error: 'Not Found', message: 'Consumption record not found' }); + return; + } + + res.status(200).json({ success: true, data: consumo }); + } catch (error) { + next(error); + } + }); + + /** + * POST /consumos + * Registrar consumo de material + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateConsumoDto = req.body; + + if (!dto.fraccionamientoId || !dto.productId || dto.quantity === undefined || !dto.consumptionDate) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId, productId, quantity and consumptionDate are required', + }); + return; + } + + dto.consumptionDate = new Date(dto.consumptionDate); + + const consumo = await consumoService.create(getContext(req), dto); + res.status(201).json({ success: true, data: consumo }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /consumos/:id + * Eliminar registro de consumo (soft delete) + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await consumoService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Consumption record not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Consumption record deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createConsumoObraController; diff --git a/src/modules/inventory/controllers/index.ts b/src/modules/inventory/controllers/index.ts new file mode 100644 index 0000000..90c1a07 --- /dev/null +++ b/src/modules/inventory/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * Inventory Controllers Index + * @module Inventory + */ + +export { createRequisicionController } from './requisicion.controller'; +export { createConsumoObraController } from './consumo-obra.controller'; diff --git a/src/modules/inventory/controllers/requisicion.controller.ts b/src/modules/inventory/controllers/requisicion.controller.ts new file mode 100644 index 0000000..e83d8ed --- /dev/null +++ b/src/modules/inventory/controllers/requisicion.controller.ts @@ -0,0 +1,363 @@ +/** + * RequisicionController - Controller de requisiciones de obra + * + * Endpoints REST para gestión de requisiciones de material. + * Workflow: draft -> submitted -> approved -> partially_served -> served + * + * @module Inventory + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + RequisicionService, + CreateRequisicionDto, + AddLineaDto, + RequisicionFilters, +} from '../services/requisicion.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { RequisicionObra } from '../entities/requisicion-obra.entity'; +import { RequisicionLinea } from '../entities/requisicion-linea.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de requisiciones + */ +export function createRequisicionController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const requisicionRepository = dataSource.getRepository(RequisicionObra); + const lineaRepository = dataSource.getRepository(RequisicionLinea); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const requisicionService = new RequisicionService(requisicionRepository, lineaRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /requisiciones + * Listar requisiciones con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: RequisicionFilters = { + fraccionamientoId: req.query.fraccionamientoId as string, + status: req.query.status as any, + priority: req.query.priority as string, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + }; + + const result = await requisicionService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /requisiciones/:id + * Obtener requisición con detalles + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const requisicion = await requisicionService.findWithDetails(getContext(req), req.params.id); + if (!requisicion) { + res.status(404).json({ error: 'Not Found', message: 'Requisition not found' }); + return; + } + + res.status(200).json({ success: true, data: requisicion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /requisiciones + * Crear requisición + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateRequisicionDto = req.body; + + if (!dto.fraccionamientoId || !dto.requisitionDate || !dto.requiredDate) { + res.status(400).json({ + error: 'Bad Request', + message: 'fraccionamientoId, requisitionDate and requiredDate are required', + }); + return; + } + + dto.requisitionDate = new Date(dto.requisitionDate); + dto.requiredDate = new Date(dto.requiredDate); + + const requisicion = await requisicionService.create(getContext(req), dto); + res.status(201).json({ success: true, data: requisicion }); + } catch (error) { + next(error); + } + }); + + /** + * POST /requisiciones/:id/lineas + * Agregar línea a requisición + */ + router.post('/:id/lineas', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: AddLineaDto = req.body; + + if (!dto.productId || dto.quantityRequested === undefined) { + res.status(400).json({ + error: 'Bad Request', + message: 'productId and quantityRequested are required', + }); + return; + } + + const linea = await requisicionService.addLinea(getContext(req), req.params.id, dto); + res.status(201).json({ success: true, data: linea }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Requisicion not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message.includes('draft')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * DELETE /requisiciones/:id/lineas/:lineaId + * Eliminar línea de requisición + */ + router.delete('/:id/lineas/:lineaId', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const removed = await requisicionService.removeLinea(getContext(req), req.params.id, req.params.lineaId); + if (!removed) { + res.status(404).json({ error: 'Not Found', message: 'Line not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Line removed' }); + } catch (error) { + if (error instanceof Error && error.message.includes('draft')) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /requisiciones/:id/submit + * Enviar requisición para aprobación + */ + router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const requisicion = await requisicionService.submit(getContext(req), req.params.id); + if (!requisicion) { + res.status(404).json({ error: 'Not Found', message: 'Requisition not found' }); + return; + } + + res.status(200).json({ success: true, data: requisicion, message: 'Requisition submitted' }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /requisiciones/:id/approve + * Aprobar requisición + */ + router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const requisicion = await requisicionService.approve(getContext(req), req.params.id); + if (!requisicion) { + res.status(404).json({ error: 'Not Found', message: 'Requisition not found' }); + return; + } + + res.status(200).json({ success: true, data: requisicion, message: 'Requisition approved' }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /requisiciones/:id/reject + * Rechazar requisición + */ + router.post('/:id/reject', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { reason } = req.body; + if (!reason) { + res.status(400).json({ error: 'Bad Request', message: 'reason is required' }); + return; + } + + const requisicion = await requisicionService.reject(getContext(req), req.params.id, reason); + if (!requisicion) { + res.status(404).json({ error: 'Not Found', message: 'Requisition not found' }); + return; + } + + res.status(200).json({ success: true, data: requisicion, message: 'Requisition rejected' }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /requisiciones/:id/cancel + * Cancelar requisición + */ + router.post('/:id/cancel', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const requisicion = await requisicionService.cancel(getContext(req), req.params.id); + if (!requisicion) { + res.status(404).json({ error: 'Not Found', message: 'Requisition not found' }); + return; + } + + res.status(200).json({ success: true, data: requisicion, message: 'Requisition cancelled' }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * DELETE /requisiciones/:id + * Eliminar requisición (soft delete) + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await requisicionService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Requisition not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Requisition deleted' }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + return router; +} + +export default createRequisicionController; diff --git a/src/modules/inventory/entities/almacen-proyecto.entity.ts b/src/modules/inventory/entities/almacen-proyecto.entity.ts new file mode 100644 index 0000000..2184976 --- /dev/null +++ b/src/modules/inventory/entities/almacen-proyecto.entity.ts @@ -0,0 +1,92 @@ +/** + * AlmacenProyecto Entity + * Almacenes por proyecto/obra + * + * @module Inventory + * @table inventory.almacenes_proyecto + * @ddl schemas/06-inventory-ext-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +export type WarehouseTypeConstruction = 'central' | 'obra' | 'temporal' | 'transito'; + +@Entity({ schema: 'inventory', name: 'almacenes_proyecto' }) +@Index(['warehouseId'], { unique: true }) +export class AlmacenProyecto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ + name: 'warehouse_type', + type: 'varchar', + length: 20, + default: 'obra', + }) + warehouseType: WarehouseTypeConstruction; + + @Column({ name: 'location_description', type: 'text', nullable: true }) + locationDescription: string; + + @Column({ name: 'responsible_id', type: 'uuid', nullable: true }) + responsibleId: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => User) + @JoinColumn({ name: 'responsible_id' }) + responsible: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/inventory/entities/consumo-obra.entity.ts b/src/modules/inventory/entities/consumo-obra.entity.ts new file mode 100644 index 0000000..8d257f1 --- /dev/null +++ b/src/modules/inventory/entities/consumo-obra.entity.ts @@ -0,0 +1,113 @@ +/** + * ConsumoObra Entity + * Consumos de materiales por obra/lote + * + * @module Inventory + * @table inventory.consumos_obra + * @ddl schemas/06-inventory-ext-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { Lote } from '../../construction/entities/lote.entity'; +import { Concepto } from '../../budgets/entities/concepto.entity'; + +@Entity({ schema: 'inventory', name: 'consumos_obra' }) +export class ConsumoObra { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'stock_move_id', type: 'uuid', nullable: true }) + stockMoveId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'lote_id', type: 'uuid', nullable: true }) + loteId: string; + + @Column({ name: 'departamento_id', type: 'uuid', nullable: true }) + departamentoId: string; + + @Column({ name: 'concepto_id', type: 'uuid', nullable: true }) + conceptoId: string; + + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + quantity: number; + + @Column({ name: 'unit_cost', type: 'decimal', precision: 12, scale: 4, nullable: true }) + unitCost: number; + + @Column({ name: 'consumption_date', type: 'date' }) + consumptionDate: Date; + + @Column({ name: 'registered_by', type: 'uuid' }) + registeredById: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Computed property (in DB is GENERATED ALWAYS AS) + get totalCost(): number { + return this.quantity * (this.unitCost || 0); + } + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => Lote) + @JoinColumn({ name: 'lote_id' }) + lote: Lote; + + @ManyToOne(() => Concepto) + @JoinColumn({ name: 'concepto_id' }) + concepto: Concepto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'registered_by' }) + registeredBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/inventory/entities/index.ts b/src/modules/inventory/entities/index.ts new file mode 100644 index 0000000..818b043 --- /dev/null +++ b/src/modules/inventory/entities/index.ts @@ -0,0 +1,11 @@ +/** + * Inventory Entities Index + * @module Inventory + * + * Extensiones de inventario para construcción (MAI-004) + */ + +export * from './almacen-proyecto.entity'; +export * from './requisicion-obra.entity'; +export * from './requisicion-linea.entity'; +export * from './consumo-obra.entity'; diff --git a/src/modules/inventory/entities/requisicion-linea.entity.ts b/src/modules/inventory/entities/requisicion-linea.entity.ts new file mode 100644 index 0000000..126826c --- /dev/null +++ b/src/modules/inventory/entities/requisicion-linea.entity.ts @@ -0,0 +1,115 @@ +/** + * RequisicionLinea Entity + * Líneas de requisición de obra + * + * @module Inventory + * @table inventory.requisicion_lineas + * @ddl schemas/06-inventory-ext-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { RequisicionObra } from './requisicion-obra.entity'; +import { Concepto } from '../../budgets/entities/concepto.entity'; +import { Lote } from '../../construction/entities/lote.entity'; + +@Entity({ schema: 'inventory', name: 'requisicion_lineas' }) +export class RequisicionLinea { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'requisicion_id', type: 'uuid' }) + requisicionId: string; + + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'concepto_id', type: 'uuid', nullable: true }) + conceptoId: string; + + @Column({ name: 'lote_id', type: 'uuid', nullable: true }) + loteId: string; + + @Column({ + name: 'quantity_requested', + type: 'decimal', + precision: 12, + scale: 4, + }) + quantityRequested: number; + + @Column({ + name: 'quantity_approved', + type: 'decimal', + precision: 12, + scale: 4, + nullable: true, + }) + quantityApproved: number; + + @Column({ + name: 'quantity_served', + type: 'decimal', + precision: 12, + scale: 4, + default: 0, + }) + quantityServed: number; + + @Column({ name: 'unit_id', type: 'uuid', nullable: true }) + unitId: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => RequisicionObra, (req) => req.lineas, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'requisicion_id' }) + requisicion: RequisicionObra; + + @ManyToOne(() => Concepto) + @JoinColumn({ name: 'concepto_id' }) + concepto: Concepto; + + @ManyToOne(() => Lote) + @JoinColumn({ name: 'lote_id' }) + lote: Lote; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/inventory/entities/requisicion-obra.entity.ts b/src/modules/inventory/entities/requisicion-obra.entity.ts new file mode 100644 index 0000000..c939fd7 --- /dev/null +++ b/src/modules/inventory/entities/requisicion-obra.entity.ts @@ -0,0 +1,117 @@ +/** + * RequisicionObra Entity + * Requisiciones de material desde obra + * + * @module Inventory + * @table inventory.requisiciones_obra + * @ddl schemas/06-inventory-ext-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { RequisicionLinea } from './requisicion-linea.entity'; + +export type RequisitionStatus = 'draft' | 'submitted' | 'approved' | 'partially_served' | 'served' | 'cancelled'; + +@Entity({ schema: 'inventory', name: 'requisiciones_obra' }) +@Index(['tenantId', 'requisitionNumber'], { unique: true }) +export class RequisicionObra { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'requisition_number', type: 'varchar', length: 30 }) + requisitionNumber: string; + + @Column({ name: 'requisition_date', type: 'date' }) + requisitionDate: Date; + + @Column({ name: 'required_date', type: 'date' }) + requiredDate: Date; + + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: RequisitionStatus; + + @Column({ type: 'varchar', length: 20, default: 'medium' }) + priority: string; + + @Column({ name: 'requested_by', type: 'uuid' }) + requestedById: string; + + @Column({ name: 'destination_warehouse_id', type: 'uuid', nullable: true }) + destinationWarehouseId: string; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason: string; + + @Column({ name: 'purchase_order_id', type: 'uuid', nullable: true }) + purchaseOrderId: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => User) + @JoinColumn({ name: 'requested_by' }) + requestedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => RequisicionLinea, (linea) => linea.requisicion) + lineas: RequisicionLinea[]; +} diff --git a/src/modules/inventory/services/consumo-obra.service.ts b/src/modules/inventory/services/consumo-obra.service.ts new file mode 100644 index 0000000..ca1aedc --- /dev/null +++ b/src/modules/inventory/services/consumo-obra.service.ts @@ -0,0 +1,200 @@ +/** + * ConsumoObraService - Servicio de consumos de materiales + * + * Gestión de consumos de materiales por obra/lote. + * + * @module Inventory + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { ConsumoObra } from '../entities/consumo-obra.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateConsumoDto { + fraccionamientoId: string; + loteId?: string; + departamentoId?: string; + conceptoId?: string; + productId: string; + quantity: number; + unitCost?: number; + consumptionDate: Date; + notes?: string; +} + +export interface ConsumoFilters { + fraccionamientoId?: string; + loteId?: string; + conceptoId?: string; + productId?: string; + dateFrom?: Date; + dateTo?: Date; +} + +export interface ConsumoStats { + totalConsumos: number; + totalQuantity: number; + totalCost: number; + byProduct: { productId: string; quantity: number; cost: number }[]; + byConcepto: { conceptoId: string; quantity: number; cost: number }[]; +} + +export class ConsumoObraService { + constructor(private readonly repository: Repository) {} + + async findWithFilters( + ctx: ServiceContext, + filters: ConsumoFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.repository + .createQueryBuilder('consumo') + .leftJoinAndSelect('consumo.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('consumo.lote', 'lote') + .leftJoinAndSelect('consumo.concepto', 'concepto') + .leftJoinAndSelect('consumo.registeredBy', 'registeredBy') + .where('consumo.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('consumo.deleted_at IS NULL'); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('consumo.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.loteId) { + queryBuilder.andWhere('consumo.lote_id = :loteId', { loteId: filters.loteId }); + } + + if (filters.conceptoId) { + queryBuilder.andWhere('consumo.concepto_id = :conceptoId', { conceptoId: filters.conceptoId }); + } + + if (filters.productId) { + queryBuilder.andWhere('consumo.product_id = :productId', { productId: filters.productId }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('consumo.consumption_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('consumo.consumption_date <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('consumo.consumption_date', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + relations: ['fraccionamiento', 'lote', 'concepto', 'registeredBy'], + }); + } + + async create(ctx: ServiceContext, dto: CreateConsumoDto): Promise { + const consumo = this.repository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + registeredById: ctx.userId, + fraccionamientoId: dto.fraccionamientoId, + loteId: dto.loteId, + departamentoId: dto.departamentoId, + conceptoId: dto.conceptoId, + productId: dto.productId, + quantity: dto.quantity, + unitCost: dto.unitCost, + consumptionDate: dto.consumptionDate, + notes: dto.notes, + }); + + return this.repository.save(consumo); + } + + async getStats(ctx: ServiceContext, fraccionamientoId: string): Promise { + const baseQuery = this.repository + .createQueryBuilder('consumo') + .where('consumo.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('consumo.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) + .andWhere('consumo.deleted_at IS NULL'); + + // Get totals + const totals = await baseQuery + .clone() + .select('COUNT(*)', 'total') + .addSelect('SUM(consumo.quantity)', 'totalQuantity') + .addSelect('SUM(consumo.quantity * COALESCE(consumo.unit_cost, 0))', 'totalCost') + .getRawOne(); + + // Get by product + const byProduct = await baseQuery + .clone() + .select('consumo.product_id', 'productId') + .addSelect('SUM(consumo.quantity)', 'quantity') + .addSelect('SUM(consumo.quantity * COALESCE(consumo.unit_cost, 0))', 'cost') + .groupBy('consumo.product_id') + .getRawMany(); + + // Get by concepto + const byConcepto = await baseQuery + .clone() + .select('consumo.concepto_id', 'conceptoId') + .addSelect('SUM(consumo.quantity)', 'quantity') + .addSelect('SUM(consumo.quantity * COALESCE(consumo.unit_cost, 0))', 'cost') + .where('consumo.concepto_id IS NOT NULL') + .groupBy('consumo.concepto_id') + .getRawMany(); + + return { + totalConsumos: parseInt(totals.total || '0', 10), + totalQuantity: parseFloat(totals.totalQuantity || '0'), + totalCost: parseFloat(totals.totalCost || '0'), + byProduct: byProduct.map((p) => ({ + productId: p.productId, + quantity: parseFloat(p.quantity || '0'), + cost: parseFloat(p.cost || '0'), + })), + byConcepto: byConcepto.map((c) => ({ + conceptoId: c.conceptoId, + quantity: parseFloat(c.quantity || '0'), + cost: parseFloat(c.cost || '0'), + })), + }; + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const consumo = await this.findById(ctx, id); + if (!consumo) { + return false; + } + + await this.repository.update( + { id, tenantId: ctx.tenantId } as unknown as FindOptionsWhere, + { deletedAt: new Date(), deletedById: ctx.userId } + ); + + return true; + } +} diff --git a/src/modules/inventory/services/index.ts b/src/modules/inventory/services/index.ts new file mode 100644 index 0000000..af14d2d --- /dev/null +++ b/src/modules/inventory/services/index.ts @@ -0,0 +1,7 @@ +/** + * Inventory Services Index + * @module Inventory + */ + +export * from './requisicion.service'; +export * from './consumo-obra.service'; diff --git a/src/modules/inventory/services/requisicion.service.ts b/src/modules/inventory/services/requisicion.service.ts new file mode 100644 index 0000000..bc8a312 --- /dev/null +++ b/src/modules/inventory/services/requisicion.service.ts @@ -0,0 +1,339 @@ +/** + * RequisicionService - Servicio de requisiciones de obra + * + * Gestión de requisiciones de material con workflow: + * draft -> submitted -> approved -> partially_served -> served + * + * @module Inventory + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { RequisicionObra, RequisitionStatus } from '../entities/requisicion-obra.entity'; +import { RequisicionLinea } from '../entities/requisicion-linea.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateRequisicionDto { + fraccionamientoId: string; + requisitionDate: Date; + requiredDate: Date; + priority?: string; + destinationWarehouseId?: string; + notes?: string; +} + +export interface AddLineaDto { + productId: string; + conceptoId?: string; + loteId?: string; + quantityRequested: number; + unitId?: string; + notes?: string; +} + +export interface RequisicionFilters { + fraccionamientoId?: string; + status?: RequisitionStatus; + priority?: string; + dateFrom?: Date; + dateTo?: Date; +} + +export class RequisicionService { + constructor( + private readonly requisicionRepository: Repository, + private readonly lineaRepository: Repository + ) {} + + private generateNumber(): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `REQ-${year}${month}-${random}`; + } + + async findWithFilters( + ctx: ServiceContext, + filters: RequisicionFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.requisicionRepository + .createQueryBuilder('req') + .leftJoinAndSelect('req.fraccionamiento', 'fraccionamiento') + .leftJoinAndSelect('req.requestedBy', 'requestedBy') + .where('req.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('req.deleted_at IS NULL'); + + if (filters.fraccionamientoId) { + queryBuilder.andWhere('req.fraccionamiento_id = :fraccionamientoId', { + fraccionamientoId: filters.fraccionamientoId, + }); + } + + if (filters.status) { + queryBuilder.andWhere('req.status = :status', { status: filters.status }); + } + + if (filters.priority) { + queryBuilder.andWhere('req.priority = :priority', { priority: filters.priority }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('req.requisition_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('req.requisition_date <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('req.requisition_date', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.requisicionRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + }); + } + + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.requisicionRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + relations: [ + 'fraccionamiento', + 'requestedBy', + 'approvedBy', + 'lineas', + 'lineas.concepto', + 'lineas.lote', + ], + }); + } + + async create(ctx: ServiceContext, dto: CreateRequisicionDto): Promise { + if (!ctx.userId) { + throw new Error('User ID is required to create a requisition'); + } + + const requisicion = this.requisicionRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + requestedById: ctx.userId, + requisitionNumber: this.generateNumber(), + fraccionamientoId: dto.fraccionamientoId, + requisitionDate: dto.requisitionDate, + requiredDate: dto.requiredDate, + priority: dto.priority || 'medium', + destinationWarehouseId: dto.destinationWarehouseId, + notes: dto.notes, + status: 'draft', + }); + + return this.requisicionRepository.save(requisicion); + } + + async addLinea(ctx: ServiceContext, requisicionId: string, dto: AddLineaDto): Promise { + const requisicion = await this.findById(ctx, requisicionId); + if (!requisicion) { + throw new Error('Requisicion not found'); + } + + if (requisicion.status !== 'draft') { + throw new Error('Can only add lines to draft requisitions'); + } + + const linea = this.lineaRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + requisicionId, + productId: dto.productId, + conceptoId: dto.conceptoId, + loteId: dto.loteId, + quantityRequested: dto.quantityRequested, + unitId: dto.unitId, + notes: dto.notes, + }); + + return this.lineaRepository.save(linea); + } + + async removeLinea(ctx: ServiceContext, requisicionId: string, lineaId: string): Promise { + const requisicion = await this.findById(ctx, requisicionId); + if (!requisicion) { + return false; + } + + if (requisicion.status !== 'draft') { + throw new Error('Can only remove lines from draft requisitions'); + } + + const result = await this.lineaRepository.delete({ + id: lineaId, + requisicionId, + tenantId: ctx.tenantId, + } as FindOptionsWhere); + + return (result.affected ?? 0) > 0; + } + + async submit(ctx: ServiceContext, id: string): Promise { + const requisicion = await this.findWithDetails(ctx, id); + if (!requisicion) { + return null; + } + + if (requisicion.status !== 'draft') { + throw new Error('Can only submit draft requisitions'); + } + + if (!requisicion.lineas || requisicion.lineas.length === 0) { + throw new Error('Cannot submit requisition without lines'); + } + + requisicion.status = 'submitted'; + return this.requisicionRepository.save(requisicion); + } + + async approve(ctx: ServiceContext, id: string): Promise { + const requisicion = await this.findWithDetails(ctx, id); + if (!requisicion) { + return null; + } + + if (requisicion.status !== 'submitted') { + throw new Error('Can only approve submitted requisitions'); + } + + requisicion.status = 'approved'; + requisicion.approvedById = ctx.userId || ''; + requisicion.approvedAt = new Date(); + + // Set approved quantities to requested quantities by default + for (const linea of requisicion.lineas) { + linea.quantityApproved = linea.quantityRequested; + await this.lineaRepository.save(linea); + } + + return this.requisicionRepository.save(requisicion); + } + + async reject(ctx: ServiceContext, id: string, reason: string): Promise { + const requisicion = await this.findById(ctx, id); + if (!requisicion) { + return null; + } + + if (requisicion.status !== 'submitted') { + throw new Error('Can only reject submitted requisitions'); + } + + requisicion.status = 'cancelled'; + requisicion.rejectionReason = reason; + return this.requisicionRepository.save(requisicion); + } + + async cancel(ctx: ServiceContext, id: string): Promise { + const requisicion = await this.findById(ctx, id); + if (!requisicion) { + return null; + } + + if (requisicion.status === 'served' || requisicion.status === 'cancelled') { + throw new Error('Cannot cancel served or already cancelled requisitions'); + } + + requisicion.status = 'cancelled'; + return this.requisicionRepository.save(requisicion); + } + + async updateServedQuantity( + ctx: ServiceContext, + lineaId: string, + quantityServed: number + ): Promise { + const linea = await this.lineaRepository.findOne({ + where: { id: lineaId, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['requisicion'], + }); + + if (!linea || !linea.requisicion) { + return null; + } + + if (linea.requisicion.status !== 'approved' && linea.requisicion.status !== 'partially_served') { + throw new Error('Can only serve approved or partially served requisitions'); + } + + linea.quantityServed = quantityServed; + await this.lineaRepository.save(linea); + + // Update requisition status based on all lines + await this.updateRequisitionStatus(ctx, linea.requisicion.id); + + return linea; + } + + private async updateRequisitionStatus(ctx: ServiceContext, requisicionId: string): Promise { + const requisicion = await this.findWithDetails(ctx, requisicionId); + if (!requisicion || !requisicion.lineas) { + return; + } + + const allServed = requisicion.lineas.every( + (l) => l.quantityServed >= (l.quantityApproved || l.quantityRequested) + ); + + const someServed = requisicion.lineas.some((l) => l.quantityServed > 0); + + if (allServed) { + requisicion.status = 'served'; + } else if (someServed) { + requisicion.status = 'partially_served'; + } + + await this.requisicionRepository.save(requisicion); + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const requisicion = await this.findById(ctx, id); + if (!requisicion) { + return false; + } + + if (requisicion.status !== 'draft' && requisicion.status !== 'cancelled') { + throw new Error('Can only delete draft or cancelled requisitions'); + } + + await this.requisicionRepository.update( + { id, tenantId: ctx.tenantId } as unknown as FindOptionsWhere, + { deletedAt: new Date(), deletedById: ctx.userId } + ); + + return true; + } +} diff --git a/src/modules/progress/controllers/avance-obra.controller.ts b/src/modules/progress/controllers/avance-obra.controller.ts new file mode 100644 index 0000000..602b8b8 --- /dev/null +++ b/src/modules/progress/controllers/avance-obra.controller.ts @@ -0,0 +1,303 @@ +/** + * AvanceObraController - Controller de avances de obra + * + * Endpoints REST para gestión de avances físicos de obra. + * Incluye workflow de captura -> revisión -> aprobación. + * + * @module Progress + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AvanceObraService, CreateAvanceDto, AddFotoDto, AvanceFilters } from '../services/avance-obra.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { AvanceObra } from '../entities/avance-obra.entity'; +import { FotoAvance } from '../entities/foto-avance.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de avances de obra + */ +export function createAvanceObraController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const avanceRepository = dataSource.getRepository(AvanceObra); + const fotoRepository = dataSource.getRepository(FotoAvance); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const avanceService = new AvanceObraService(avanceRepository, fotoRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /avances + * Listar avances con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: AvanceFilters = { + loteId: req.query.loteId as string, + departamentoId: req.query.departamentoId as string, + conceptoId: req.query.conceptoId as string, + status: req.query.status as any, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + }; + + const result = await avanceService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /avances/accumulated + * Obtener avance acumulado por concepto + */ + router.get('/accumulated', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const loteId = req.query.loteId as string; + const departamentoId = req.query.departamentoId as string; + + const progress = await avanceService.getAccumulatedProgress(getContext(req), loteId, departamentoId); + res.status(200).json({ success: true, data: progress }); + } catch (error) { + next(error); + } + }); + + /** + * GET /avances/:id + * Obtener avance por ID con fotos + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const avance = await avanceService.findWithFotos(getContext(req), req.params.id); + if (!avance) { + res.status(404).json({ error: 'Not Found', message: 'Progress record not found' }); + return; + } + + res.status(200).json({ success: true, data: avance }); + } catch (error) { + next(error); + } + }); + + /** + * POST /avances + * Crear avance (captura) + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateAvanceDto = req.body; + + if (!dto.conceptoId) { + res.status(400).json({ error: 'Bad Request', message: 'conceptoId is required' }); + return; + } + + if (!dto.loteId && !dto.departamentoId) { + res.status(400).json({ error: 'Bad Request', message: 'Either loteId or departamentoId is required' }); + return; + } + + const avance = await avanceService.createAvance(getContext(req), dto); + res.status(201).json({ success: true, data: avance }); + } catch (error) { + if (error instanceof Error) { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /avances/:id/fotos + * Agregar foto al avance + */ + router.post('/:id/fotos', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: AddFotoDto = req.body; + + if (!dto.fileUrl) { + res.status(400).json({ error: 'Bad Request', message: 'fileUrl is required' }); + return; + } + + const foto = await avanceService.addFoto(getContext(req), req.params.id, dto); + res.status(201).json({ success: true, data: foto }); + } catch (error) { + if (error instanceof Error && error.message === 'Avance not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /avances/:id/review + * Revisar avance + */ + router.post('/:id/review', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const avance = await avanceService.review(getContext(req), req.params.id); + if (!avance) { + res.status(400).json({ error: 'Bad Request', message: 'Cannot review this progress record. It may not exist or is not in captured status.' }); + return; + } + + res.status(200).json({ success: true, data: avance, message: 'Progress reviewed' }); + } catch (error) { + next(error); + } + }); + + /** + * POST /avances/:id/approve + * Aprobar avance + */ + router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const avance = await avanceService.approve(getContext(req), req.params.id); + if (!avance) { + res.status(400).json({ error: 'Bad Request', message: 'Cannot approve this progress record. It may not exist or is not in reviewed status.' }); + return; + } + + res.status(200).json({ success: true, data: avance, message: 'Progress approved' }); + } catch (error) { + next(error); + } + }); + + /** + * POST /avances/:id/reject + * Rechazar avance + */ + router.post('/:id/reject', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { reason } = req.body; + if (!reason) { + res.status(400).json({ error: 'Bad Request', message: 'reason is required' }); + return; + } + + const avance = await avanceService.reject(getContext(req), req.params.id, reason); + if (!avance) { + res.status(400).json({ error: 'Bad Request', message: 'Cannot reject this progress record.' }); + return; + } + + res.status(200).json({ success: true, data: avance, message: 'Progress rejected' }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /avances/:id + * Eliminar avance (soft delete) + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await avanceService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Progress record not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Progress record deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createAvanceObraController; diff --git a/src/modules/progress/controllers/bitacora-obra.controller.ts b/src/modules/progress/controllers/bitacora-obra.controller.ts new file mode 100644 index 0000000..520e4f5 --- /dev/null +++ b/src/modules/progress/controllers/bitacora-obra.controller.ts @@ -0,0 +1,245 @@ +/** + * BitacoraObraController - Controller de bitácora de obra + * + * Endpoints REST para gestión de bitácora diaria de obra. + * + * @module Progress + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { BitacoraObraService, CreateBitacoraDto, UpdateBitacoraDto, BitacoraFilters } from '../services/bitacora-obra.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { BitacoraObra } from '../entities/bitacora-obra.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +/** + * Crear router de bitácora de obra + */ +export function createBitacoraObraController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const bitacoraRepository = dataSource.getRepository(BitacoraObra); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const bitacoraService = new BitacoraObraService(bitacoraRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto de servicio + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /bitacora + * Listar entradas de bitácora por fraccionamiento + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string; + if (!fraccionamientoId) { + res.status(400).json({ error: 'Bad Request', message: 'fraccionamientoId is required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: BitacoraFilters = { + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + hasIncidents: req.query.hasIncidents === 'true' ? true : req.query.hasIncidents === 'false' ? false : undefined, + }; + + const result = await bitacoraService.findWithFilters(getContext(req), fraccionamientoId, filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bitacora/stats + * Obtener estadísticas de bitácora + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string; + if (!fraccionamientoId) { + res.status(400).json({ error: 'Bad Request', message: 'fraccionamientoId is required' }); + return; + } + + const stats = await bitacoraService.getStats(getContext(req), fraccionamientoId); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bitacora/latest + * Obtener última entrada de bitácora + */ + router.get('/latest', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string; + if (!fraccionamientoId) { + res.status(400).json({ error: 'Bad Request', message: 'fraccionamientoId is required' }); + return; + } + + const entry = await bitacoraService.findLatest(getContext(req), fraccionamientoId); + if (!entry) { + res.status(404).json({ error: 'Not Found', message: 'No log entries found' }); + return; + } + + res.status(200).json({ success: true, data: entry }); + } catch (error) { + next(error); + } + }); + + /** + * GET /bitacora/:id + * Obtener entrada por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const entry = await bitacoraService.findById(getContext(req), req.params.id); + if (!entry) { + res.status(404).json({ error: 'Not Found', message: 'Log entry not found' }); + return; + } + + res.status(200).json({ success: true, data: entry }); + } catch (error) { + next(error); + } + }); + + /** + * POST /bitacora + * Crear entrada de bitácora + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateBitacoraDto = req.body; + + if (!dto.fraccionamientoId || !dto.entryDate || !dto.description) { + res.status(400).json({ error: 'Bad Request', message: 'fraccionamientoId, entryDate and description are required' }); + return; + } + + const entry = await bitacoraService.createEntry(getContext(req), dto); + res.status(201).json({ success: true, data: entry }); + } catch (error) { + next(error); + } + }); + + /** + * PATCH /bitacora/:id + * Actualizar entrada de bitácora + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'engineer', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateBitacoraDto = req.body; + const entry = await bitacoraService.update(getContext(req), req.params.id, dto); + + if (!entry) { + res.status(404).json({ error: 'Not Found', message: 'Log entry not found' }); + return; + } + + res.status(200).json({ success: true, data: entry }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /bitacora/:id + * Eliminar entrada de bitácora (soft delete) + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await bitacoraService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Log entry not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Log entry deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createBitacoraObraController; diff --git a/src/modules/progress/controllers/index.ts b/src/modules/progress/controllers/index.ts new file mode 100644 index 0000000..66326c1 --- /dev/null +++ b/src/modules/progress/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * Progress Controllers Index + * @module Progress + */ + +export { createAvanceObraController } from './avance-obra.controller'; +export { createBitacoraObraController } from './bitacora-obra.controller'; diff --git a/src/modules/progress/entities/avance-obra.entity.ts b/src/modules/progress/entities/avance-obra.entity.ts new file mode 100644 index 0000000..3e112a9 --- /dev/null +++ b/src/modules/progress/entities/avance-obra.entity.ts @@ -0,0 +1,127 @@ +/** + * AvanceObra Entity + * Registro de avances fisicos de obra por lote/departamento + * + * @module Progress + * @table construction.avances_obra + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, + Check, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Concepto } from '../../budgets/entities/concepto.entity'; +import { FotoAvance } from './foto-avance.entity'; + +export type AdvanceStatus = 'pending' | 'captured' | 'reviewed' | 'approved' | 'rejected'; + +@Entity({ schema: 'construction', name: 'avances_obra' }) +@Index(['tenantId']) +@Index(['loteId']) +@Index(['conceptoId']) +@Index(['captureDate']) +@Check(`"lote_id" IS NOT NULL AND "departamento_id" IS NULL OR "lote_id" IS NULL AND "departamento_id" IS NOT NULL`) +export class AvanceObra { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'lote_id', type: 'uuid', nullable: true }) + loteId: string | null; + + @Column({ name: 'departamento_id', type: 'uuid', nullable: true }) + departamentoId: string | null; + + @Column({ name: 'concepto_id', type: 'uuid' }) + conceptoId: string; + + @Column({ name: 'capture_date', type: 'date' }) + captureDate: Date; + + @Column({ name: 'quantity_executed', type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantityExecuted: number; + + @Column({ name: 'percentage_executed', type: 'decimal', precision: 5, scale: 2, default: 0 }) + percentageExecuted: number; + + @Column({ + type: 'enum', + enum: ['pending', 'captured', 'reviewed', 'approved', 'rejected'], + enumName: 'construction.advance_status', + default: 'pending', + }) + status: AdvanceStatus; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ name: 'captured_by', type: 'uuid' }) + capturedById: string; + + @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) + reviewedById: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt: Date | null; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Concepto) + @JoinColumn({ name: 'concepto_id' }) + concepto: Concepto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'captured_by' }) + capturedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'reviewed_by' }) + reviewedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User | null; + + @OneToMany(() => FotoAvance, (f) => f.avance) + fotos: FotoAvance[]; +} diff --git a/src/modules/progress/entities/bitacora-obra.entity.ts b/src/modules/progress/entities/bitacora-obra.entity.ts new file mode 100644 index 0000000..5dae0f8 --- /dev/null +++ b/src/modules/progress/entities/bitacora-obra.entity.ts @@ -0,0 +1,102 @@ +/** + * BitacoraObra Entity + * Registro diario de bitacora de obra + * + * @module Progress + * @table construction.bitacora_obra + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +@Entity({ schema: 'construction', name: 'bitacora_obra' }) +@Index(['fraccionamientoId', 'entryNumber'], { unique: true }) +@Index(['tenantId']) +@Index(['fraccionamientoId']) +export class BitacoraObra { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'entry_date', type: 'date' }) + entryDate: Date; + + @Column({ name: 'entry_number', type: 'integer' }) + entryNumber: number; + + @Column({ type: 'varchar', length: 50, nullable: true }) + weather: string | null; + + @Column({ name: 'temperature_max', type: 'decimal', precision: 4, scale: 1, nullable: true }) + temperatureMax: number | null; + + @Column({ name: 'temperature_min', type: 'decimal', precision: 4, scale: 1, nullable: true }) + temperatureMin: number | null; + + @Column({ name: 'workers_count', type: 'integer', default: 0 }) + workersCount: number; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'text', nullable: true }) + observations: string | null; + + @Column({ type: 'text', nullable: true }) + incidents: string | null; + + @Column({ name: 'registered_by', type: 'uuid' }) + registeredById: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => User) + @JoinColumn({ name: 'registered_by' }) + registeredBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/src/modules/progress/entities/foto-avance.entity.ts b/src/modules/progress/entities/foto-avance.entity.ts new file mode 100644 index 0000000..652a0d3 --- /dev/null +++ b/src/modules/progress/entities/foto-avance.entity.ts @@ -0,0 +1,87 @@ +/** + * FotoAvance Entity + * Evidencia fotografica de avances de obra + * + * @module Progress + * @table construction.fotos_avance + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { AvanceObra } from './avance-obra.entity'; + +@Entity({ schema: 'construction', name: 'fotos_avance' }) +@Index(['tenantId']) +@Index(['avanceId']) +export class FotoAvance { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'avance_id', type: 'uuid' }) + avanceId: string; + + @Column({ name: 'file_url', type: 'varchar', length: 500 }) + fileUrl: string; + + @Column({ name: 'file_name', type: 'varchar', length: 255, nullable: true }) + fileName: string | null; + + @Column({ name: 'file_size', type: 'integer', nullable: true }) + fileSize: number | null; + + @Column({ name: 'mime_type', type: 'varchar', length: 50, nullable: true }) + mimeType: string | null; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + // PostGIS Point para ubicacion GPS + @Column({ + type: 'geometry', + spatialFeatureType: 'Point', + srid: 4326, + nullable: true, + }) + location: string | null; + + @Column({ name: 'captured_at', type: 'timestamptz', default: () => 'NOW()' }) + capturedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => AvanceObra, (a) => a.fotos) + @JoinColumn({ name: 'avance_id' }) + avance: AvanceObra; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/src/modules/progress/entities/index.ts b/src/modules/progress/entities/index.ts new file mode 100644 index 0000000..d7cc1a8 --- /dev/null +++ b/src/modules/progress/entities/index.ts @@ -0,0 +1,10 @@ +/** + * Progress Module - Entity Exports + * MAI-005: Control de Obra + */ + +export * from './avance-obra.entity'; +export * from './foto-avance.entity'; +export * from './bitacora-obra.entity'; +export * from './programa-obra.entity'; +export * from './programa-actividad.entity'; diff --git a/src/modules/progress/entities/programa-actividad.entity.ts b/src/modules/progress/entities/programa-actividad.entity.ts new file mode 100644 index 0000000..7973d18 --- /dev/null +++ b/src/modules/progress/entities/programa-actividad.entity.ts @@ -0,0 +1,107 @@ +/** + * ProgramaActividad Entity + * Actividades del programa de obra (WBS) + * + * @module Progress + * @table construction.programa_actividades + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Concepto } from '../../budgets/entities/concepto.entity'; +import { ProgramaObra } from './programa-obra.entity'; + +@Entity({ schema: 'construction', name: 'programa_actividades' }) +@Index(['tenantId']) +@Index(['programaId']) +export class ProgramaActividad { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'programa_id', type: 'uuid' }) + programaId: string; + + @Column({ name: 'concepto_id', type: 'uuid', nullable: true }) + conceptoId: string | null; + + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string | null; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'integer', default: 0 }) + sequence: number; + + @Column({ name: 'planned_start', type: 'date', nullable: true }) + plannedStart: Date | null; + + @Column({ name: 'planned_end', type: 'date', nullable: true }) + plannedEnd: Date | null; + + @Column({ name: 'planned_quantity', type: 'decimal', precision: 12, scale: 4, default: 0 }) + plannedQuantity: number; + + @Column({ name: 'planned_weight', type: 'decimal', precision: 8, scale: 4, default: 0 }) + plannedWeight: number; + + @Column({ name: 'wbs_code', type: 'varchar', length: 50, nullable: true }) + wbsCode: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => ProgramaObra, (p) => p.actividades) + @JoinColumn({ name: 'programa_id' }) + programa: ProgramaObra; + + @ManyToOne(() => Concepto, { nullable: true }) + @JoinColumn({ name: 'concepto_id' }) + concepto: Concepto | null; + + @ManyToOne(() => ProgramaActividad, (a) => a.children, { nullable: true }) + @JoinColumn({ name: 'parent_id' }) + parent: ProgramaActividad | null; + + @OneToMany(() => ProgramaActividad, (a) => a.parent) + children: ProgramaActividad[]; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/src/modules/progress/entities/programa-obra.entity.ts b/src/modules/progress/entities/programa-obra.entity.ts new file mode 100644 index 0000000..171d0d7 --- /dev/null +++ b/src/modules/progress/entities/programa-obra.entity.ts @@ -0,0 +1,91 @@ +/** + * ProgramaObra Entity + * Programa maestro de obra (planificacion) + * + * @module Progress + * @table construction.programa_obra + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { ProgramaActividad } from './programa-actividad.entity'; + +@Entity({ schema: 'construction', name: 'programa_obra' }) +@Index(['tenantId', 'code', 'version'], { unique: true }) +@Index(['tenantId']) +@Index(['fraccionamientoId']) +export class ProgramaObra { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'integer', default: 1 }) + version: number; + + @Column({ name: 'start_date', type: 'date' }) + startDate: Date; + + @Column({ name: 'end_date', type: 'date' }) + endDate: Date; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => ProgramaActividad, (a) => a.programa) + actividades: ProgramaActividad[]; +} diff --git a/src/modules/progress/services/avance-obra.service.ts b/src/modules/progress/services/avance-obra.service.ts new file mode 100644 index 0000000..266a7c2 --- /dev/null +++ b/src/modules/progress/services/avance-obra.service.ts @@ -0,0 +1,284 @@ +/** + * AvanceObraService - Gestión de Avances de Obra + * + * Gestiona el registro y aprobación de avances físicos de obra. + * Incluye workflow de captura -> revisión -> aprobación. + * + * @module Progress + */ + +import { Repository } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { AvanceObra, AdvanceStatus } from '../entities/avance-obra.entity'; +import { FotoAvance } from '../entities/foto-avance.entity'; + +export interface CreateAvanceDto { + loteId?: string; + departamentoId?: string; + conceptoId: string; + captureDate: Date; + quantityExecuted: number; + percentageExecuted?: number; + notes?: string; +} + +export interface AddFotoDto { + fileUrl: string; + fileName?: string; + fileSize?: number; + mimeType?: string; + description?: string; + location?: { lat: number; lng: number }; +} + +export interface AvanceFilters { + loteId?: string; + departamentoId?: string; + conceptoId?: string; + status?: AdvanceStatus; + dateFrom?: Date; + dateTo?: Date; +} + +export class AvanceObraService extends BaseService { + constructor( + repository: Repository, + private readonly fotoRepository: Repository + ) { + super(repository); + } + + /** + * Crear nuevo avance (captura) + */ + async createAvance( + ctx: ServiceContext, + data: CreateAvanceDto + ): Promise { + if (!data.loteId && !data.departamentoId) { + throw new Error('Either loteId or departamentoId is required'); + } + + if (data.loteId && data.departamentoId) { + throw new Error('Cannot specify both loteId and departamentoId'); + } + + return this.create(ctx, { + ...data, + status: 'captured', + capturedById: ctx.userId, + }); + } + + /** + * Obtener avances por lote + */ + async findByLote( + ctx: ServiceContext, + loteId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { loteId } as any, + }); + } + + /** + * Obtener avances por departamento + */ + async findByDepartamento( + ctx: ServiceContext, + departamentoId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { departamentoId } as any, + }); + } + + /** + * Obtener avances con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: AvanceFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('a') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.deleted_at IS NULL'); + + if (filters.loteId) { + qb.andWhere('a.lote_id = :loteId', { loteId: filters.loteId }); + } + if (filters.departamentoId) { + qb.andWhere('a.departamento_id = :departamentoId', { departamentoId: filters.departamentoId }); + } + if (filters.conceptoId) { + qb.andWhere('a.concepto_id = :conceptoId', { conceptoId: filters.conceptoId }); + } + if (filters.status) { + qb.andWhere('a.status = :status', { status: filters.status }); + } + if (filters.dateFrom) { + qb.andWhere('a.capture_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('a.capture_date <= :dateTo', { dateTo: filters.dateTo }); + } + + const skip = (page - 1) * limit; + qb.orderBy('a.capture_date', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Obtener avance con fotos + */ + async findWithFotos(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + relations: ['fotos', 'concepto', 'capturedBy'], + }); + } + + /** + * Agregar foto al avance + */ + async addFoto( + ctx: ServiceContext, + avanceId: string, + data: AddFotoDto + ): Promise { + const avance = await this.findById(ctx, avanceId); + if (!avance) { + throw new Error('Avance not found'); + } + + const location = data.location + ? `POINT(${data.location.lng} ${data.location.lat})` + : null; + + const foto = this.fotoRepository.create({ + tenantId: ctx.tenantId, + avanceId, + fileUrl: data.fileUrl, + fileName: data.fileName, + fileSize: data.fileSize, + mimeType: data.mimeType, + description: data.description, + location, + createdById: ctx.userId, + }); + + return this.fotoRepository.save(foto); + } + + /** + * Revisar avance + */ + async review(ctx: ServiceContext, avanceId: string): Promise { + const avance = await this.findById(ctx, avanceId); + if (!avance || avance.status !== 'captured') { + return null; + } + + return this.update(ctx, avanceId, { + status: 'reviewed', + reviewedById: ctx.userId, + reviewedAt: new Date(), + }); + } + + /** + * Aprobar avance + */ + async approve(ctx: ServiceContext, avanceId: string): Promise { + const avance = await this.findById(ctx, avanceId); + if (!avance || avance.status !== 'reviewed') { + return null; + } + + return this.update(ctx, avanceId, { + status: 'approved', + approvedById: ctx.userId, + approvedAt: new Date(), + }); + } + + /** + * Rechazar avance + */ + async reject( + ctx: ServiceContext, + avanceId: string, + reason: string + ): Promise { + const avance = await this.findById(ctx, avanceId); + if (!avance || !['captured', 'reviewed'].includes(avance.status)) { + return null; + } + + return this.update(ctx, avanceId, { + status: 'rejected', + notes: reason, + }); + } + + /** + * Calcular avance acumulado por concepto + */ + async getAccumulatedProgress( + ctx: ServiceContext, + loteId?: string, + departamentoId?: string + ): Promise { + const qb = this.repository + .createQueryBuilder('a') + .select('a.concepto_id', 'conceptoId') + .addSelect('SUM(a.quantity_executed)', 'totalQuantity') + .addSelect('AVG(a.percentage_executed)', 'avgPercentage') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.deleted_at IS NULL') + .andWhere('a.status = :status', { status: 'approved' }) + .groupBy('a.concepto_id'); + + if (loteId) { + qb.andWhere('a.lote_id = :loteId', { loteId }); + } + if (departamentoId) { + qb.andWhere('a.departamento_id = :departamentoId', { departamentoId }); + } + + return qb.getRawMany(); + } +} + +interface ConceptProgress { + conceptoId: string; + totalQuantity: number; + avgPercentage: number; +} diff --git a/src/modules/progress/services/bitacora-obra.service.ts b/src/modules/progress/services/bitacora-obra.service.ts new file mode 100644 index 0000000..676de68 --- /dev/null +++ b/src/modules/progress/services/bitacora-obra.service.ts @@ -0,0 +1,209 @@ +/** + * BitacoraObraService - Bitácora de Obra + * + * Gestiona el registro diario de bitácora de obra. + * Genera automáticamente el número de entrada secuencial. + * + * @module Progress + */ + +import { Repository } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { BitacoraObra } from '../entities/bitacora-obra.entity'; + +export interface CreateBitacoraDto { + fraccionamientoId: string; + entryDate: Date; + weather?: string; + temperatureMax?: number; + temperatureMin?: number; + workersCount?: number; + description: string; + observations?: string; + incidents?: string; +} + +export interface UpdateBitacoraDto { + weather?: string; + temperatureMax?: number; + temperatureMin?: number; + workersCount?: number; + description?: string; + observations?: string; + incidents?: string; +} + +export interface BitacoraFilters { + dateFrom?: Date; + dateTo?: Date; + hasIncidents?: boolean; +} + +export class BitacoraObraService extends BaseService { + constructor(repository: Repository) { + super(repository); + } + + /** + * Crear nueva entrada de bitácora + */ + async createEntry( + ctx: ServiceContext, + data: CreateBitacoraDto + ): Promise { + const entryNumber = await this.getNextEntryNumber(ctx, data.fraccionamientoId); + + return this.create(ctx, { + ...data, + entryNumber, + registeredById: ctx.userId, + }); + } + + /** + * Obtener siguiente número de entrada + */ + private async getNextEntryNumber( + ctx: ServiceContext, + fraccionamientoId: string + ): Promise { + const result = await this.repository + .createQueryBuilder('b') + .select('MAX(b.entry_number)', 'maxNumber') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) + .getRawOne(); + + return (result?.maxNumber || 0) + 1; + } + + /** + * Obtener bitácora por fraccionamiento + */ + async findByFraccionamiento( + ctx: ServiceContext, + fraccionamientoId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { fraccionamientoId } as any, + }); + } + + /** + * Obtener bitácora con filtros + */ + async findWithFilters( + ctx: ServiceContext, + fraccionamientoId: string, + filters: BitacoraFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('b') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) + .andWhere('b.deleted_at IS NULL'); + + if (filters.dateFrom) { + qb.andWhere('b.entry_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('b.entry_date <= :dateTo', { dateTo: filters.dateTo }); + } + if (filters.hasIncidents !== undefined) { + if (filters.hasIncidents) { + qb.andWhere('b.incidents IS NOT NULL'); + } else { + qb.andWhere('b.incidents IS NULL'); + } + } + + const skip = (page - 1) * limit; + qb.orderBy('b.entry_date', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Obtener entrada por fecha + */ + async findByDate( + ctx: ServiceContext, + fraccionamientoId: string, + date: Date + ): Promise { + return this.findOne(ctx, { + fraccionamientoId, + entryDate: date, + } as any); + } + + /** + * Obtener última entrada + */ + async findLatest( + ctx: ServiceContext, + fraccionamientoId: string + ): Promise { + const entries = await this.find(ctx, { + where: { fraccionamientoId } as any, + order: { entryNumber: 'DESC' }, + take: 1, + }); + + return entries[0] || null; + } + + /** + * Obtener estadísticas de bitácora + */ + async getStats( + ctx: ServiceContext, + fraccionamientoId: string + ): Promise { + const totalEntries = await this.count(ctx, { fraccionamientoId } as any); + + const incidentsCount = await this.repository + .createQueryBuilder('b') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.incidents IS NOT NULL') + .getCount(); + + const avgWorkers = await this.repository + .createQueryBuilder('b') + .select('AVG(b.workers_count)', 'avg') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) + .andWhere('b.deleted_at IS NULL') + .getRawOne(); + + return { + totalEntries, + entriesWithIncidents: incidentsCount, + avgWorkersCount: parseFloat(avgWorkers?.avg || '0'), + }; + } +} + +interface BitacoraStats { + totalEntries: number; + entriesWithIncidents: number; + avgWorkersCount: number; +} diff --git a/src/modules/progress/services/index.ts b/src/modules/progress/services/index.ts new file mode 100644 index 0000000..89c3b16 --- /dev/null +++ b/src/modules/progress/services/index.ts @@ -0,0 +1,7 @@ +/** + * Progress Module - Service Exports + * MAI-005: Control de Obra + */ + +export * from './avance-obra.service'; +export * from './bitacora-obra.service'; diff --git a/src/modules/purchase/controllers/comparativo.controller.ts b/src/modules/purchase/controllers/comparativo.controller.ts new file mode 100644 index 0000000..7062c29 --- /dev/null +++ b/src/modules/purchase/controllers/comparativo.controller.ts @@ -0,0 +1,308 @@ +/** + * ComparativoController - REST API for quotation comparisons + * + * Endpoints para gestión de cuadros comparativos de cotizaciones. + * + * @module Purchase + * @routes /api/comparativos + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ComparativoService, ComparativoFilters } from '../services/comparativo.service'; +import { ComparativoCotizaciones } from '../entities/comparativo-cotizaciones.entity'; +import { ComparativoProveedor } from '../entities/comparativo-proveedor.entity'; +import { ComparativoProducto } from '../entities/comparativo-producto.entity'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createComparativoController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const comparativoRepo = dataSource.getRepository(ComparativoCotizaciones); + const proveedorRepo = dataSource.getRepository(ComparativoProveedor); + const productoRepo = dataSource.getRepository(ComparativoProducto); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const service = new ComparativoService(comparativoRepo, proveedorRepo, productoRepo); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper for service context + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /api/comparativos + * List comparisons with filters + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: ComparativoFilters = {}; + if (req.query.requisicionId) { + filters.requisicionId = req.query.requisicionId as string; + } + if (req.query.status) { + filters.status = req.query.status as ComparativoFilters['status']; + } + if (req.query.dateFrom) { + filters.dateFrom = new Date(req.query.dateFrom as string); + } + if (req.query.dateTo) { + filters.dateTo = new Date(req.query.dateTo as string); + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await service.findWithFilters(getContext(req), filters, page, limit); + res.status(200).json({ success: true, data: result.data, pagination: result.meta }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/comparativos/:id + * Get comparison with full details + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const comparativo = await service.findWithDetails(getContext(req), req.params.id); + if (!comparativo) { + res.status(404).json({ error: 'Not Found', message: 'Comparison not found' }); + return; + } + + res.status(200).json({ success: true, data: comparativo }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/comparativos + * Create new comparison + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const comparativo = await service.create(getContext(req), { + requisicionId: req.body.requisicionId, + name: req.body.name, + comparisonDate: new Date(req.body.comparisonDate), + notes: req.body.notes, + }); + + res.status(201).json({ success: true, data: comparativo }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/comparativos/:id/proveedores + * Add supplier to comparison + */ + router.post('/:id/proveedores', authMiddleware.authenticate, authMiddleware.authorize('admin', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const proveedor = await service.addProveedor(getContext(req), req.params.id, { + supplierId: req.body.supplierId, + quotationNumber: req.body.quotationNumber, + quotationDate: req.body.quotationDate ? new Date(req.body.quotationDate) : undefined, + deliveryDays: req.body.deliveryDays, + paymentConditions: req.body.paymentConditions, + evaluationNotes: req.body.evaluationNotes, + }); + + res.status(201).json({ success: true, data: proveedor }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/comparativos/proveedores/:proveedorId/productos + * Add product to supplier entry + */ + router.post('/proveedores/:proveedorId/productos', authMiddleware.authenticate, authMiddleware.authorize('admin', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const producto = await service.addProducto(getContext(req), req.params.proveedorId, { + productId: req.body.productId, + quantity: parseFloat(req.body.quantity), + unitPrice: parseFloat(req.body.unitPrice), + notes: req.body.notes, + }); + + res.status(201).json({ success: true, data: producto }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /api/comparativos/proveedores/:proveedorId/total + * Recalculate supplier total + */ + router.put('/proveedores/:proveedorId/total', authMiddleware.authenticate, authMiddleware.authorize('admin', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const proveedor = await service.updateProveedorTotal(getContext(req), req.params.proveedorId); + if (!proveedor) { + res.status(404).json({ error: 'Not Found', message: 'Supplier entry not found' }); + return; + } + + res.status(200).json({ success: true, data: proveedor }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/comparativos/:id/start-evaluation + * Start evaluation process + */ + router.post('/:id/start-evaluation', authMiddleware.authenticate, authMiddleware.authorize('admin', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const comparativo = await service.startEvaluation(getContext(req), req.params.id); + if (!comparativo) { + res.status(404).json({ error: 'Not Found', message: 'Comparison not found' }); + return; + } + + res.status(200).json({ success: true, data: comparativo }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/comparativos/:id/select-winner + * Select winning supplier + */ + router.post('/:id/select-winner', authMiddleware.authenticate, authMiddleware.authorize('admin', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const comparativo = await service.selectWinner(getContext(req), req.params.id, req.body.supplierId); + if (!comparativo) { + res.status(404).json({ error: 'Not Found', message: 'Comparison not found' }); + return; + } + + res.status(200).json({ success: true, data: comparativo }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/comparativos/:id/cancel + * Cancel comparison + */ + router.post('/:id/cancel', authMiddleware.authenticate, authMiddleware.authorize('admin', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const comparativo = await service.cancel(getContext(req), req.params.id); + if (!comparativo) { + res.status(404).json({ error: 'Not Found', message: 'Comparison not found' }); + return; + } + + res.status(200).json({ success: true, data: comparativo }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /api/comparativos/:id + * Soft delete comparison + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await service.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Comparison not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/src/modules/purchase/controllers/index.ts b/src/modules/purchase/controllers/index.ts new file mode 100644 index 0000000..22393fb --- /dev/null +++ b/src/modules/purchase/controllers/index.ts @@ -0,0 +1,6 @@ +/** + * Purchase Controllers Index + * @module Purchase + */ + +export * from './comparativo.controller'; diff --git a/src/modules/purchase/entities/comparativo-cotizaciones.entity.ts b/src/modules/purchase/entities/comparativo-cotizaciones.entity.ts new file mode 100644 index 0000000..5e989bf --- /dev/null +++ b/src/modules/purchase/entities/comparativo-cotizaciones.entity.ts @@ -0,0 +1,101 @@ +/** + * ComparativoCotizaciones Entity + * Cuadro comparativo de cotizaciones + * + * @module Purchase + * @table purchase.comparativo_cotizaciones + * @ddl schemas/07-purchase-ext-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { RequisicionObra } from '../../inventory/entities/requisicion-obra.entity'; +import { ComparativoProveedor } from './comparativo-proveedor.entity'; + +export type ComparativoStatus = 'draft' | 'in_evaluation' | 'approved' | 'cancelled'; + +@Entity({ schema: 'purchase', name: 'comparativo_cotizaciones' }) +@Index(['tenantId', 'code'], { unique: true }) +export class ComparativoCotizaciones { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'requisicion_id', type: 'uuid', nullable: true }) + requisicionId: string; + + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'comparison_date', type: 'date' }) + comparisonDate: Date; + + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: ComparativoStatus; + + @Column({ name: 'winner_supplier_id', type: 'uuid', nullable: true }) + winnerSupplierId: string; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => RequisicionObra) + @JoinColumn({ name: 'requisicion_id' }) + requisicion: RequisicionObra; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => ComparativoProveedor, (cp) => cp.comparativo) + proveedores: ComparativoProveedor[]; +} diff --git a/src/modules/purchase/entities/comparativo-producto.entity.ts b/src/modules/purchase/entities/comparativo-producto.entity.ts new file mode 100644 index 0000000..f6e9640 --- /dev/null +++ b/src/modules/purchase/entities/comparativo-producto.entity.ts @@ -0,0 +1,75 @@ +/** + * ComparativoProducto Entity + * Productos cotizados por proveedor en comparativo + * + * @module Purchase + * @table purchase.comparativo_productos + * @ddl schemas/07-purchase-ext-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { ComparativoProveedor } from './comparativo-proveedor.entity'; + +@Entity({ schema: 'purchase', name: 'comparativo_productos' }) +export class ComparativoProducto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'comparativo_proveedor_id', type: 'uuid' }) + comparativoProveedorId: string; + + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + quantity: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 4 }) + unitPrice: number; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Computed property (in DB is GENERATED ALWAYS AS) + get totalPrice(): number { + return this.quantity * this.unitPrice; + } + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => ComparativoProveedor, (cp) => cp.productos, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'comparativo_proveedor_id' }) + comparativoProveedor: ComparativoProveedor; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/purchase/entities/comparativo-proveedor.entity.ts b/src/modules/purchase/entities/comparativo-proveedor.entity.ts new file mode 100644 index 0000000..8a00104 --- /dev/null +++ b/src/modules/purchase/entities/comparativo-proveedor.entity.ts @@ -0,0 +1,87 @@ +/** + * ComparativoProveedor Entity + * Proveedores participantes en comparativo + * + * @module Purchase + * @table purchase.comparativo_proveedores + * @ddl schemas/07-purchase-ext-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { ComparativoCotizaciones } from './comparativo-cotizaciones.entity'; +import { ComparativoProducto } from './comparativo-producto.entity'; + +@Entity({ schema: 'purchase', name: 'comparativo_proveedores' }) +export class ComparativoProveedor { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'comparativo_id', type: 'uuid' }) + comparativoId: string; + + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId: string; + + @Column({ name: 'quotation_number', type: 'varchar', length: 50, nullable: true }) + quotationNumber: string; + + @Column({ name: 'quotation_date', type: 'date', nullable: true }) + quotationDate: Date; + + @Column({ name: 'delivery_days', type: 'integer', nullable: true }) + deliveryDays: number; + + @Column({ name: 'payment_conditions', type: 'varchar', length: 100, nullable: true }) + paymentConditions: string; + + @Column({ name: 'total_amount', type: 'decimal', precision: 16, scale: 2, nullable: true }) + totalAmount: number; + + @Column({ name: 'is_selected', type: 'boolean', default: false }) + isSelected: boolean; + + @Column({ name: 'evaluation_notes', type: 'text', nullable: true }) + evaluationNotes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => ComparativoCotizaciones, (c) => c.proveedores, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'comparativo_id' }) + comparativo: ComparativoCotizaciones; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => ComparativoProducto, (cp) => cp.comparativoProveedor) + productos: ComparativoProducto[]; +} diff --git a/src/modules/purchase/entities/index.ts b/src/modules/purchase/entities/index.ts new file mode 100644 index 0000000..9a3adf5 --- /dev/null +++ b/src/modules/purchase/entities/index.ts @@ -0,0 +1,10 @@ +/** + * Purchase Entities Index + * @module Purchase + * + * Extensiones de compras para construcción (MAI-004) + */ + +export * from './comparativo-cotizaciones.entity'; +export * from './comparativo-proveedor.entity'; +export * from './comparativo-producto.entity'; diff --git a/src/modules/purchase/services/comparativo.service.ts b/src/modules/purchase/services/comparativo.service.ts new file mode 100644 index 0000000..f43b4fa --- /dev/null +++ b/src/modules/purchase/services/comparativo.service.ts @@ -0,0 +1,311 @@ +/** + * ComparativoService - Servicio de comparativos de cotizaciones + * + * Gestión de cuadros comparativos para selección de proveedores. + * + * @module Purchase + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { ComparativoCotizaciones, ComparativoStatus } from '../entities/comparativo-cotizaciones.entity'; +import { ComparativoProveedor } from '../entities/comparativo-proveedor.entity'; +import { ComparativoProducto } from '../entities/comparativo-producto.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateComparativoDto { + requisicionId?: string; + name: string; + comparisonDate: Date; + notes?: string; +} + +export interface AddProveedorDto { + supplierId: string; + quotationNumber?: string; + quotationDate?: Date; + deliveryDays?: number; + paymentConditions?: string; + evaluationNotes?: string; +} + +export interface AddProductoDto { + productId: string; + quantity: number; + unitPrice: number; + notes?: string; +} + +export interface ComparativoFilters { + requisicionId?: string; + status?: ComparativoStatus; + dateFrom?: Date; + dateTo?: Date; +} + +export class ComparativoService { + constructor( + private readonly comparativoRepository: Repository, + private readonly proveedorRepository: Repository, + private readonly productoRepository: Repository + ) {} + + private generateCode(): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `CMP-${year}${month}-${random}`; + } + + async findWithFilters( + ctx: ServiceContext, + filters: ComparativoFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.comparativoRepository + .createQueryBuilder('comp') + .leftJoinAndSelect('comp.requisicion', 'requisicion') + .leftJoinAndSelect('comp.createdBy', 'createdBy') + .where('comp.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('comp.deleted_at IS NULL'); + + if (filters.requisicionId) { + queryBuilder.andWhere('comp.requisicion_id = :requisicionId', { + requisicionId: filters.requisicionId, + }); + } + + if (filters.status) { + queryBuilder.andWhere('comp.status = :status', { status: filters.status }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('comp.comparison_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('comp.comparison_date <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('comp.comparison_date', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.comparativoRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + }); + } + + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.comparativoRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + relations: [ + 'requisicion', + 'createdBy', + 'approvedBy', + 'proveedores', + 'proveedores.productos', + ], + }); + } + + async create(ctx: ServiceContext, dto: CreateComparativoDto): Promise { + const comparativo = this.comparativoRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + code: this.generateCode(), + requisicionId: dto.requisicionId, + name: dto.name, + comparisonDate: dto.comparisonDate, + notes: dto.notes, + status: 'draft', + }); + + return this.comparativoRepository.save(comparativo); + } + + async addProveedor( + ctx: ServiceContext, + comparativoId: string, + dto: AddProveedorDto + ): Promise { + const comparativo = await this.findById(ctx, comparativoId); + if (!comparativo) { + throw new Error('Comparativo not found'); + } + + if (comparativo.status !== 'draft' && comparativo.status !== 'in_evaluation') { + throw new Error('Cannot add suppliers to approved or cancelled comparisons'); + } + + const proveedor = this.proveedorRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + comparativoId, + supplierId: dto.supplierId, + quotationNumber: dto.quotationNumber, + quotationDate: dto.quotationDate, + deliveryDays: dto.deliveryDays, + paymentConditions: dto.paymentConditions, + evaluationNotes: dto.evaluationNotes, + }); + + return this.proveedorRepository.save(proveedor); + } + + async addProducto( + ctx: ServiceContext, + proveedorId: string, + dto: AddProductoDto + ): Promise { + const proveedor = await this.proveedorRepository.findOne({ + where: { id: proveedorId, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['comparativo'], + }); + + if (!proveedor) { + throw new Error('Supplier entry not found'); + } + + if (proveedor.comparativo.status !== 'draft' && proveedor.comparativo.status !== 'in_evaluation') { + throw new Error('Cannot add products to approved or cancelled comparisons'); + } + + const producto = this.productoRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + comparativoProveedorId: proveedorId, + productId: dto.productId, + quantity: dto.quantity, + unitPrice: dto.unitPrice, + notes: dto.notes, + }); + + return this.productoRepository.save(producto); + } + + async startEvaluation(ctx: ServiceContext, id: string): Promise { + const comparativo = await this.findWithDetails(ctx, id); + if (!comparativo) { + return null; + } + + if (comparativo.status !== 'draft') { + throw new Error('Can only start evaluation on draft comparisons'); + } + + if (!comparativo.proveedores || comparativo.proveedores.length < 2) { + throw new Error('Need at least 2 suppliers to start evaluation'); + } + + comparativo.status = 'in_evaluation'; + return this.comparativoRepository.save(comparativo); + } + + async selectWinner(ctx: ServiceContext, id: string, supplierId: string): Promise { + const comparativo = await this.findWithDetails(ctx, id); + if (!comparativo) { + return null; + } + + if (comparativo.status !== 'in_evaluation') { + throw new Error('Can only select winner during evaluation'); + } + + // Verify supplier is in the comparison + const proveedor = comparativo.proveedores?.find((p) => p.supplierId === supplierId); + if (!proveedor) { + throw new Error('Supplier not found in this comparison'); + } + + // Mark all as not selected, then mark winner + for (const p of comparativo.proveedores || []) { + p.isSelected = p.supplierId === supplierId; + await this.proveedorRepository.save(p); + } + + comparativo.winnerSupplierId = supplierId; + comparativo.status = 'approved'; + comparativo.approvedById = ctx.userId || ''; + comparativo.approvedAt = new Date(); + + return this.comparativoRepository.save(comparativo); + } + + async cancel(ctx: ServiceContext, id: string): Promise { + const comparativo = await this.findById(ctx, id); + if (!comparativo) { + return null; + } + + if (comparativo.status === 'approved') { + throw new Error('Cannot cancel approved comparisons'); + } + + comparativo.status = 'cancelled'; + return this.comparativoRepository.save(comparativo); + } + + async updateProveedorTotal(ctx: ServiceContext, proveedorId: string): Promise { + const proveedor = await this.proveedorRepository.findOne({ + where: { id: proveedorId, tenantId: ctx.tenantId } as FindOptionsWhere, + relations: ['productos'], + }); + + if (!proveedor) { + return null; + } + + const total = proveedor.productos?.reduce( + (sum, p) => sum + (p.quantity * p.unitPrice), + 0 + ) || 0; + + proveedor.totalAmount = total; + return this.proveedorRepository.save(proveedor); + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const comparativo = await this.findById(ctx, id); + if (!comparativo) { + return false; + } + + if (comparativo.status === 'approved') { + throw new Error('Cannot delete approved comparisons'); + } + + await this.comparativoRepository.update( + { id, tenantId: ctx.tenantId } as unknown as FindOptionsWhere, + { deletedAt: new Date(), deletedById: ctx.userId } + ); + + return true; + } +} diff --git a/src/modules/purchase/services/index.ts b/src/modules/purchase/services/index.ts new file mode 100644 index 0000000..96280d7 --- /dev/null +++ b/src/modules/purchase/services/index.ts @@ -0,0 +1,6 @@ +/** + * Purchase Services Index + * @module Purchase + */ + +export * from './comparativo.service'; diff --git a/src/modules/quality/controllers/index.ts b/src/modules/quality/controllers/index.ts new file mode 100644 index 0000000..3ece966 --- /dev/null +++ b/src/modules/quality/controllers/index.ts @@ -0,0 +1,7 @@ +/** + * Quality Controllers Index + * @module Quality + */ + +export * from './inspection.controller'; +export * from './ticket.controller'; diff --git a/src/modules/quality/controllers/inspection.controller.ts b/src/modules/quality/controllers/inspection.controller.ts new file mode 100644 index 0000000..d98ba82 --- /dev/null +++ b/src/modules/quality/controllers/inspection.controller.ts @@ -0,0 +1,254 @@ +/** + * InspectionController - REST API for quality inspections + * + * Endpoints para gestión de inspecciones de calidad. + * + * @module Quality + * @routes /api/inspections + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { InspectionService, InspectionFilters } from '../services/inspection.service'; +import { Inspection } from '../entities/inspection.entity'; +import { InspectionResult } from '../entities/inspection-result.entity'; +import { NonConformity } from '../entities/non-conformity.entity'; +import { Checklist } from '../entities/checklist.entity'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createInspectionController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const inspectionRepo = dataSource.getRepository(Inspection); + const resultRepo = dataSource.getRepository(InspectionResult); + const ncRepo = dataSource.getRepository(NonConformity); + const checklistRepo = dataSource.getRepository(Checklist); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const service = new InspectionService(inspectionRepo, resultRepo, ncRepo, checklistRepo); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper for service context + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /api/inspections + * List inspections with filters + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: InspectionFilters = {}; + if (req.query.checklistId) filters.checklistId = req.query.checklistId as string; + if (req.query.loteId) filters.loteId = req.query.loteId as string; + if (req.query.inspectorId) filters.inspectorId = req.query.inspectorId as string; + if (req.query.status) filters.status = req.query.status as InspectionFilters['status']; + if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string); + if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string); + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await service.findWithFilters(getContext(req), filters, page, limit); + res.status(200).json({ success: true, data: result.data, pagination: result.meta }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/inspections/:id + * Get inspection with details + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const inspection = await service.findWithDetails(getContext(req), req.params.id); + if (!inspection) { + res.status(404).json({ error: 'Not Found', message: 'Inspection not found' }); + return; + } + + res.status(200).json({ success: true, data: inspection }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/inspections + * Create new inspection + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const inspection = await service.create(getContext(req), { + checklistId: req.body.checklistId, + loteId: req.body.loteId, + inspectionDate: new Date(req.body.inspectionDate), + inspectorId: req.body.inspectorId, + notes: req.body.notes, + }); + + res.status(201).json({ success: true, data: inspection }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/inspections/:id/start + * Start inspection + */ + router.post('/:id/start', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const inspection = await service.startInspection(getContext(req), req.params.id); + if (!inspection) { + res.status(404).json({ error: 'Not Found', message: 'Inspection not found' }); + return; + } + + res.status(200).json({ success: true, data: inspection }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/inspections/:id/results + * Record inspection result + */ + router.post('/:id/results', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const result = await service.recordResult(getContext(req), req.params.id, { + checklistItemId: req.body.checklistItemId, + result: req.body.result, + observations: req.body.observations, + photoUrl: req.body.photoUrl, + }); + + res.status(201).json({ success: true, data: result }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/inspections/:id/complete + * Complete inspection + */ + router.post('/:id/complete', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'resident'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const inspection = await service.completeInspection(getContext(req), req.params.id); + if (!inspection) { + res.status(404).json({ error: 'Not Found', message: 'Inspection not found' }); + return; + } + + res.status(200).json({ success: true, data: inspection }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/inspections/:id/approve + * Approve inspection + */ + router.post('/:id/approve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const inspection = await service.approveInspection(getContext(req), req.params.id); + if (!inspection) { + res.status(404).json({ error: 'Not Found', message: 'Inspection not found' }); + return; + } + + res.status(200).json({ success: true, data: inspection }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/inspections/:id/reject + * Reject inspection + */ + router.post('/:id/reject', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const inspection = await service.rejectInspection(getContext(req), req.params.id, req.body.reason); + if (!inspection) { + res.status(404).json({ error: 'Not Found', message: 'Inspection not found' }); + return; + } + + res.status(200).json({ success: true, data: inspection }); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/src/modules/quality/controllers/ticket.controller.ts b/src/modules/quality/controllers/ticket.controller.ts new file mode 100644 index 0000000..2ad5364 --- /dev/null +++ b/src/modules/quality/controllers/ticket.controller.ts @@ -0,0 +1,308 @@ +/** + * TicketController - REST API for post-sale tickets + * + * Endpoints para gestión de tickets de garantía. + * + * @module Quality + * @routes /api/tickets + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { TicketService, TicketFilters } from '../services/ticket.service'; +import { PostSaleTicket } from '../entities/post-sale-ticket.entity'; +import { TicketAssignment } from '../entities/ticket-assignment.entity'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createTicketController(dataSource: DataSource): Router { + const router = Router(); + + // Repositories + const ticketRepo = dataSource.getRepository(PostSaleTicket); + const assignmentRepo = dataSource.getRepository(TicketAssignment); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Services + const service = new TicketService(ticketRepo, assignmentRepo); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper for service context + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /api/tickets + * List tickets with filters + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const filters: TicketFilters = {}; + if (req.query.loteId) filters.loteId = req.query.loteId as string; + if (req.query.derechohabienteId) filters.derechohabienteId = req.query.derechohabienteId as string; + if (req.query.category) filters.category = req.query.category as TicketFilters['category']; + if (req.query.priority) filters.priority = req.query.priority as TicketFilters['priority']; + if (req.query.status) filters.status = req.query.status as TicketFilters['status']; + if (req.query.slaBreached) filters.slaBreached = req.query.slaBreached === 'true'; + if (req.query.assignedTo) filters.assignedTo = req.query.assignedTo as string; + if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string); + if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string); + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await service.findWithFilters(getContext(req), filters, page, limit); + res.status(200).json({ success: true, data: result.data, pagination: result.meta }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/tickets/sla-stats + * Get SLA statistics + */ + router.get('/sla-stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await service.getSlaStats(getContext(req)); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /api/tickets/:id + * Get ticket with details + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const ticket = await service.findWithDetails(getContext(req), req.params.id); + if (!ticket) { + res.status(404).json({ error: 'Not Found', message: 'Ticket not found' }); + return; + } + + res.status(200).json({ success: true, data: ticket }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/tickets + * Create new ticket + */ + router.post('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const ticket = await service.create(getContext(req), { + loteId: req.body.loteId, + derechohabienteId: req.body.derechohabienteId, + category: req.body.category, + title: req.body.title, + description: req.body.description, + photoUrl: req.body.photoUrl, + contactName: req.body.contactName, + contactPhone: req.body.contactPhone, + }); + + res.status(201).json({ success: true, data: ticket }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/tickets/:id/assign + * Assign ticket to technician + */ + router.post('/:id/assign', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'postventa'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const ticket = await service.assign(getContext(req), req.params.id, { + technicianId: req.body.technicianId, + scheduledDate: req.body.scheduledDate ? new Date(req.body.scheduledDate) : undefined, + scheduledTime: req.body.scheduledTime, + }); + + if (!ticket) { + res.status(404).json({ error: 'Not Found', message: 'Ticket not found' }); + return; + } + + res.status(200).json({ success: true, data: ticket }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/tickets/:id/start + * Start work on ticket + */ + router.post('/:id/start', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'postventa', 'technician'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const ticket = await service.startWork(getContext(req), req.params.id); + if (!ticket) { + res.status(404).json({ error: 'Not Found', message: 'Ticket not found' }); + return; + } + + res.status(200).json({ success: true, data: ticket }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/tickets/:id/resolve + * Resolve ticket + */ + router.post('/:id/resolve', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'postventa', 'technician'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const ticket = await service.resolve(getContext(req), req.params.id, { + resolutionNotes: req.body.resolutionNotes, + resolutionPhotoUrl: req.body.resolutionPhotoUrl, + }); + + if (!ticket) { + res.status(404).json({ error: 'Not Found', message: 'Ticket not found' }); + return; + } + + res.status(200).json({ success: true, data: ticket }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/tickets/:id/close + * Close ticket with satisfaction rating + */ + router.post('/:id/close', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const ticket = await service.close( + getContext(req), + req.params.id, + req.body.rating, + req.body.comment + ); + + if (!ticket) { + res.status(404).json({ error: 'Not Found', message: 'Ticket not found' }); + return; + } + + res.status(200).json({ success: true, data: ticket }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/tickets/:id/cancel + * Cancel ticket + */ + router.post('/:id/cancel', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality', 'postventa'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const ticket = await service.cancel(getContext(req), req.params.id, req.body.reason); + if (!ticket) { + res.status(404).json({ error: 'Not Found', message: 'Ticket not found' }); + return; + } + + res.status(200).json({ success: true, data: ticket }); + } catch (error) { + next(error); + } + }); + + /** + * POST /api/tickets/check-sla + * Check and update SLA breaches + */ + router.post('/check-sla', authMiddleware.authenticate, authMiddleware.authorize('admin', 'quality'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const breached = await service.checkSlaBreaches(getContext(req)); + res.status(200).json({ success: true, data: { breachedTickets: breached } }); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/src/modules/quality/entities/checklist-item.entity.ts b/src/modules/quality/entities/checklist-item.entity.ts new file mode 100644 index 0000000..bb68941 --- /dev/null +++ b/src/modules/quality/entities/checklist-item.entity.ts @@ -0,0 +1,69 @@ +/** + * ChecklistItem Entity + * Items de verificación en checklists + * + * @module Quality + * @table quality.checklist_items + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Checklist } from './checklist.entity'; + +@Entity({ schema: 'quality', name: 'checklist_items' }) +@Index(['tenantId', 'checklistId', 'sequenceNumber']) +export class ChecklistItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'checklist_id', type: 'uuid' }) + checklistId: string; + + @Column({ name: 'sequence_number', type: 'integer' }) + sequenceNumber: number; + + @Column({ type: 'varchar', length: 100 }) + category: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ name: 'is_critical', type: 'boolean', default: false }) + isCritical: boolean; + + @Column({ name: 'requires_photo', type: 'boolean', default: false }) + requiresPhoto: boolean; + + @Column({ name: 'acceptance_criteria', type: 'text', nullable: true }) + acceptanceCriteria: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Checklist, (c) => c.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'checklist_id' }) + checklist: Checklist; +} diff --git a/src/modules/quality/entities/checklist.entity.ts b/src/modules/quality/entities/checklist.entity.ts new file mode 100644 index 0000000..acefd6f --- /dev/null +++ b/src/modules/quality/entities/checklist.entity.ts @@ -0,0 +1,89 @@ +/** + * Checklist Entity + * Templates de inspección de calidad + * + * @module Quality + * @table quality.checklists + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { ChecklistItem } from './checklist-item.entity'; +import { Inspection } from './inspection.entity'; + +export type ChecklistStage = 'foundation' | 'structure' | 'installations' | 'finishes' | 'delivery' | 'custom'; + +@Entity({ schema: 'quality', name: 'checklists' }) +@Index(['tenantId', 'code'], { unique: true }) +export class Checklist { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'varchar', length: 30 }) + stage: ChecklistStage; + + @Column({ name: 'prototype_id', type: 'uuid', nullable: true }) + prototypeId: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'integer', default: 0 }) + version: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => ChecklistItem, (item) => item.checklist) + items: ChecklistItem[]; + + @OneToMany(() => Inspection, (insp) => insp.checklist) + inspections: Inspection[]; +} diff --git a/src/modules/quality/entities/corrective-action.entity.ts b/src/modules/quality/entities/corrective-action.entity.ts new file mode 100644 index 0000000..9afb879 --- /dev/null +++ b/src/modules/quality/entities/corrective-action.entity.ts @@ -0,0 +1,100 @@ +/** + * CorrectiveAction Entity + * Acciones correctivas (CAPA) + * + * @module Quality + * @table quality.corrective_actions + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { NonConformity } from './non-conformity.entity'; + +export type ActionType = 'corrective' | 'preventive' | 'improvement'; +export type ActionStatus = 'pending' | 'in_progress' | 'completed' | 'verified'; + +@Entity({ schema: 'quality', name: 'corrective_actions' }) +@Index(['tenantId', 'nonConformityId']) +export class CorrectiveAction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'non_conformity_id', type: 'uuid' }) + nonConformityId: string; + + @Column({ name: 'action_type', type: 'varchar', length: 20 }) + actionType: ActionType; + + @Column({ type: 'text' }) + description: string; + + @Column({ name: 'responsible_id', type: 'uuid' }) + responsibleId: string; + + @Column({ name: 'due_date', type: 'date' }) + dueDate: Date; + + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: ActionStatus; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'completion_notes', type: 'text', nullable: true }) + completionNotes: string; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'verified_by', type: 'uuid', nullable: true }) + verifiedById: string; + + @Column({ name: 'effectiveness_verified', type: 'boolean', default: false }) + effectivenessVerified: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => NonConformity, (nc) => nc.correctiveActions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'non_conformity_id' }) + nonConformity: NonConformity; + + @ManyToOne(() => User) + @JoinColumn({ name: 'responsible_id' }) + responsible: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'verified_by' }) + verifiedBy: User; +} diff --git a/src/modules/quality/entities/index.ts b/src/modules/quality/entities/index.ts new file mode 100644 index 0000000..5bf0124 --- /dev/null +++ b/src/modules/quality/entities/index.ts @@ -0,0 +1,15 @@ +/** + * Quality Entities Index + * @module Quality + * + * Control de calidad y postventa (MAI-009) + */ + +export * from './checklist.entity'; +export * from './checklist-item.entity'; +export * from './inspection.entity'; +export * from './inspection-result.entity'; +export * from './non-conformity.entity'; +export * from './corrective-action.entity'; +export * from './post-sale-ticket.entity'; +export * from './ticket-assignment.entity'; diff --git a/src/modules/quality/entities/inspection-result.entity.ts b/src/modules/quality/entities/inspection-result.entity.ts new file mode 100644 index 0000000..79a938f --- /dev/null +++ b/src/modules/quality/entities/inspection-result.entity.ts @@ -0,0 +1,70 @@ +/** + * InspectionResult Entity + * Resultados por item de inspección + * + * @module Quality + * @table quality.inspection_results + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Inspection } from './inspection.entity'; +import { ChecklistItem } from './checklist-item.entity'; + +export type InspectionResultStatus = 'pending' | 'passed' | 'failed' | 'not_applicable'; + +@Entity({ schema: 'quality', name: 'inspection_results' }) +@Index(['tenantId', 'inspectionId', 'checklistItemId'], { unique: true }) +export class InspectionResult { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'inspection_id', type: 'uuid' }) + inspectionId: string; + + @Column({ name: 'checklist_item_id', type: 'uuid' }) + checklistItemId: string; + + @Column({ type: 'varchar', length: 20, default: 'pending' }) + result: InspectionResultStatus; + + @Column({ type: 'text', nullable: true }) + observations: string; + + @Column({ name: 'photo_url', type: 'varchar', length: 500, nullable: true }) + photoUrl: string; + + @Column({ name: 'inspected_at', type: 'timestamptz', nullable: true }) + inspectedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Inspection, (i) => i.results, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'inspection_id' }) + inspection: Inspection; + + @ManyToOne(() => ChecklistItem) + @JoinColumn({ name: 'checklist_item_id' }) + checklistItem: ChecklistItem; +} diff --git a/src/modules/quality/entities/inspection.entity.ts b/src/modules/quality/entities/inspection.entity.ts new file mode 100644 index 0000000..a0c9c30 --- /dev/null +++ b/src/modules/quality/entities/inspection.entity.ts @@ -0,0 +1,120 @@ +/** + * Inspection Entity + * Inspecciones de calidad realizadas + * + * @module Quality + * @table quality.inspections + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Checklist } from './checklist.entity'; +import { InspectionResult } from './inspection-result.entity'; +import { NonConformity } from './non-conformity.entity'; + +export type InspectionStatus = 'pending' | 'in_progress' | 'completed' | 'approved' | 'rejected'; + +@Entity({ schema: 'quality', name: 'inspections' }) +@Index(['tenantId', 'inspectionNumber'], { unique: true }) +export class Inspection { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'checklist_id', type: 'uuid' }) + checklistId: string; + + @Column({ name: 'lote_id', type: 'uuid' }) + loteId: string; + + @Column({ name: 'inspection_number', type: 'varchar', length: 50 }) + inspectionNumber: string; + + @Column({ name: 'inspection_date', type: 'date' }) + inspectionDate: Date; + + @Column({ name: 'inspector_id', type: 'uuid' }) + inspectorId: string; + + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: InspectionStatus; + + @Column({ name: 'total_items', type: 'integer', default: 0 }) + totalItems: number; + + @Column({ name: 'passed_items', type: 'integer', default: 0 }) + passedItems: number; + + @Column({ name: 'failed_items', type: 'integer', default: 0 }) + failedItems: number; + + @Column({ name: 'pass_rate', type: 'decimal', precision: 5, scale: 2, nullable: true }) + passRate: number; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Checklist, (c) => c.inspections) + @JoinColumn({ name: 'checklist_id' }) + checklist: Checklist; + + @ManyToOne(() => User) + @JoinColumn({ name: 'inspector_id' }) + inspector: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User; + + @OneToMany(() => InspectionResult, (r) => r.inspection) + results: InspectionResult[]; + + @OneToMany(() => NonConformity, (nc) => nc.inspection) + nonConformities: NonConformity[]; +} diff --git a/src/modules/quality/entities/non-conformity.entity.ts b/src/modules/quality/entities/non-conformity.entity.ts new file mode 100644 index 0000000..fac8d63 --- /dev/null +++ b/src/modules/quality/entities/non-conformity.entity.ts @@ -0,0 +1,126 @@ +/** + * NonConformity Entity + * No conformidades detectadas en inspecciones + * + * @module Quality + * @table quality.non_conformities + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Inspection } from './inspection.entity'; +import { CorrectiveAction } from './corrective-action.entity'; + +export type NCSeverity = 'minor' | 'major' | 'critical'; +export type NCStatus = 'open' | 'in_progress' | 'closed' | 'verified'; + +@Entity({ schema: 'quality', name: 'non_conformities' }) +@Index(['tenantId', 'ncNumber'], { unique: true }) +export class NonConformity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'inspection_id', type: 'uuid', nullable: true }) + inspectionId: string; + + @Column({ name: 'lote_id', type: 'uuid' }) + loteId: string; + + @Column({ name: 'nc_number', type: 'varchar', length: 50 }) + ncNumber: string; + + @Column({ name: 'detection_date', type: 'date' }) + detectionDate: Date; + + @Column({ type: 'varchar', length: 100 }) + category: string; + + @Column({ type: 'varchar', length: 20 }) + severity: NCSeverity; + + @Column({ type: 'text' }) + description: string; + + @Column({ name: 'root_cause', type: 'text', nullable: true }) + rootCause: string; + + @Column({ name: 'photo_url', type: 'varchar', length: 500, nullable: true }) + photoUrl: string; + + @Column({ name: 'contractor_id', type: 'uuid', nullable: true }) + contractorId: string; + + @Column({ type: 'varchar', length: 20, default: 'open' }) + status: NCStatus; + + @Column({ name: 'due_date', type: 'date', nullable: true }) + dueDate: Date; + + @Column({ name: 'closed_at', type: 'timestamptz', nullable: true }) + closedAt: Date; + + @Column({ name: 'closed_by', type: 'uuid', nullable: true }) + closedById: string; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'verified_by', type: 'uuid', nullable: true }) + verifiedById: string; + + @Column({ name: 'closure_photo_url', type: 'varchar', length: 500, nullable: true }) + closurePhotoUrl: string; + + @Column({ name: 'closure_notes', type: 'text', nullable: true }) + closureNotes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Inspection, (i) => i.nonConformities) + @JoinColumn({ name: 'inspection_id' }) + inspection: Inspection; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'closed_by' }) + closedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'verified_by' }) + verifiedBy: User; + + @OneToMany(() => CorrectiveAction, (ca) => ca.nonConformity) + correctiveActions: CorrectiveAction[]; +} diff --git a/src/modules/quality/entities/post-sale-ticket.entity.ts b/src/modules/quality/entities/post-sale-ticket.entity.ts new file mode 100644 index 0000000..127ad69 --- /dev/null +++ b/src/modules/quality/entities/post-sale-ticket.entity.ts @@ -0,0 +1,123 @@ +/** + * PostSaleTicket Entity + * Tickets de garantía postventa + * + * @module Quality + * @table quality.post_sale_tickets + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { TicketAssignment } from './ticket-assignment.entity'; + +export type TicketPriority = 'urgent' | 'high' | 'medium' | 'low'; +export type TicketStatus = 'created' | 'assigned' | 'in_progress' | 'resolved' | 'closed' | 'cancelled'; +export type TicketCategory = 'plumbing' | 'electrical' | 'finishes' | 'carpentry' | 'structural' | 'other'; + +@Entity({ schema: 'quality', name: 'post_sale_tickets' }) +@Index(['tenantId', 'ticketNumber'], { unique: true }) +export class PostSaleTicket { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'lote_id', type: 'uuid' }) + loteId: string; + + @Column({ name: 'derechohabiente_id', type: 'uuid', nullable: true }) + derechohabienteId: string; + + @Column({ name: 'ticket_number', type: 'varchar', length: 50 }) + ticketNumber: string; + + @Column({ type: 'varchar', length: 30 }) + category: TicketCategory; + + @Column({ type: 'varchar', length: 20 }) + priority: TicketPriority; + + @Column({ type: 'varchar', length: 255 }) + title: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ name: 'photo_url', type: 'varchar', length: 500, nullable: true }) + photoUrl: string; + + @Column({ type: 'varchar', length: 20, default: 'created' }) + status: TicketStatus; + + @Column({ name: 'sla_hours', type: 'integer' }) + slaHours: number; + + @Column({ name: 'sla_due_at', type: 'timestamptz' }) + slaDueAt: Date; + + @Column({ name: 'sla_breached', type: 'boolean', default: false }) + slaBreached: boolean; + + @Column({ name: 'assigned_at', type: 'timestamptz', nullable: true }) + assignedAt: Date; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt: Date; + + @Column({ name: 'closed_at', type: 'timestamptz', nullable: true }) + closedAt: Date; + + @Column({ name: 'resolution_notes', type: 'text', nullable: true }) + resolutionNotes: string; + + @Column({ name: 'resolution_photo_url', type: 'varchar', length: 500, nullable: true }) + resolutionPhotoUrl: string; + + @Column({ name: 'satisfaction_rating', type: 'integer', nullable: true }) + satisfactionRating: number; + + @Column({ name: 'satisfaction_comment', type: 'text', nullable: true }) + satisfactionComment: string; + + @Column({ name: 'contact_name', type: 'varchar', length: 200, nullable: true }) + contactName: string; + + @Column({ name: 'contact_phone', type: 'varchar', length: 20, nullable: true }) + contactPhone: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => TicketAssignment, (ta) => ta.ticket) + assignments: TicketAssignment[]; +} diff --git a/src/modules/quality/entities/ticket-assignment.entity.ts b/src/modules/quality/entities/ticket-assignment.entity.ts new file mode 100644 index 0000000..0086afe --- /dev/null +++ b/src/modules/quality/entities/ticket-assignment.entity.ts @@ -0,0 +1,92 @@ +/** + * TicketAssignment Entity + * Asignaciones de tickets a técnicos + * + * @module Quality + * @table quality.ticket_assignments + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { PostSaleTicket } from './post-sale-ticket.entity'; + +export type AssignmentStatus = 'assigned' | 'accepted' | 'in_progress' | 'completed' | 'reassigned'; + +@Entity({ schema: 'quality', name: 'ticket_assignments' }) +@Index(['tenantId', 'ticketId', 'technicianId']) +export class TicketAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'ticket_id', type: 'uuid' }) + ticketId: string; + + @Column({ name: 'technician_id', type: 'uuid' }) + technicianId: string; + + @Column({ name: 'assigned_at', type: 'timestamptz' }) + assignedAt: Date; + + @Column({ name: 'assigned_by', type: 'uuid' }) + assignedById: string; + + @Column({ type: 'varchar', length: 20, default: 'assigned' }) + status: AssignmentStatus; + + @Column({ name: 'accepted_at', type: 'timestamptz', nullable: true }) + acceptedAt: Date; + + @Column({ name: 'scheduled_date', type: 'date', nullable: true }) + scheduledDate: Date; + + @Column({ name: 'scheduled_time', type: 'time', nullable: true }) + scheduledTime: string; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'work_notes', type: 'text', nullable: true }) + workNotes: string; + + @Column({ name: 'reassignment_reason', type: 'text', nullable: true }) + reassignmentReason: string; + + @Column({ name: 'is_current', type: 'boolean', default: true }) + isCurrent: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => PostSaleTicket, (t) => t.assignments, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'ticket_id' }) + ticket: PostSaleTicket; + + @ManyToOne(() => User) + @JoinColumn({ name: 'technician_id' }) + technician: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'assigned_by' }) + assignedBy: User; +} diff --git a/src/modules/quality/services/index.ts b/src/modules/quality/services/index.ts new file mode 100644 index 0000000..c401230 --- /dev/null +++ b/src/modules/quality/services/index.ts @@ -0,0 +1,7 @@ +/** + * Quality Services Index + * @module Quality + */ + +export * from './inspection.service'; +export * from './ticket.service'; diff --git a/src/modules/quality/services/inspection.service.ts b/src/modules/quality/services/inspection.service.ts new file mode 100644 index 0000000..d10f5d0 --- /dev/null +++ b/src/modules/quality/services/inspection.service.ts @@ -0,0 +1,317 @@ +/** + * InspectionService - Servicio de inspecciones de calidad + * + * Gestión de inspecciones con checklists y resultados. + * + * @module Quality + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Inspection, InspectionStatus } from '../entities/inspection.entity'; +import { InspectionResult } from '../entities/inspection-result.entity'; +import { NonConformity } from '../entities/non-conformity.entity'; +import { Checklist } from '../entities/checklist.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateInspectionDto { + checklistId: string; + loteId: string; + inspectionDate: Date; + inspectorId: string; + notes?: string; +} + +export interface RecordResultDto { + checklistItemId: string; + result: 'passed' | 'failed' | 'not_applicable'; + observations?: string; + photoUrl?: string; +} + +export interface InspectionFilters { + checklistId?: string; + loteId?: string; + inspectorId?: string; + status?: InspectionStatus; + dateFrom?: Date; + dateTo?: Date; +} + +export class InspectionService { + constructor( + private readonly inspectionRepository: Repository, + private readonly resultRepository: Repository, + private readonly nonConformityRepository: Repository, + private readonly checklistRepository: Repository + ) {} + + private generateInspectionNumber(): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `INS-${year}${month}-${random}`; + } + + private generateNCNumber(): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `NC-${year}${month}-${random}`; + } + + async findWithFilters( + ctx: ServiceContext, + filters: InspectionFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.inspectionRepository + .createQueryBuilder('insp') + .leftJoinAndSelect('insp.checklist', 'checklist') + .leftJoinAndSelect('insp.inspector', 'inspector') + .where('insp.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.checklistId) { + queryBuilder.andWhere('insp.checklist_id = :checklistId', { checklistId: filters.checklistId }); + } + + if (filters.loteId) { + queryBuilder.andWhere('insp.lote_id = :loteId', { loteId: filters.loteId }); + } + + if (filters.inspectorId) { + queryBuilder.andWhere('insp.inspector_id = :inspectorId', { inspectorId: filters.inspectorId }); + } + + if (filters.status) { + queryBuilder.andWhere('insp.status = :status', { status: filters.status }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('insp.inspection_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('insp.inspection_date <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('insp.inspection_date', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.inspectionRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as unknown as FindOptionsWhere, + }); + } + + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.inspectionRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as unknown as FindOptionsWhere, + relations: ['checklist', 'checklist.items', 'inspector', 'results', 'results.checklistItem', 'nonConformities'], + }); + } + + async create(ctx: ServiceContext, dto: CreateInspectionDto): Promise { + // Validate checklist exists + const checklist = await this.checklistRepository.findOne({ + where: { + id: dto.checklistId, + tenantId: ctx.tenantId, + isActive: true, + deletedAt: null, + } as unknown as FindOptionsWhere, + relations: ['items'], + }); + + if (!checklist) { + throw new Error('Checklist not found or inactive'); + } + + const inspection = this.inspectionRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + checklistId: dto.checklistId, + loteId: dto.loteId, + inspectionNumber: this.generateInspectionNumber(), + inspectionDate: dto.inspectionDate, + inspectorId: dto.inspectorId, + notes: dto.notes, + status: 'pending', + totalItems: checklist.items?.filter(i => i.isActive).length || 0, + }); + + return this.inspectionRepository.save(inspection); + } + + async startInspection(ctx: ServiceContext, id: string): Promise { + const inspection = await this.findById(ctx, id); + if (!inspection) { + return null; + } + + if (inspection.status !== 'pending') { + throw new Error('Can only start pending inspections'); + } + + inspection.status = 'in_progress'; + inspection.updatedById = ctx.userId || ''; + + return this.inspectionRepository.save(inspection); + } + + async recordResult(ctx: ServiceContext, inspectionId: string, dto: RecordResultDto): Promise { + const inspection = await this.findById(ctx, inspectionId); + if (!inspection) { + throw new Error('Inspection not found'); + } + + if (inspection.status !== 'in_progress') { + throw new Error('Can only record results for in-progress inspections'); + } + + // Check if result already exists + let result = await this.resultRepository.findOne({ + where: { + inspectionId, + checklistItemId: dto.checklistItemId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (result) { + // Update existing + result.result = dto.result; + result.observations = dto.observations || result.observations; + result.photoUrl = dto.photoUrl || result.photoUrl; + result.inspectedAt = new Date(); + } else { + // Create new + result = this.resultRepository.create({ + tenantId: ctx.tenantId, + inspectionId, + checklistItemId: dto.checklistItemId, + result: dto.result, + observations: dto.observations, + photoUrl: dto.photoUrl, + inspectedAt: new Date(), + }); + } + + const savedResult = await this.resultRepository.save(result); + + // If failed, create non-conformity automatically + if (dto.result === 'failed') { + const nc = this.nonConformityRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + inspectionId, + loteId: inspection.loteId, + ncNumber: this.generateNCNumber(), + detectionDate: new Date(), + category: 'Inspection Finding', + severity: 'minor', + description: dto.observations || 'Failed inspection item', + photoUrl: dto.photoUrl, + status: 'open', + }); + await this.nonConformityRepository.save(nc); + } + + return savedResult; + } + + async completeInspection(ctx: ServiceContext, id: string): Promise { + const inspection = await this.findWithDetails(ctx, id); + if (!inspection) { + return null; + } + + if (inspection.status !== 'in_progress') { + throw new Error('Can only complete in-progress inspections'); + } + + // Calculate pass/fail counts + const results = inspection.results || []; + const passed = results.filter(r => r.result === 'passed').length; + const failed = results.filter(r => r.result === 'failed').length; + const total = results.filter(r => r.result !== 'not_applicable').length; + + inspection.passedItems = passed; + inspection.failedItems = failed; + inspection.passRate = total > 0 ? (passed / total) * 100 : 0; + inspection.status = 'completed'; + inspection.completedAt = new Date(); + inspection.updatedById = ctx.userId || ''; + + return this.inspectionRepository.save(inspection); + } + + async approveInspection(ctx: ServiceContext, id: string): Promise { + const inspection = await this.findWithDetails(ctx, id); + if (!inspection) { + return null; + } + + if (inspection.status !== 'completed') { + throw new Error('Can only approve completed inspections'); + } + + // Check for open critical non-conformities + const criticalNCs = inspection.nonConformities?.filter( + nc => nc.severity === 'critical' && nc.status !== 'verified' + ); + + if (criticalNCs && criticalNCs.length > 0) { + throw new Error('Cannot approve inspection with open critical non-conformities'); + } + + inspection.status = 'approved'; + inspection.approvedById = ctx.userId || ''; + inspection.approvedAt = new Date(); + inspection.updatedById = ctx.userId || ''; + + return this.inspectionRepository.save(inspection); + } + + async rejectInspection(ctx: ServiceContext, id: string, reason: string): Promise { + const inspection = await this.findById(ctx, id); + if (!inspection) { + return null; + } + + if (inspection.status !== 'completed') { + throw new Error('Can only reject completed inspections'); + } + + inspection.status = 'rejected'; + inspection.rejectionReason = reason; + inspection.updatedById = ctx.userId || ''; + + return this.inspectionRepository.save(inspection); + } +} diff --git a/src/modules/quality/services/ticket.service.ts b/src/modules/quality/services/ticket.service.ts new file mode 100644 index 0000000..48466f8 --- /dev/null +++ b/src/modules/quality/services/ticket.service.ts @@ -0,0 +1,395 @@ +/** + * TicketService - Servicio de tickets postventa + * + * Gestión de tickets de garantía con SLA. + * + * @module Quality + */ + +import { Repository, FindOptionsWhere, LessThan } from 'typeorm'; +import { PostSaleTicket, TicketStatus, TicketPriority, TicketCategory } from '../entities/post-sale-ticket.entity'; +import { TicketAssignment } from '../entities/ticket-assignment.entity'; +import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +export interface CreateTicketDto { + loteId: string; + derechohabienteId?: string; + category: TicketCategory; + title: string; + description: string; + photoUrl?: string; + contactName?: string; + contactPhone?: string; +} + +export interface AssignTicketDto { + technicianId: string; + scheduledDate?: Date; + scheduledTime?: string; +} + +export interface ResolveTicketDto { + resolutionNotes: string; + resolutionPhotoUrl?: string; +} + +export interface TicketFilters { + loteId?: string; + derechohabienteId?: string; + category?: TicketCategory; + priority?: TicketPriority; + status?: TicketStatus; + slaBreached?: boolean; + assignedTo?: string; + dateFrom?: Date; + dateTo?: Date; +} + +// SLA hours by priority +const SLA_HOURS: Record = { + urgent: 24, + high: 48, + medium: 168, // 7 days + low: 360, // 15 days +}; + +// Auto-priority by category +const CATEGORY_PRIORITY: Record = { + plumbing: 'high', + electrical: 'high', + structural: 'urgent', + finishes: 'medium', + carpentry: 'medium', + other: 'low', +}; + +export class TicketService { + constructor( + private readonly ticketRepository: Repository, + private readonly assignmentRepository: Repository + ) {} + + private generateTicketNumber(): string { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `TKT-${year}${month}-${random}`; + } + + async findWithFilters( + ctx: ServiceContext, + filters: TicketFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const queryBuilder = this.ticketRepository + .createQueryBuilder('tkt') + .leftJoinAndSelect('tkt.assignments', 'assignments', 'assignments.is_current = true') + .leftJoinAndSelect('assignments.technician', 'technician') + .where('tkt.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.loteId) { + queryBuilder.andWhere('tkt.lote_id = :loteId', { loteId: filters.loteId }); + } + + if (filters.derechohabienteId) { + queryBuilder.andWhere('tkt.derechohabiente_id = :derechohabienteId', { + derechohabienteId: filters.derechohabienteId, + }); + } + + if (filters.category) { + queryBuilder.andWhere('tkt.category = :category', { category: filters.category }); + } + + if (filters.priority) { + queryBuilder.andWhere('tkt.priority = :priority', { priority: filters.priority }); + } + + if (filters.status) { + queryBuilder.andWhere('tkt.status = :status', { status: filters.status }); + } + + if (filters.slaBreached !== undefined) { + queryBuilder.andWhere('tkt.sla_breached = :slaBreached', { slaBreached: filters.slaBreached }); + } + + if (filters.assignedTo) { + queryBuilder.andWhere('assignments.technician_id = :technicianId', { technicianId: filters.assignedTo }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('tkt.created_at >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('tkt.created_at <= :dateTo', { dateTo: filters.dateTo }); + } + + queryBuilder + .orderBy('tkt.priority', 'ASC') + .addOrderBy('tkt.sla_due_at', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.ticketRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as unknown as FindOptionsWhere, + }); + } + + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.ticketRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as unknown as FindOptionsWhere, + relations: ['assignments', 'assignments.technician', 'assignments.assignedBy', 'createdBy'], + }); + } + + async create(ctx: ServiceContext, dto: CreateTicketDto): Promise { + // Determine priority based on category + const priority = CATEGORY_PRIORITY[dto.category]; + const slaHours = SLA_HOURS[priority]; + const slaDueAt = new Date(); + slaDueAt.setHours(slaDueAt.getHours() + slaHours); + + const ticket = this.ticketRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + loteId: dto.loteId, + derechohabienteId: dto.derechohabienteId, + ticketNumber: this.generateTicketNumber(), + category: dto.category, + priority, + title: dto.title, + description: dto.description, + photoUrl: dto.photoUrl, + contactName: dto.contactName, + contactPhone: dto.contactPhone, + status: 'created', + slaHours, + slaDueAt, + }); + + return this.ticketRepository.save(ticket); + } + + async assign(ctx: ServiceContext, id: string, dto: AssignTicketDto): Promise { + const ticket = await this.findWithDetails(ctx, id); + if (!ticket) { + return null; + } + + if (ticket.status === 'closed' || ticket.status === 'cancelled') { + throw new Error('Cannot assign closed or cancelled tickets'); + } + + // Mark previous assignments as not current + if (ticket.assignments && ticket.assignments.length > 0) { + for (const assignment of ticket.assignments) { + if (assignment.isCurrent) { + assignment.isCurrent = false; + assignment.status = 'reassigned'; + await this.assignmentRepository.save(assignment); + } + } + } + + // Create new assignment + const assignment = this.assignmentRepository.create({ + tenantId: ctx.tenantId, + ticketId: id, + technicianId: dto.technicianId, + assignedAt: new Date(), + assignedById: ctx.userId || '', + scheduledDate: dto.scheduledDate, + scheduledTime: dto.scheduledTime, + status: 'assigned', + isCurrent: true, + }); + await this.assignmentRepository.save(assignment); + + // Update ticket status + ticket.status = 'assigned'; + ticket.assignedAt = new Date(); + ticket.updatedById = ctx.userId || ''; + + return this.ticketRepository.save(ticket); + } + + async startWork(ctx: ServiceContext, id: string): Promise { + const ticket = await this.findWithDetails(ctx, id); + if (!ticket) { + return null; + } + + if (ticket.status !== 'assigned') { + throw new Error('Can only start work on assigned tickets'); + } + + // Update current assignment + const currentAssignment = ticket.assignments?.find(a => a.isCurrent); + if (currentAssignment) { + currentAssignment.status = 'in_progress'; + currentAssignment.acceptedAt = new Date(); + await this.assignmentRepository.save(currentAssignment); + } + + ticket.status = 'in_progress'; + ticket.updatedById = ctx.userId || ''; + + return this.ticketRepository.save(ticket); + } + + async resolve(ctx: ServiceContext, id: string, dto: ResolveTicketDto): Promise { + const ticket = await this.findWithDetails(ctx, id); + if (!ticket) { + return null; + } + + if (ticket.status !== 'in_progress') { + throw new Error('Can only resolve in-progress tickets'); + } + + // Update current assignment + const currentAssignment = ticket.assignments?.find(a => a.isCurrent); + if (currentAssignment) { + currentAssignment.status = 'completed'; + currentAssignment.completedAt = new Date(); + currentAssignment.workNotes = dto.resolutionNotes; + await this.assignmentRepository.save(currentAssignment); + } + + ticket.status = 'resolved'; + ticket.resolvedAt = new Date(); + ticket.resolutionNotes = dto.resolutionNotes; + if (dto.resolutionPhotoUrl) { + ticket.resolutionPhotoUrl = dto.resolutionPhotoUrl; + } + ticket.updatedById = ctx.userId || ''; + + // Check if SLA was breached + if (new Date() > ticket.slaDueAt) { + ticket.slaBreached = true; + } + + return this.ticketRepository.save(ticket); + } + + async close(ctx: ServiceContext, id: string, rating?: number, comment?: string): Promise { + const ticket = await this.findById(ctx, id); + if (!ticket) { + return null; + } + + if (ticket.status !== 'resolved') { + throw new Error('Can only close resolved tickets'); + } + + ticket.status = 'closed'; + ticket.closedAt = new Date(); + if (rating !== undefined) { + ticket.satisfactionRating = rating; + } + if (comment) { + ticket.satisfactionComment = comment; + } + ticket.updatedById = ctx.userId || ''; + + return this.ticketRepository.save(ticket); + } + + async cancel(ctx: ServiceContext, id: string, reason: string): Promise { + const ticket = await this.findById(ctx, id); + if (!ticket) { + return null; + } + + if (ticket.status === 'closed') { + throw new Error('Cannot cancel closed tickets'); + } + + ticket.status = 'cancelled'; + ticket.resolutionNotes = `[CANCELLED] ${reason}`; + ticket.updatedById = ctx.userId || ''; + + return this.ticketRepository.save(ticket); + } + + async checkSlaBreaches(ctx: ServiceContext): Promise { + const result = await this.ticketRepository.update( + { + tenantId: ctx.tenantId, + slaBreached: false, + slaDueAt: LessThan(new Date()), + status: 'created' as TicketStatus, + } as FindOptionsWhere, + { slaBreached: true } + ); + + // Also check assigned and in_progress + await this.ticketRepository.update( + { + tenantId: ctx.tenantId, + slaBreached: false, + slaDueAt: LessThan(new Date()), + status: 'assigned' as TicketStatus, + } as FindOptionsWhere, + { slaBreached: true } + ); + + await this.ticketRepository.update( + { + tenantId: ctx.tenantId, + slaBreached: false, + slaDueAt: LessThan(new Date()), + status: 'in_progress' as TicketStatus, + } as FindOptionsWhere, + { slaBreached: true } + ); + + return result.affected || 0; + } + + async getSlaStats(ctx: ServiceContext): Promise<{ total: number; breached: number; complianceRate: number }> { + const total = await this.ticketRepository.count({ + where: { + tenantId: ctx.tenantId, + status: 'closed' as TicketStatus, + } as FindOptionsWhere, + }); + + const breached = await this.ticketRepository.count({ + where: { + tenantId: ctx.tenantId, + status: 'closed' as TicketStatus, + slaBreached: true, + } as FindOptionsWhere, + }); + + const complianceRate = total > 0 ? ((total - breached) / total) * 100 : 100; + + return { total, breached, complianceRate }; + } +} diff --git a/src/modules/reports/controllers/dashboard.controller.ts b/src/modules/reports/controllers/dashboard.controller.ts new file mode 100644 index 0000000..1710a6d --- /dev/null +++ b/src/modules/reports/controllers/dashboard.controller.ts @@ -0,0 +1,504 @@ +/** + * DashboardController - Controller de Dashboards + * + * Endpoints REST para gestión de dashboards y widgets. + * + * @module Reports + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + DashboardService, + CreateDashboardDto, + UpdateDashboardDto, + DashboardFilters, + CreateWidgetDto, + UpdateWidgetDto, +} from '../services/dashboard.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Dashboard } from '../entities/dashboard.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createDashboardController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const dashboardRepository = dataSource.getRepository(Dashboard); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const dashboardService = new DashboardService(dashboardRepository, dataSource); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /dashboards + * Listar dashboards con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: DashboardFilters = {}; + if (req.query.dashboardType) filters.dashboardType = req.query.dashboardType as any; + if (req.query.visibility) filters.visibility = req.query.visibility as any; + if (req.query.ownerId) filters.ownerId = req.query.ownerId as string; + if (req.query.fraccionamientoId) filters.fraccionamientoId = req.query.fraccionamientoId as string; + if (req.query.isActive !== undefined) filters.isActive = req.query.isActive === 'true'; + if (req.query.search) filters.search = req.query.search as string; + + const result = await dashboardService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /dashboards/stats + * Estadísticas de dashboards + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await dashboardService.getStats(getContext(req)); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /dashboards/my + * Mis dashboards + */ + router.get('/my', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const userId = req.user?.sub; + if (!userId) { + res.status(400).json({ error: 'Bad Request', message: 'User ID required' }); + return; + } + + const dashboards = await dashboardService.findByOwner(getContext(req), userId); + res.status(200).json({ success: true, data: dashboards }); + } catch (error) { + next(error); + } + }); + + /** + * GET /dashboards/type/:type + * Dashboards por tipo + */ + router.get('/type/:type', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dashboards = await dashboardService.findByType(getContext(req), req.params.type as any); + res.status(200).json({ success: true, data: dashboards }); + } catch (error) { + next(error); + } + }); + + /** + * GET /dashboards/type/:type/default + * Dashboard por defecto para un tipo + */ + router.get('/type/:type/default', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dashboard = await dashboardService.findDefault(getContext(req), req.params.type as any); + if (!dashboard) { + res.status(404).json({ error: 'Not Found', message: 'No default dashboard found' }); + return; + } + + // Registrar vista + await dashboardService.recordView(getContext(req), dashboard.id); + + res.status(200).json({ success: true, data: dashboard }); + } catch (error) { + next(error); + } + }); + + /** + * GET /dashboards/code/:code + * Dashboard por código + */ + router.get('/code/:code', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dashboard = await dashboardService.findByCode(getContext(req), req.params.code); + if (!dashboard) { + res.status(404).json({ error: 'Not Found', message: 'Dashboard not found' }); + return; + } + + // Registrar vista + await dashboardService.recordView(getContext(req), dashboard.id); + + res.status(200).json({ success: true, data: dashboard }); + } catch (error) { + next(error); + } + }); + + /** + * GET /dashboards/:id + * Obtener dashboard con widgets + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dashboard = await dashboardService.findByIdWithWidgets(getContext(req), req.params.id); + if (!dashboard) { + res.status(404).json({ error: 'Not Found', message: 'Dashboard not found' }); + return; + } + + // Registrar vista + await dashboardService.recordView(getContext(req), dashboard.id); + + res.status(200).json({ success: true, data: dashboard }); + } catch (error) { + next(error); + } + }); + + /** + * POST /dashboards + * Crear dashboard + */ + router.post('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateDashboardDto = req.body; + if (!dto.code || !dto.name) { + res.status(400).json({ + error: 'Bad Request', + message: 'code and name are required', + }); + return; + } + + const dashboard = await dashboardService.createDashboard(getContext(req), dto); + res.status(201).json({ success: true, data: dashboard }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PUT /dashboards/:id + * Actualizar dashboard + */ + router.put('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateDashboardDto = req.body; + const dashboard = await dashboardService.update(getContext(req), req.params.id, dto); + + if (!dashboard) { + res.status(404).json({ error: 'Not Found', message: 'Dashboard not found' }); + return; + } + + res.status(200).json({ success: true, data: dashboard }); + } catch (error) { + next(error); + } + }); + + /** + * POST /dashboards/:id/set-default + * Establecer como dashboard por defecto + */ + router.post('/:id/set-default', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dashboard = await dashboardService.setDefault(getContext(req), req.params.id); + if (!dashboard) { + res.status(404).json({ error: 'Not Found', message: 'Dashboard not found' }); + return; + } + + res.status(200).json({ success: true, data: dashboard }); + } catch (error) { + next(error); + } + }); + + /** + * POST /dashboards/:id/duplicate + * Duplicar dashboard + */ + router.post('/:id/duplicate', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { code, name } = req.body; + if (!code || !name) { + res.status(400).json({ + error: 'Bad Request', + message: 'code and name are required for duplication', + }); + return; + } + + const dashboard = await dashboardService.duplicate(getContext(req), req.params.id, code, name); + res.status(201).json({ success: true, data: dashboard }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Dashboard not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * DELETE /dashboards/:id + * Eliminar dashboard + */ + router.delete('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + // Verificar que no sea un dashboard de sistema + const dashboard = await dashboardService.findById(getContext(req), req.params.id); + if (!dashboard) { + res.status(404).json({ error: 'Not Found', message: 'Dashboard not found' }); + return; + } + + if (dashboard.isSystem) { + res.status(403).json({ error: 'Forbidden', message: 'Cannot delete system dashboards' }); + return; + } + + const deleted = await dashboardService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Dashboard not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Dashboard deleted' }); + } catch (error) { + next(error); + } + }); + + // ==================== Widget Endpoints ==================== + + /** + * GET /dashboards/:id/widgets + * Obtener widgets de un dashboard + */ + router.get('/:id/widgets', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const widgets = await dashboardService.getWidgets(getContext(req), req.params.id); + res.status(200).json({ success: true, data: widgets }); + } catch (error) { + next(error); + } + }); + + /** + * POST /dashboards/:id/widgets + * Crear widget en dashboard + */ + router.post('/:id/widgets', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateWidgetDto = { + ...req.body, + dashboardId: req.params.id, + }; + + if (!dto.title || !dto.widgetType) { + res.status(400).json({ + error: 'Bad Request', + message: 'title and widgetType are required', + }); + return; + } + + const widget = await dashboardService.createWidget(getContext(req), dto); + res.status(201).json({ success: true, data: widget }); + } catch (error) { + if (error instanceof Error && error.message === 'Dashboard not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PUT /dashboards/:dashboardId/widgets/:widgetId + * Actualizar widget + */ + router.put('/:dashboardId/widgets/:widgetId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateWidgetDto = req.body; + const widget = await dashboardService.updateWidget(getContext(req), req.params.widgetId, dto); + + if (!widget) { + res.status(404).json({ error: 'Not Found', message: 'Widget not found' }); + return; + } + + res.status(200).json({ success: true, data: widget }); + } catch (error) { + next(error); + } + }); + + /** + * PUT /dashboards/:id/widgets/positions + * Actualizar posiciones de widgets + */ + router.put('/:id/widgets/positions', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { positions } = req.body; + if (!Array.isArray(positions)) { + res.status(400).json({ + error: 'Bad Request', + message: 'positions array is required', + }); + return; + } + + await dashboardService.updateWidgetPositions(getContext(req), req.params.id, positions); + res.status(200).json({ success: true, message: 'Widget positions updated' }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /dashboards/:dashboardId/widgets/:widgetId + * Eliminar widget + */ + router.delete('/:dashboardId/widgets/:widgetId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const deleted = await dashboardService.deleteWidget(getContext(req), req.params.widgetId); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Widget not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Widget deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createDashboardController; diff --git a/src/modules/reports/controllers/index.ts b/src/modules/reports/controllers/index.ts new file mode 100644 index 0000000..e2047cd --- /dev/null +++ b/src/modules/reports/controllers/index.ts @@ -0,0 +1,8 @@ +/** + * Reports Controllers Index + * @module Reports + */ + +export { createReportController } from './report.controller'; +export { createDashboardController } from './dashboard.controller'; +export { createKpiController } from './kpi.controller'; diff --git a/src/modules/reports/controllers/kpi.controller.ts b/src/modules/reports/controllers/kpi.controller.ts new file mode 100644 index 0000000..0fb4d4f --- /dev/null +++ b/src/modules/reports/controllers/kpi.controller.ts @@ -0,0 +1,349 @@ +/** + * KpiController - Controller de KPIs + * + * Endpoints REST para gestión de KPIs y analytics. + * + * @module Reports + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { KpiService, CreateKpiSnapshotDto, KpiFilters } from '../services/kpi.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { KpiSnapshot } from '../entities/kpi-snapshot.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createKpiController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const kpiRepository = dataSource.getRepository(KpiSnapshot); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const kpiService = new KpiService(kpiRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /kpis + * Listar snapshots de KPIs con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: KpiFilters = {}; + if (req.query.kpiCode) filters.kpiCode = req.query.kpiCode as string; + if (req.query.category) filters.category = req.query.category as any; + if (req.query.periodType) filters.periodType = req.query.periodType as any; + if (req.query.fraccionamientoId) filters.fraccionamientoId = req.query.fraccionamientoId as string; + if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string); + if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string); + + const result = await kpiService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /kpis/stats + * Estadísticas de KPIs + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await kpiService.getStats(getContext(req)); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /kpis/dashboard + * KPIs para dashboard agrupados por categoría + */ + router.get('/dashboard', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string | undefined; + const dashboard = await kpiService.getDashboardKpis(getContext(req), fraccionamientoId); + + res.status(200).json({ success: true, data: dashboard }); + } catch (error) { + next(error); + } + }); + + /** + * GET /kpis/category/:category + * Resumen de KPIs por categoría + */ + router.get('/category/:category', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string | undefined; + const summary = await kpiService.getSummaryByCategory( + getContext(req), + req.params.category as any, + fraccionamientoId + ); + + res.status(200).json({ success: true, data: summary }); + } catch (error) { + next(error); + } + }); + + /** + * GET /kpis/code/:code/latest + * Último snapshot de un KPI + */ + router.get('/code/:code/latest', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoId = req.query.fraccionamientoId as string | undefined; + const snapshot = await kpiService.getLatestSnapshot( + getContext(req), + req.params.code, + fraccionamientoId + ); + + if (!snapshot) { + res.status(404).json({ error: 'Not Found', message: 'KPI snapshot not found' }); + return; + } + + res.status(200).json({ success: true, data: snapshot }); + } catch (error) { + next(error); + } + }); + + /** + * GET /kpis/code/:code/trend + * Tendencia de un KPI + */ + router.get('/code/:code/trend', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const days = parseInt(req.query.days as string) || 30; + const fraccionamientoId = req.query.fraccionamientoId as string | undefined; + + const trend = await kpiService.getKpiTrend( + getContext(req), + req.params.code, + days, + fraccionamientoId + ); + + if (!trend) { + res.status(404).json({ error: 'Not Found', message: 'KPI data not found' }); + return; + } + + res.status(200).json({ success: true, data: trend }); + } catch (error) { + next(error); + } + }); + + /** + * GET /kpis/code/:code/history + * Historial de un KPI + */ + router.get('/code/:code/history', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dateFrom = req.query.dateFrom ? new Date(req.query.dateFrom as string) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const dateTo = req.query.dateTo ? new Date(req.query.dateTo as string) : new Date(); + const fraccionamientoId = req.query.fraccionamientoId as string | undefined; + + const history = await kpiService.getKpiHistory( + getContext(req), + req.params.code, + dateFrom, + dateTo, + fraccionamientoId + ); + + res.status(200).json({ success: true, data: history }); + } catch (error) { + next(error); + } + }); + + /** + * GET /kpis/code/:code/compare + * Comparar KPI entre proyectos + */ + router.get('/code/:code/compare', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const fraccionamientoIds = (req.query.fraccionamientoIds as string)?.split(',') || []; + if (fraccionamientoIds.length < 2) { + res.status(400).json({ + error: 'Bad Request', + message: 'At least 2 fraccionamientoIds are required for comparison', + }); + return; + } + + const snapshotDate = req.query.date ? new Date(req.query.date as string) : undefined; + const comparison = await kpiService.compareProjects( + getContext(req), + req.params.code, + fraccionamientoIds, + snapshotDate + ); + + res.status(200).json({ success: true, data: comparison }); + } catch (error) { + next(error); + } + }); + + /** + * POST /kpis + * Crear snapshot de KPI + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'analyst', 'system'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateKpiSnapshotDto = req.body; + if (!dto.kpiCode || !dto.kpiName || !dto.category || !dto.snapshotDate || dto.value === undefined) { + res.status(400).json({ + error: 'Bad Request', + message: 'kpiCode, kpiName, category, snapshotDate, and value are required', + }); + return; + } + + const snapshot = await kpiService.createSnapshot(getContext(req), dto); + res.status(201).json({ success: true, data: snapshot }); + } catch (error) { + next(error); + } + }); + + /** + * POST /kpis/bulk + * Crear múltiples snapshots (para jobs) + */ + router.post('/bulk', authMiddleware.authenticate, authMiddleware.authorize('admin', 'system'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { snapshots } = req.body; + if (!Array.isArray(snapshots) || snapshots.length === 0) { + res.status(400).json({ + error: 'Bad Request', + message: 'snapshots array is required and must not be empty', + }); + return; + } + + const result = await kpiService.bulkCreateSnapshots(getContext(req), snapshots); + res.status(201).json({ + success: true, + data: result, + message: `Created ${result.created} snapshots, ${result.errors} errors`, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /kpis/:id + * Obtener snapshot por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const snapshot = await kpiService.findById(getContext(req), req.params.id); + if (!snapshot) { + res.status(404).json({ error: 'Not Found', message: 'KPI snapshot not found' }); + return; + } + + res.status(200).json({ success: true, data: snapshot }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createKpiController; diff --git a/src/modules/reports/controllers/report.controller.ts b/src/modules/reports/controllers/report.controller.ts new file mode 100644 index 0000000..79fdd10 --- /dev/null +++ b/src/modules/reports/controllers/report.controller.ts @@ -0,0 +1,373 @@ +/** + * ReportController - Controller de Reportes + * + * Endpoints REST para gestión de reportes y ejecuciones. + * + * @module Reports + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { ReportService, CreateReportDto, UpdateReportDto, ReportFilters, ExecuteReportDto } from '../services/report.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { Report } from '../entities/report.entity'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { ServiceContext } from '../../../shared/services/base.service'; + +export function createReportController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const reportRepository = dataSource.getRepository(Report); + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const reportService = new ReportService(reportRepository, dataSource); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + // Helper para crear contexto + const getContext = (req: Request): ServiceContext => { + if (!req.tenantId) { + throw new Error('Tenant ID is required'); + } + return { + tenantId: req.tenantId, + userId: req.user?.sub, + }; + }; + + /** + * GET /reports + * Listar reportes con filtros + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const filters: ReportFilters = {}; + if (req.query.reportType) filters.reportType = req.query.reportType as any; + if (req.query.isActive !== undefined) filters.isActive = req.query.isActive === 'true'; + if (req.query.isScheduled !== undefined) filters.isScheduled = req.query.isScheduled === 'true'; + if (req.query.search) filters.search = req.query.search as string; + + const result = await reportService.findWithFilters(getContext(req), filters, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /reports/stats + * Estadísticas de reportes + */ + router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const stats = await reportService.getStats(getContext(req)); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }); + + /** + * GET /reports/scheduled + * Obtener reportes programados + */ + router.get('/scheduled', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const reports = await reportService.findScheduled(getContext(req)); + res.status(200).json({ success: true, data: reports }); + } catch (error) { + next(error); + } + }); + + /** + * GET /reports/type/:type + * Obtener reportes por tipo + */ + router.get('/type/:type', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const reports = await reportService.findByType(getContext(req), req.params.type as any); + res.status(200).json({ success: true, data: reports }); + } catch (error) { + next(error); + } + }); + + /** + * GET /reports/code/:code + * Obtener reporte por código + */ + router.get('/code/:code', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const report = await reportService.findByCode(getContext(req), req.params.code); + if (!report) { + res.status(404).json({ error: 'Not Found', message: 'Report not found' }); + return; + } + + res.status(200).json({ success: true, data: report }); + } catch (error) { + next(error); + } + }); + + /** + * GET /reports/:id + * Obtener reporte por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const report = await reportService.findById(getContext(req), req.params.id); + if (!report) { + res.status(404).json({ error: 'Not Found', message: 'Report not found' }); + return; + } + + res.status(200).json({ success: true, data: report }); + } catch (error) { + next(error); + } + }); + + /** + * POST /reports + * Crear reporte + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'analyst'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateReportDto = req.body; + if (!dto.code || !dto.name || !dto.reportType) { + res.status(400).json({ + error: 'Bad Request', + message: 'code, name, and reportType are required', + }); + return; + } + + const report = await reportService.createReport(getContext(req), dto); + res.status(201).json({ success: true, data: report }); + } catch (error) { + if (error instanceof Error && error.message.includes('already exists')) { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PUT /reports/:id + * Actualizar reporte + */ + router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'analyst'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateReportDto = req.body; + const report = await reportService.update(getContext(req), req.params.id, dto); + + if (!report) { + res.status(404).json({ error: 'Not Found', message: 'Report not found' }); + return; + } + + res.status(200).json({ success: true, data: report }); + } catch (error) { + next(error); + } + }); + + /** + * POST /reports/:id/execute + * Ejecutar reporte + */ + router.post('/:id/execute', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const options: ExecuteReportDto = req.body; + const execution = await reportService.executeReport(getContext(req), req.params.id, options); + + res.status(200).json({ success: true, data: execution }); + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Report not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + if (error.message === 'Report is not active') { + res.status(400).json({ error: 'Bad Request', message: error.message }); + return; + } + } + next(error); + } + }); + + /** + * GET /reports/:id/executions + * Historial de ejecuciones + */ + router.get('/:id/executions', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + + const result = await reportService.getExecutionHistory(getContext(req), req.params.id, page, limit); + + res.status(200).json({ + success: true, + data: result.data, + pagination: result.meta, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /reports/:id/executions/latest + * Última ejecución + */ + router.get('/:id/executions/latest', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const execution = await reportService.getLastExecution(getContext(req), req.params.id); + if (!execution) { + res.status(404).json({ error: 'Not Found', message: 'No executions found' }); + return; + } + + res.status(200).json({ success: true, data: execution }); + } catch (error) { + next(error); + } + }); + + /** + * GET /reports/executions/:executionId + * Obtener ejecución específica + */ + router.get('/executions/:executionId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const execution = await reportService.getExecution(getContext(req), req.params.executionId); + if (!execution) { + res.status(404).json({ error: 'Not Found', message: 'Execution not found' }); + return; + } + + res.status(200).json({ success: true, data: execution }); + } catch (error) { + next(error); + } + }); + + /** + * DELETE /reports/:id + * Eliminar reporte (soft delete) + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + if (!req.tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + // Verificar que no sea un reporte de sistema + const report = await reportService.findById(getContext(req), req.params.id); + if (!report) { + res.status(404).json({ error: 'Not Found', message: 'Report not found' }); + return; + } + + if (report.isSystem) { + res.status(403).json({ error: 'Forbidden', message: 'Cannot delete system reports' }); + return; + } + + const deleted = await reportService.softDelete(getContext(req), req.params.id); + if (!deleted) { + res.status(404).json({ error: 'Not Found', message: 'Report not found' }); + return; + } + + res.status(200).json({ success: true, message: 'Report deleted' }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createReportController; diff --git a/src/modules/reports/entities/dashboard-widget.entity.ts b/src/modules/reports/entities/dashboard-widget.entity.ts new file mode 100644 index 0000000..0fa4257 --- /dev/null +++ b/src/modules/reports/entities/dashboard-widget.entity.ts @@ -0,0 +1,222 @@ +/** + * DashboardWidget Entity + * Configuración de widgets de dashboard + * + * @module Reports + * @table reports.dashboard_widgets + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Dashboard } from './dashboard.entity'; + +export type WidgetType = + | 'kpi_card' + | 'line_chart' + | 'bar_chart' + | 'pie_chart' + | 'donut_chart' + | 'area_chart' + | 'gauge' + | 'table' + | 'heatmap' + | 'map' + | 'timeline' + | 'progress' + | 'list' + | 'text' + | 'image' + | 'custom'; + +export type DataSourceType = 'query' | 'api' | 'static' | 'kpi' | 'report'; + +@Entity({ schema: 'reports', name: 'dashboard_widgets' }) +@Index(['tenantId']) +@Index(['dashboardId']) +@Index(['widgetType']) +@Index(['isActive']) +export class DashboardWidget { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'dashboard_id', type: 'uuid' }) + dashboardId: string; + + @Column({ type: 'varchar', length: 200 }) + title: string; + + @Column({ type: 'text', nullable: true }) + subtitle: string | null; + + @Column({ + name: 'widget_type', + type: 'varchar', + length: 30, + }) + widgetType: WidgetType; + + @Column({ + name: 'data_source_type', + type: 'varchar', + length: 20, + default: 'query', + }) + dataSourceType: DataSourceType; + + @Column({ + name: 'data_source', + type: 'jsonb', + nullable: true, + comment: 'Query, API endpoint, or KPI code', + }) + dataSource: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Widget-specific configuration', + }) + config: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Chart options (colors, legend, etc)', + }) + chartOptions: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Threshold/alert configuration', + }) + thresholds: Record | null; + + @Column({ + name: 'grid_x', + type: 'integer', + default: 0, + comment: 'Grid position X', + }) + gridX: number; + + @Column({ + name: 'grid_y', + type: 'integer', + default: 0, + comment: 'Grid position Y', + }) + gridY: number; + + @Column({ + name: 'grid_width', + type: 'integer', + default: 4, + comment: 'Width in grid units', + }) + gridWidth: number; + + @Column({ + name: 'grid_height', + type: 'integer', + default: 2, + comment: 'Height in grid units', + }) + gridHeight: number; + + @Column({ + name: 'min_width', + type: 'integer', + default: 2, + }) + minWidth: number; + + @Column({ + name: 'min_height', + type: 'integer', + default: 1, + }) + minHeight: number; + + @Column({ + name: 'refresh_interval', + type: 'integer', + nullable: true, + comment: 'Override dashboard refresh (seconds)', + }) + refreshInterval: number | null; + + @Column({ + name: 'cache_duration', + type: 'integer', + default: 60, + comment: 'Cache duration in seconds', + }) + cacheDuration: number; + + @Column({ + name: 'drill_down_config', + type: 'jsonb', + nullable: true, + comment: 'Drill-down navigation config', + }) + drillDownConfig: Record | null; + + @Column({ + name: 'click_action', + type: 'jsonb', + nullable: true, + comment: 'Action on click (navigate, filter, etc)', + }) + clickAction: Record | null; + + @Column({ + name: 'is_active', + type: 'boolean', + default: true, + }) + isActive: boolean; + + @Column({ + name: 'sort_order', + type: 'integer', + default: 0, + }) + sortOrder: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Dashboard) + @JoinColumn({ name: 'dashboard_id' }) + dashboard: Dashboard; +} diff --git a/src/modules/reports/entities/dashboard.entity.ts b/src/modules/reports/entities/dashboard.entity.ts new file mode 100644 index 0000000..ca109be --- /dev/null +++ b/src/modules/reports/entities/dashboard.entity.ts @@ -0,0 +1,205 @@ +/** + * Dashboard Entity + * Configuración de dashboards personalizables + * + * @module Reports + * @table reports.dashboards + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { DashboardWidget } from './dashboard-widget.entity'; + +export type DashboardType = 'corporate' | 'project' | 'department' | 'personal' | 'custom'; + +export type DashboardVisibility = 'private' | 'team' | 'department' | 'company'; + +@Entity({ schema: 'reports', name: 'dashboards' }) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId']) +@Index(['dashboardType']) +@Index(['ownerId']) +@Index(['isActive']) +export class Dashboard { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + name: 'dashboard_type', + type: 'varchar', + length: 30, + default: 'custom', + }) + dashboardType: DashboardType; + + @Column({ + type: 'varchar', + length: 20, + default: 'private', + }) + visibility: DashboardVisibility; + + @Column({ + name: 'owner_id', + type: 'uuid', + nullable: true, + comment: 'User who owns this dashboard', + }) + ownerId: string | null; + + @Column({ + name: 'fraccionamiento_id', + type: 'uuid', + nullable: true, + comment: 'Project-specific dashboard', + }) + fraccionamientoId: string | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Layout configuration (grid positions)', + }) + layout: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Theme and styling configuration', + }) + theme: Record | null; + + @Column({ + name: 'refresh_interval', + type: 'integer', + default: 300, + comment: 'Auto-refresh interval in seconds', + }) + refreshInterval: number; + + @Column({ + name: 'default_date_range', + type: 'varchar', + length: 30, + default: 'last_30_days', + comment: 'Default date range filter', + }) + defaultDateRange: string; + + @Column({ + name: 'default_filters', + type: 'jsonb', + nullable: true, + }) + defaultFilters: Record | null; + + @Column({ + name: 'allowed_roles', + type: 'varchar', + array: true, + nullable: true, + comment: 'Roles that can view this dashboard', + }) + allowedRoles: string[] | null; + + @Column({ + name: 'is_default', + type: 'boolean', + default: false, + comment: 'Default dashboard for type', + }) + isDefault: boolean; + + @Column({ + name: 'is_active', + type: 'boolean', + default: true, + }) + isActive: boolean; + + @Column({ + name: 'is_system', + type: 'boolean', + default: false, + comment: 'System dashboard cannot be deleted', + }) + isSystem: boolean; + + @Column({ + name: 'sort_order', + type: 'integer', + default: 0, + }) + sortOrder: number; + + @Column({ + name: 'view_count', + type: 'integer', + default: 0, + }) + viewCount: number; + + @Column({ + name: 'last_viewed_at', + type: 'timestamptz', + nullable: true, + }) + lastViewedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'owner_id' }) + owner: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => DashboardWidget, (w) => w.dashboard) + widgets: DashboardWidget[]; +} diff --git a/src/modules/reports/entities/index.ts b/src/modules/reports/entities/index.ts new file mode 100644 index 0000000..baef642 --- /dev/null +++ b/src/modules/reports/entities/index.ts @@ -0,0 +1,10 @@ +/** + * Reports Module - Entity Exports + * MAI-006: Reportes y Analytics + */ + +export * from './report.entity'; +export * from './report-execution.entity'; +export * from './dashboard.entity'; +export * from './dashboard-widget.entity'; +export * from './kpi-snapshot.entity'; diff --git a/src/modules/reports/entities/kpi-snapshot.entity.ts b/src/modules/reports/entities/kpi-snapshot.entity.ts new file mode 100644 index 0000000..82c7e59 --- /dev/null +++ b/src/modules/reports/entities/kpi-snapshot.entity.ts @@ -0,0 +1,220 @@ +/** + * KpiSnapshot Entity + * Snapshots históricos de KPIs para análisis + * + * @module Reports + * @table reports.kpi_snapshots + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; + +export type KpiCategory = + | 'financial' + | 'progress' + | 'quality' + | 'hse' + | 'hr' + | 'inventory' + | 'sales' + | 'operational'; + +export type KpiPeriodType = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'; + +export type TrendDirection = 'up' | 'down' | 'stable'; + +@Entity({ schema: 'reports', name: 'kpi_snapshots' }) +@Index(['tenantId', 'kpiCode', 'snapshotDate']) +@Index(['tenantId']) +@Index(['kpiCode']) +@Index(['category']) +@Index(['snapshotDate']) +@Index(['fraccionamientoId']) +export class KpiSnapshot { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ + name: 'kpi_code', + type: 'varchar', + length: 50, + comment: 'Unique KPI identifier', + }) + kpiCode: string; + + @Column({ + name: 'kpi_name', + type: 'varchar', + length: 200, + }) + kpiName: string; + + @Column({ + type: 'varchar', + length: 30, + }) + category: KpiCategory; + + @Column({ + name: 'snapshot_date', + type: 'date', + }) + snapshotDate: Date; + + @Column({ + name: 'period_type', + type: 'varchar', + length: 20, + default: 'daily', + }) + periodType: KpiPeriodType; + + @Column({ + name: 'period_start', + type: 'date', + nullable: true, + }) + periodStart: Date | null; + + @Column({ + name: 'period_end', + type: 'date', + nullable: true, + }) + periodEnd: Date | null; + + @Column({ + name: 'fraccionamiento_id', + type: 'uuid', + nullable: true, + comment: 'Project-specific KPI, null for global', + }) + fraccionamientoId: string | null; + + @Column({ + type: 'decimal', + precision: 18, + scale: 4, + }) + value: number; + + @Column({ + name: 'previous_value', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + }) + previousValue: number | null; + + @Column({ + name: 'target_value', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + }) + targetValue: number | null; + + @Column({ + name: 'min_value', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + comment: 'Minimum acceptable value', + }) + minValue: number | null; + + @Column({ + name: 'max_value', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + comment: 'Maximum acceptable value', + }) + maxValue: number | null; + + @Column({ + type: 'varchar', + length: 20, + nullable: true, + comment: 'Unit of measurement', + }) + unit: string | null; + + @Column({ + name: 'change_percentage', + type: 'decimal', + precision: 8, + scale: 2, + nullable: true, + comment: 'Percentage change from previous', + }) + changePercentage: number | null; + + @Column({ + name: 'trend_direction', + type: 'varchar', + length: 10, + nullable: true, + }) + trendDirection: TrendDirection | null; + + @Column({ + name: 'is_on_target', + type: 'boolean', + nullable: true, + }) + isOnTarget: boolean | null; + + @Column({ + name: 'status_color', + type: 'varchar', + length: 20, + nullable: true, + comment: 'green, yellow, red based on thresholds', + }) + statusColor: string | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Additional breakdown data', + }) + breakdown: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Source data references', + }) + metadata: Record | null; + + @Column({ + name: 'calculated_at', + type: 'timestamptz', + default: () => 'CURRENT_TIMESTAMP', + }) + calculatedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; +} diff --git a/src/modules/reports/entities/report-execution.entity.ts b/src/modules/reports/entities/report-execution.entity.ts new file mode 100644 index 0000000..57fa312 --- /dev/null +++ b/src/modules/reports/entities/report-execution.entity.ts @@ -0,0 +1,191 @@ +/** + * ReportExecution Entity + * Historial de ejecuciones de reportes + * + * @module Reports + * @table reports.report_executions + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Report, ReportFormat } from './report.entity'; + +export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + +@Entity({ schema: 'reports', name: 'report_executions' }) +@Index(['tenantId']) +@Index(['reportId']) +@Index(['status']) +@Index(['executedAt']) +@Index(['executedById']) +export class ReportExecution { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'report_id', type: 'uuid' }) + reportId: string; + + @Column({ + type: 'varchar', + length: 20, + default: 'pending', + }) + status: ExecutionStatus; + + @Column({ + type: 'varchar', + length: 20, + }) + format: ReportFormat; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Parameters used for this execution', + }) + parameters: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Filters applied', + }) + filters: Record | null; + + @Column({ + name: 'started_at', + type: 'timestamptz', + nullable: true, + }) + startedAt: Date | null; + + @Column({ + name: 'completed_at', + type: 'timestamptz', + nullable: true, + }) + completedAt: Date | null; + + @Column({ + name: 'duration_ms', + type: 'integer', + nullable: true, + comment: 'Execution duration in milliseconds', + }) + durationMs: number | null; + + @Column({ + name: 'row_count', + type: 'integer', + nullable: true, + }) + rowCount: number | null; + + @Column({ + name: 'file_path', + type: 'varchar', + length: 500, + nullable: true, + }) + filePath: string | null; + + @Column({ + name: 'file_size', + type: 'integer', + nullable: true, + comment: 'File size in bytes', + }) + fileSize: number | null; + + @Column({ + name: 'file_url', + type: 'varchar', + length: 1000, + nullable: true, + comment: 'Presigned URL or download URL', + }) + fileUrl: string | null; + + @Column({ + name: 'url_expires_at', + type: 'timestamptz', + nullable: true, + }) + urlExpiresAt: Date | null; + + @Column({ + name: 'error_message', + type: 'text', + nullable: true, + }) + errorMessage: string | null; + + @Column({ + name: 'error_stack', + type: 'text', + nullable: true, + }) + errorStack: string | null; + + @Column({ + name: 'is_scheduled', + type: 'boolean', + default: false, + comment: 'Was this a scheduled execution?', + }) + isScheduled: boolean; + + @Column({ + name: 'distributed_to', + type: 'varchar', + array: true, + nullable: true, + comment: 'Email addresses report was sent to', + }) + distributedTo: string[] | null; + + @Column({ + name: 'distributed_at', + type: 'timestamptz', + nullable: true, + }) + distributedAt: Date | null; + + @Column({ + name: 'executed_at', + type: 'timestamptz', + default: () => 'CURRENT_TIMESTAMP', + }) + executedAt: Date; + + @Column({ name: 'executed_by', type: 'uuid', nullable: true }) + executedById: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Report) + @JoinColumn({ name: 'report_id' }) + report: Report; + + @ManyToOne(() => User) + @JoinColumn({ name: 'executed_by' }) + executedBy: User | null; +} diff --git a/src/modules/reports/entities/report.entity.ts b/src/modules/reports/entities/report.entity.ts new file mode 100644 index 0000000..7117fef --- /dev/null +++ b/src/modules/reports/entities/report.entity.ts @@ -0,0 +1,222 @@ +/** + * Report Entity + * Definición de reportes configurables + * + * @module Reports + * @table reports.report_definitions + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { ReportExecution } from './report-execution.entity'; + +export type ReportType = + | 'financial' + | 'progress' + | 'quality' + | 'hse' + | 'hr' + | 'inventory' + | 'contracts' + | 'executive' + | 'custom'; + +export type ReportFormat = 'pdf' | 'excel' | 'csv' | 'html' | 'json'; + +export type ReportFrequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'; + +@Entity({ schema: 'reports', name: 'report_definitions' }) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId']) +@Index(['reportType']) +@Index(['isActive']) +export class Report { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + name: 'report_type', + type: 'varchar', + length: 30, + }) + reportType: ReportType; + + @Column({ + name: 'default_format', + type: 'varchar', + length: 20, + default: 'pdf', + }) + defaultFormat: ReportFormat; + + @Column({ + name: 'available_formats', + type: 'varchar', + array: true, + default: ['pdf', 'excel'], + }) + availableFormats: ReportFormat[]; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'SQL query or data source configuration', + }) + query: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Report parameters definition', + }) + parameters: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Column/field definitions', + }) + columns: Record[] | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Grouping and aggregation config', + }) + grouping: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Sorting configuration', + }) + sorting: Record[] | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Default filters', + }) + filters: Record | null; + + @Column({ + name: 'template_path', + type: 'varchar', + length: 500, + nullable: true, + }) + templatePath: string | null; + + @Column({ + name: 'is_scheduled', + type: 'boolean', + default: false, + }) + isScheduled: boolean; + + @Column({ + type: 'varchar', + length: 20, + nullable: true, + }) + frequency: ReportFrequency | null; + + @Column({ + name: 'schedule_config', + type: 'jsonb', + nullable: true, + comment: 'Cron or schedule configuration', + }) + scheduleConfig: Record | null; + + @Column({ + name: 'distribution_list', + type: 'varchar', + array: true, + nullable: true, + comment: 'Email addresses for distribution', + }) + distributionList: string[] | null; + + @Column({ + name: 'is_active', + type: 'boolean', + default: true, + }) + isActive: boolean; + + @Column({ + name: 'is_system', + type: 'boolean', + default: false, + comment: 'System report cannot be deleted', + }) + isSystem: boolean; + + @Column({ + name: 'execution_count', + type: 'integer', + default: 0, + }) + executionCount: number; + + @Column({ + name: 'last_executed_at', + type: 'timestamptz', + nullable: true, + }) + lastExecutedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => ReportExecution, (e) => e.report) + executions: ReportExecution[]; +} diff --git a/src/modules/reports/services/dashboard.service.ts b/src/modules/reports/services/dashboard.service.ts new file mode 100644 index 0000000..6103fab --- /dev/null +++ b/src/modules/reports/services/dashboard.service.ts @@ -0,0 +1,471 @@ +/** + * DashboardService - Gestión de Dashboards + * + * Administra dashboards personalizables y sus widgets. + * + * @module Reports + */ + +import { Repository, DataSource } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Dashboard, DashboardType, DashboardVisibility } from '../entities/dashboard.entity'; +import { DashboardWidget, WidgetType, DataSourceType } from '../entities/dashboard-widget.entity'; + +export interface CreateDashboardDto { + code: string; + name: string; + description?: string; + dashboardType?: DashboardType; + visibility?: DashboardVisibility; + fraccionamientoId?: string; + layout?: Record; + theme?: Record; + refreshInterval?: number; + defaultDateRange?: string; + defaultFilters?: Record; + allowedRoles?: string[]; +} + +export interface UpdateDashboardDto extends Partial { + isDefault?: boolean; + isActive?: boolean; + sortOrder?: number; +} + +export interface CreateWidgetDto { + dashboardId: string; + title: string; + subtitle?: string; + widgetType: WidgetType; + dataSourceType?: DataSourceType; + dataSource?: Record; + config?: Record; + chartOptions?: Record; + thresholds?: Record; + gridX?: number; + gridY?: number; + gridWidth?: number; + gridHeight?: number; + refreshInterval?: number; + cacheDuration?: number; + drillDownConfig?: Record; + clickAction?: Record; +} + +export interface UpdateWidgetDto extends Partial> { + isActive?: boolean; + sortOrder?: number; +} + +export interface DashboardFilters { + dashboardType?: DashboardType; + visibility?: DashboardVisibility; + ownerId?: string; + fraccionamientoId?: string; + isActive?: boolean; + search?: string; +} + +export class DashboardService extends BaseService { + private widgetRepository: Repository; + + constructor( + repository: Repository, + dataSource: DataSource + ) { + super(repository); + this.widgetRepository = dataSource.getRepository(DashboardWidget); + } + + /** + * Buscar dashboards con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: DashboardFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('d') + .leftJoinAndSelect('d.widgets', 'w', 'w.deleted_at IS NULL AND w.is_active = true') + .where('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('d.deleted_at IS NULL'); + + if (filters.dashboardType) { + qb.andWhere('d.dashboard_type = :dashboardType', { dashboardType: filters.dashboardType }); + } + if (filters.visibility) { + qb.andWhere('d.visibility = :visibility', { visibility: filters.visibility }); + } + if (filters.ownerId) { + qb.andWhere('d.owner_id = :ownerId', { ownerId: filters.ownerId }); + } + if (filters.fraccionamientoId) { + qb.andWhere('d.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId: filters.fraccionamientoId }); + } + if (filters.isActive !== undefined) { + qb.andWhere('d.is_active = :isActive', { isActive: filters.isActive }); + } + if (filters.search) { + qb.andWhere('(d.name ILIKE :search OR d.code ILIKE :search OR d.description ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const skip = (page - 1) * limit; + qb.orderBy('d.sort_order', 'ASC') + .addOrderBy('d.name', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Buscar dashboard por código + */ + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.repository.findOne({ + where: { + tenantId: ctx.tenantId, + code, + deletedAt: null, + } as any, + relations: ['widgets'], + }); + } + + /** + * Obtener dashboard con widgets + */ + async findByIdWithWidgets(ctx: ServiceContext, id: string): Promise { + return this.repository + .createQueryBuilder('d') + .leftJoinAndSelect('d.widgets', 'w', 'w.deleted_at IS NULL') + .where('d.id = :id', { id }) + .andWhere('d.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('d.deleted_at IS NULL') + .orderBy('w.sort_order', 'ASC') + .getOne(); + } + + /** + * Obtener dashboards por tipo + */ + async findByType(ctx: ServiceContext, dashboardType: DashboardType): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + dashboardType, + isActive: true, + deletedAt: null, + } as any, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + } + + /** + * Obtener dashboards del usuario + */ + async findByOwner(ctx: ServiceContext, ownerId: string): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + ownerId, + deletedAt: null, + } as any, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + } + + /** + * Obtener dashboard por defecto por tipo + */ + async findDefault(ctx: ServiceContext, dashboardType: DashboardType): Promise { + return this.repository.findOne({ + where: { + tenantId: ctx.tenantId, + dashboardType, + isDefault: true, + isActive: true, + deletedAt: null, + } as any, + relations: ['widgets'], + }); + } + + /** + * Crear dashboard + */ + async createDashboard(ctx: ServiceContext, data: CreateDashboardDto): Promise { + const existing = await this.findByCode(ctx, data.code); + if (existing) { + throw new Error(`Dashboard with code ${data.code} already exists`); + } + + return this.create(ctx, { + ...data, + ownerId: ctx.userId, + isActive: true, + isSystem: false, + isDefault: false, + viewCount: 0, + }); + } + + /** + * Establecer dashboard por defecto + */ + async setDefault(ctx: ServiceContext, id: string): Promise { + const dashboard = await this.findById(ctx, id); + if (!dashboard) { + return null; + } + + // Quitar default de otros dashboards del mismo tipo + await this.repository.update( + { + tenantId: ctx.tenantId, + dashboardType: dashboard.dashboardType, + isDefault: true, + } as any, + { isDefault: false } as any + ); + + return this.update(ctx, id, { isDefault: true }); + } + + /** + * Registrar vista de dashboard + */ + async recordView(ctx: ServiceContext, id: string): Promise { + await this.repository.update( + { id, tenantId: ctx.tenantId } as any, + { + viewCount: () => 'view_count + 1', + lastViewedAt: new Date(), + } as any + ); + } + + /** + * Duplicar dashboard + */ + async duplicate( + ctx: ServiceContext, + id: string, + newCode: string, + newName: string + ): Promise { + const original = await this.findByIdWithWidgets(ctx, id); + if (!original) { + throw new Error('Dashboard not found'); + } + + // Crear copia del dashboard + const newDashboard = await this.create(ctx, { + code: newCode, + name: newName, + description: original.description, + dashboardType: original.dashboardType, + visibility: 'private', + layout: original.layout, + theme: original.theme, + refreshInterval: original.refreshInterval, + defaultDateRange: original.defaultDateRange, + defaultFilters: original.defaultFilters, + ownerId: ctx.userId, + isDefault: false, + isSystem: false, + isActive: true, + }); + + // Copiar widgets + if (original.widgets?.length) { + for (const widget of original.widgets) { + await this.createWidget(ctx, { + dashboardId: newDashboard.id, + title: widget.title, + subtitle: widget.subtitle || undefined, + widgetType: widget.widgetType, + dataSourceType: widget.dataSourceType, + dataSource: widget.dataSource || undefined, + config: widget.config || undefined, + chartOptions: widget.chartOptions || undefined, + thresholds: widget.thresholds || undefined, + gridX: widget.gridX, + gridY: widget.gridY, + gridWidth: widget.gridWidth, + gridHeight: widget.gridHeight, + refreshInterval: widget.refreshInterval || undefined, + cacheDuration: widget.cacheDuration, + drillDownConfig: widget.drillDownConfig || undefined, + clickAction: widget.clickAction || undefined, + }); + } + } + + return this.findByIdWithWidgets(ctx, newDashboard.id) as Promise; + } + + // ==================== Widget Methods ==================== + + /** + * Crear widget + */ + async createWidget(ctx: ServiceContext, data: CreateWidgetDto): Promise { + const dashboard = await this.findById(ctx, data.dashboardId); + if (!dashboard) { + throw new Error('Dashboard not found'); + } + + const widget = this.widgetRepository.create({ + tenantId: ctx.tenantId, + ...data, + isActive: true, + createdById: ctx.userId, + }); + + return this.widgetRepository.save(widget); + } + + /** + * Actualizar widget + */ + async updateWidget( + ctx: ServiceContext, + widgetId: string, + data: UpdateWidgetDto + ): Promise { + const widget = await this.widgetRepository.findOne({ + where: { + id: widgetId, + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + }); + + if (!widget) { + return null; + } + + const updated = this.widgetRepository.merge(widget, { + ...data, + updatedById: ctx.userId, + }); + + return this.widgetRepository.save(updated); + } + + /** + * Eliminar widget (soft delete) + */ + async deleteWidget(ctx: ServiceContext, widgetId: string): Promise { + const result = await this.widgetRepository.update( + { + id: widgetId, + tenantId: ctx.tenantId, + } as any, + { + deletedAt: new Date(), + } as any + ); + + return (result.affected ?? 0) > 0; + } + + /** + * Obtener widgets de un dashboard + */ + async getWidgets(ctx: ServiceContext, dashboardId: string): Promise { + return this.widgetRepository.find({ + where: { + tenantId: ctx.tenantId, + dashboardId, + deletedAt: null, + } as any, + order: { sortOrder: 'ASC' }, + }); + } + + /** + * Actualizar posiciones de widgets + */ + async updateWidgetPositions( + ctx: ServiceContext, + dashboardId: string, + positions: { id: string; gridX: number; gridY: number; gridWidth: number; gridHeight: number }[] + ): Promise { + for (const pos of positions) { + await this.widgetRepository.update( + { + id: pos.id, + dashboardId, + tenantId: ctx.tenantId, + } as any, + { + gridX: pos.gridX, + gridY: pos.gridY, + gridWidth: pos.gridWidth, + gridHeight: pos.gridHeight, + } as any + ); + } + } + + /** + * Estadísticas de dashboards + */ + async getStats(ctx: ServiceContext): Promise { + const dashboards = await this.repository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + }); + + const byType = new Map(); + let activeCount = 0; + let totalViews = 0; + + dashboards.forEach((d) => { + byType.set(d.dashboardType, (byType.get(d.dashboardType) || 0) + 1); + if (d.isActive) activeCount++; + totalViews += d.viewCount; + }); + + const widgetCount = await this.widgetRepository.count({ + where: { + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + }); + + return { + totalDashboards: dashboards.length, + activeDashboards: activeCount, + totalWidgets: widgetCount, + totalViews, + byType: Array.from(byType.entries()).map(([type, count]) => ({ type, count })), + }; + } +} + +export interface DashboardStats { + totalDashboards: number; + activeDashboards: number; + totalWidgets: number; + totalViews: number; + byType: { type: DashboardType; count: number }[]; +} diff --git a/src/modules/reports/services/index.ts b/src/modules/reports/services/index.ts new file mode 100644 index 0000000..ddefa06 --- /dev/null +++ b/src/modules/reports/services/index.ts @@ -0,0 +1,8 @@ +/** + * Reports Module - Service Exports + * MAI-006: Reportes y Analytics + */ + +export * from './report.service'; +export * from './dashboard.service'; +export * from './kpi.service'; diff --git a/src/modules/reports/services/kpi.service.ts b/src/modules/reports/services/kpi.service.ts new file mode 100644 index 0000000..5e5ad0e --- /dev/null +++ b/src/modules/reports/services/kpi.service.ts @@ -0,0 +1,425 @@ +/** + * KpiService - Gestión de KPIs y Analytics + * + * Administra snapshots de KPIs, cálculos y tendencias. + * + * @module Reports + */ + +import { Repository, LessThanOrEqual } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { KpiSnapshot, KpiCategory, KpiPeriodType, TrendDirection } from '../entities/kpi-snapshot.entity'; + +export interface CreateKpiSnapshotDto { + kpiCode: string; + kpiName: string; + category: KpiCategory; + snapshotDate: Date; + periodType?: KpiPeriodType; + periodStart?: Date; + periodEnd?: Date; + fraccionamientoId?: string; + value: number; + previousValue?: number; + targetValue?: number; + minValue?: number; + maxValue?: number; + unit?: string; + breakdown?: Record; + metadata?: Record; +} + +export interface KpiFilters { + kpiCode?: string; + category?: KpiCategory; + periodType?: KpiPeriodType; + fraccionamientoId?: string; + dateFrom?: Date; + dateTo?: Date; +} + +export interface KpiTrendData { + kpiCode: string; + kpiName: string; + category: KpiCategory; + currentValue: number; + previousValue: number | null; + targetValue: number | null; + changePercentage: number | null; + trend: TrendDirection; + isOnTarget: boolean; + statusColor: string; + unit: string | null; + history: { date: Date; value: number }[]; +} + +export interface KpiSummary { + category: KpiCategory; + kpis: { + code: string; + name: string; + value: number; + target: number | null; + trend: TrendDirection; + statusColor: string; + unit: string | null; + }[]; +} + +export class KpiService extends BaseService { + constructor(repository: Repository) { + super(repository); + } + + /** + * Buscar snapshots con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: KpiFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('k') + .where('k.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.kpiCode) { + qb.andWhere('k.kpi_code = :kpiCode', { kpiCode: filters.kpiCode }); + } + if (filters.category) { + qb.andWhere('k.category = :category', { category: filters.category }); + } + if (filters.periodType) { + qb.andWhere('k.period_type = :periodType', { periodType: filters.periodType }); + } + if (filters.fraccionamientoId) { + qb.andWhere('k.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId: filters.fraccionamientoId }); + } + if (filters.dateFrom) { + qb.andWhere('k.snapshot_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('k.snapshot_date <= :dateTo', { dateTo: filters.dateTo }); + } + + const skip = (page - 1) * limit; + qb.orderBy('k.snapshot_date', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Crear snapshot de KPI + */ + async createSnapshot(ctx: ServiceContext, data: CreateKpiSnapshotDto): Promise { + // Calcular cambio porcentual y tendencia + const changePercentage = data.previousValue + ? ((data.value - data.previousValue) / Math.abs(data.previousValue)) * 100 + : null; + + let trendDirection: TrendDirection = 'stable'; + if (changePercentage !== null) { + if (changePercentage > 1) trendDirection = 'up'; + else if (changePercentage < -1) trendDirection = 'down'; + } + + // Determinar si está en objetivo + let isOnTarget: boolean | null = null; + let statusColor = 'gray'; + if (data.targetValue !== undefined) { + isOnTarget = data.value >= data.targetValue; + if (isOnTarget) { + statusColor = 'green'; + } else if (data.value >= data.targetValue * 0.9) { + statusColor = 'yellow'; + } else { + statusColor = 'red'; + } + } + + return this.create(ctx, { + ...data, + changePercentage, + trendDirection, + isOnTarget, + statusColor, + }); + } + + /** + * Obtener último snapshot de un KPI + */ + async getLatestSnapshot( + ctx: ServiceContext, + kpiCode: string, + fraccionamientoId?: string + ): Promise { + const where: any = { + tenantId: ctx.tenantId, + kpiCode, + }; + if (fraccionamientoId) { + where.fraccionamientoId = fraccionamientoId; + } + + return this.repository.findOne({ + where, + order: { snapshotDate: 'DESC' }, + }); + } + + /** + * Obtener historial de un KPI + */ + async getKpiHistory( + ctx: ServiceContext, + kpiCode: string, + dateFrom: Date, + dateTo: Date, + fraccionamientoId?: string + ): Promise { + const qb = this.repository + .createQueryBuilder('k') + .where('k.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('k.kpi_code = :kpiCode', { kpiCode }) + .andWhere('k.snapshot_date BETWEEN :dateFrom AND :dateTo', { dateFrom, dateTo }); + + if (fraccionamientoId) { + qb.andWhere('k.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + return qb.orderBy('k.snapshot_date', 'ASC').getMany(); + } + + /** + * Obtener tendencia de KPI con historial + */ + async getKpiTrend( + ctx: ServiceContext, + kpiCode: string, + days = 30, + fraccionamientoId?: string + ): Promise { + const dateTo = new Date(); + const dateFrom = new Date(); + dateFrom.setDate(dateFrom.getDate() - days); + + const history = await this.getKpiHistory(ctx, kpiCode, dateFrom, dateTo, fraccionamientoId); + if (!history.length) { + return null; + } + + const latest = history[history.length - 1]; + const previous = history.length > 1 ? history[history.length - 2] : null; + + return { + kpiCode: latest.kpiCode, + kpiName: latest.kpiName, + category: latest.category, + currentValue: Number(latest.value), + previousValue: previous ? Number(previous.value) : null, + targetValue: latest.targetValue ? Number(latest.targetValue) : null, + changePercentage: latest.changePercentage ? Number(latest.changePercentage) : null, + trend: latest.trendDirection || 'stable', + isOnTarget: latest.isOnTarget || false, + statusColor: latest.statusColor || 'gray', + unit: latest.unit, + history: history.map((h) => ({ + date: h.snapshotDate, + value: Number(h.value), + })), + }; + } + + /** + * Obtener resumen de KPIs por categoría + */ + async getSummaryByCategory( + ctx: ServiceContext, + category: KpiCategory, + fraccionamientoId?: string + ): Promise { + // Obtener los KPIs únicos de la categoría + const qb = this.repository + .createQueryBuilder('k') + .select('DISTINCT k.kpi_code', 'kpiCode') + .addSelect('k.kpi_name', 'kpiName') + .where('k.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('k.category = :category', { category }); + + if (fraccionamientoId) { + qb.andWhere('k.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }); + } + + const kpiCodes = await qb.getRawMany(); + + const kpis = []; + for (const kpi of kpiCodes) { + const latest = await this.getLatestSnapshot(ctx, kpi.kpiCode, fraccionamientoId); + if (latest) { + kpis.push({ + code: latest.kpiCode, + name: latest.kpiName, + value: Number(latest.value), + target: latest.targetValue ? Number(latest.targetValue) : null, + trend: latest.trendDirection || 'stable', + statusColor: latest.statusColor || 'gray', + unit: latest.unit, + }); + } + } + + return { + category, + kpis, + }; + } + + /** + * Obtener dashboard de KPIs + */ + async getDashboardKpis( + ctx: ServiceContext, + fraccionamientoId?: string + ): Promise<{ [category: string]: KpiSummary }> { + const categories: KpiCategory[] = [ + 'financial', + 'progress', + 'quality', + 'hse', + 'hr', + 'inventory', + 'operational', + ]; + + const result: { [category: string]: KpiSummary } = {}; + + for (const category of categories) { + const summary = await this.getSummaryByCategory(ctx, category, fraccionamientoId); + if (summary.kpis.length > 0) { + result[category] = summary; + } + } + + return result; + } + + /** + * Comparar KPIs entre proyectos + */ + async compareProjects( + ctx: ServiceContext, + kpiCode: string, + fraccionamientoIds: string[], + snapshotDate?: Date + ): Promise<{ fraccionamientoId: string; value: number; target: number | null }[]> { + const date = snapshotDate || new Date(); + + const results = []; + for (const fraccionamientoId of fraccionamientoIds) { + const snapshot = await this.repository.findOne({ + where: { + tenantId: ctx.tenantId, + kpiCode, + fraccionamientoId, + snapshotDate: LessThanOrEqual(date), + } as any, + order: { snapshotDate: 'DESC' }, + }); + + if (snapshot) { + results.push({ + fraccionamientoId, + value: Number(snapshot.value), + target: snapshot.targetValue ? Number(snapshot.targetValue) : null, + }); + } + } + + return results; + } + + /** + * Calcular y guardar snapshot masivo (para jobs) + */ + async bulkCreateSnapshots( + ctx: ServiceContext, + snapshots: CreateKpiSnapshotDto[] + ): Promise<{ created: number; errors: number }> { + let created = 0; + let errors = 0; + + for (const data of snapshots) { + try { + await this.createSnapshot(ctx, data); + created++; + } catch { + errors++; + } + } + + return { created, errors }; + } + + /** + * Estadísticas de KPIs + */ + async getStats(ctx: ServiceContext): Promise { + const qb = this.repository + .createQueryBuilder('k') + .select('k.category', 'category') + .addSelect('COUNT(DISTINCT k.kpi_code)', 'uniqueKpis') + .addSelect('COUNT(*)', 'totalSnapshots') + .where('k.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .groupBy('k.category'); + + const categoryStats = await qb.getRawMany(); + + const onTarget = await this.repository.count({ + where: { + tenantId: ctx.tenantId, + isOnTarget: true, + } as any, + }); + + const offTarget = await this.repository.count({ + where: { + tenantId: ctx.tenantId, + isOnTarget: false, + } as any, + }); + + return { + byCategory: categoryStats.map((s) => ({ + category: s.category as KpiCategory, + uniqueKpis: parseInt(s.uniqueKpis), + totalSnapshots: parseInt(s.totalSnapshots), + })), + onTargetCount: onTarget, + offTargetCount: offTarget, + healthPercentage: onTarget + offTarget > 0 ? (onTarget / (onTarget + offTarget)) * 100 : 0, + }; + } +} + +export interface KpiStats { + byCategory: { + category: KpiCategory; + uniqueKpis: number; + totalSnapshots: number; + }[]; + onTargetCount: number; + offTargetCount: number; + healthPercentage: number; +} diff --git a/src/modules/reports/services/report.service.ts b/src/modules/reports/services/report.service.ts new file mode 100644 index 0000000..12cc465 --- /dev/null +++ b/src/modules/reports/services/report.service.ts @@ -0,0 +1,364 @@ +/** + * ReportService - Gestión de Reportes + * + * Administra definiciones de reportes, ejecuciones y distribución. + * + * @module Reports + */ + +import { Repository, DataSource } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Report, ReportType, ReportFormat, ReportFrequency } from '../entities/report.entity'; +import { ReportExecution, ExecutionStatus } from '../entities/report-execution.entity'; + +export interface CreateReportDto { + code: string; + name: string; + description?: string; + reportType: ReportType; + defaultFormat?: ReportFormat; + availableFormats?: ReportFormat[]; + query?: Record; + parameters?: Record; + columns?: Record[]; + grouping?: Record; + sorting?: Record[]; + filters?: Record; + templatePath?: string; + isScheduled?: boolean; + frequency?: ReportFrequency; + scheduleConfig?: Record; + distributionList?: string[]; +} + +export interface UpdateReportDto extends Partial { + isActive?: boolean; +} + +export interface ReportFilters { + reportType?: ReportType; + isActive?: boolean; + isScheduled?: boolean; + search?: string; +} + +export interface ExecuteReportDto { + format?: ReportFormat; + parameters?: Record; + filters?: Record; + distribute?: boolean; +} + +export class ReportService extends BaseService { + private executionRepository: Repository; + + constructor( + repository: Repository, + dataSource: DataSource + ) { + super(repository); + this.executionRepository = dataSource.getRepository(ReportExecution); + } + + /** + * Buscar reportes con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: ReportFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('r') + .where('r.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('r.deleted_at IS NULL'); + + if (filters.reportType) { + qb.andWhere('r.report_type = :reportType', { reportType: filters.reportType }); + } + if (filters.isActive !== undefined) { + qb.andWhere('r.is_active = :isActive', { isActive: filters.isActive }); + } + if (filters.isScheduled !== undefined) { + qb.andWhere('r.is_scheduled = :isScheduled', { isScheduled: filters.isScheduled }); + } + if (filters.search) { + qb.andWhere('(r.name ILIKE :search OR r.code ILIKE :search OR r.description ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const skip = (page - 1) * limit; + qb.orderBy('r.name', 'ASC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Buscar reporte por código + */ + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.repository.findOne({ + where: { + tenantId: ctx.tenantId, + code, + deletedAt: null, + } as any, + }); + } + + /** + * Obtener reportes por tipo + */ + async findByType(ctx: ServiceContext, reportType: ReportType): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + reportType, + isActive: true, + deletedAt: null, + } as any, + order: { name: 'ASC' }, + }); + } + + /** + * Obtener reportes programados + */ + async findScheduled(ctx: ServiceContext): Promise { + return this.repository.find({ + where: { + tenantId: ctx.tenantId, + isScheduled: true, + isActive: true, + deletedAt: null, + } as any, + order: { name: 'ASC' }, + }); + } + + /** + * Crear reporte + */ + async createReport(ctx: ServiceContext, data: CreateReportDto): Promise { + const existing = await this.findByCode(ctx, data.code); + if (existing) { + throw new Error(`Report with code ${data.code} already exists`); + } + + return this.create(ctx, { + ...data, + isActive: true, + isSystem: false, + executionCount: 0, + }); + } + + /** + * Ejecutar reporte + */ + async executeReport( + ctx: ServiceContext, + reportId: string, + options: ExecuteReportDto + ): Promise { + const report = await this.findById(ctx, reportId); + if (!report) { + throw new Error('Report not found'); + } + + if (!report.isActive) { + throw new Error('Report is not active'); + } + + // Crear registro de ejecución + const execution = this.executionRepository.create({ + tenantId: ctx.tenantId, + reportId, + status: 'pending' as ExecutionStatus, + format: options.format || report.defaultFormat, + parameters: options.parameters || report.parameters, + filters: options.filters || report.filters, + isScheduled: false, + executedById: ctx.userId, + }); + + const savedExecution = await this.executionRepository.save(execution); + + // En una implementación real, aquí se ejecutaría el reporte de forma asíncrona + // Por ahora, simulamos el proceso + try { + await this.processReportExecution(ctx, savedExecution, report, options); + } catch (error) { + await this.markExecutionFailed(savedExecution.id, error as Error); + throw error; + } + + // Actualizar contador de ejecuciones + await this.repository.update( + { id: reportId } as any, + { + executionCount: () => 'execution_count + 1', + lastExecutedAt: new Date(), + } as any + ); + + return this.executionRepository.findOne({ + where: { id: savedExecution.id } as any, + }) as Promise; + } + + /** + * Procesar ejecución del reporte + */ + private async processReportExecution( + _ctx: ServiceContext, + execution: ReportExecution, + _report: Report, + _options: ExecuteReportDto + ): Promise { + const startTime = Date.now(); + + await this.executionRepository.update( + { id: execution.id } as any, + { status: 'running', startedAt: new Date() } as any + ); + + // Aquí iría la lógica real de generación del reporte + // Por ahora simulamos una ejecución exitosa + const duration = Date.now() - startTime; + + await this.executionRepository.update( + { id: execution.id } as any, + { + status: 'completed', + completedAt: new Date(), + durationMs: duration, + rowCount: 0, // Se calcularía del resultado real + } as any + ); + } + + /** + * Marcar ejecución como fallida + */ + private async markExecutionFailed(executionId: string, error: Error): Promise { + await this.executionRepository.update( + { id: executionId } as any, + { + status: 'failed', + completedAt: new Date(), + errorMessage: error.message, + errorStack: error.stack, + } as any + ); + } + + /** + * Obtener historial de ejecuciones + */ + async getExecutionHistory( + ctx: ServiceContext, + reportId: string, + page = 1, + limit = 20 + ): Promise> { + const skip = (page - 1) * limit; + + const [data, total] = await this.executionRepository.findAndCount({ + where: { + tenantId: ctx.tenantId, + reportId, + } as any, + order: { executedAt: 'DESC' }, + skip, + take: limit, + }); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Obtener última ejecución + */ + async getLastExecution(ctx: ServiceContext, reportId: string): Promise { + return this.executionRepository.findOne({ + where: { + tenantId: ctx.tenantId, + reportId, + } as any, + order: { executedAt: 'DESC' }, + }); + } + + /** + * Obtener ejecución por ID + */ + async getExecution(ctx: ServiceContext, executionId: string): Promise { + return this.executionRepository.findOne({ + where: { + tenantId: ctx.tenantId, + id: executionId, + } as any, + }); + } + + /** + * Estadísticas de reportes + */ + async getStats(ctx: ServiceContext): Promise { + const reports = await this.repository.find({ + where: { + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + }); + + const byType = new Map(); + let activeCount = 0; + let scheduledCount = 0; + let totalExecutions = 0; + + reports.forEach((r) => { + byType.set(r.reportType, (byType.get(r.reportType) || 0) + 1); + if (r.isActive) activeCount++; + if (r.isScheduled) scheduledCount++; + totalExecutions += r.executionCount; + }); + + return { + totalReports: reports.length, + activeReports: activeCount, + scheduledReports: scheduledCount, + totalExecutions, + byType: Array.from(byType.entries()).map(([type, count]) => ({ type, count })), + }; + } +} + +export interface ReportStats { + totalReports: number; + activeReports: number; + scheduledReports: number; + totalExecutions: number; + byType: { type: ReportType; count: number }[]; +} diff --git a/src/modules/users/controllers/index.ts b/src/modules/users/controllers/index.ts new file mode 100644 index 0000000..01880ac --- /dev/null +++ b/src/modules/users/controllers/index.ts @@ -0,0 +1 @@ +export * from './users.controller'; diff --git a/src/modules/users/controllers/users.controller.ts b/src/modules/users/controllers/users.controller.ts new file mode 100644 index 0000000..7bb5194 --- /dev/null +++ b/src/modules/users/controllers/users.controller.ts @@ -0,0 +1,270 @@ +/** + * UsersController - Controlador de usuarios + * + * Endpoints REST para CRUD de usuarios y asignación de roles. + * + * @module Users + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { UsersService, CreateUserDto, UpdateUserDto } from '../services/users.service'; +import { AuthMiddleware } from '../../auth/middleware/auth.middleware'; +import { AuthService } from '../../auth/services/auth.service'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Role } from '../../auth/entities/role.entity'; +import { UserRole } from '../../auth/entities/user-role.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; + +/** + * Crear router de usuarios + */ +export function createUsersController(dataSource: DataSource): Router { + const router = Router(); + + // Repositorios + const userRepository = dataSource.getRepository(User); + const tenantRepository = dataSource.getRepository(Tenant); + const roleRepository = dataSource.getRepository(Role); + const userRoleRepository = dataSource.getRepository(UserRole); + const refreshTokenRepository = dataSource.getRepository(RefreshToken); + + // Servicios + const usersService = new UsersService(userRepository, roleRepository, userRoleRepository); + const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any); + const authMiddleware = new AuthMiddleware(authService, dataSource); + + /** + * GET /users + * Listar usuarios del tenant + */ + router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const search = req.query.search as string; + const isActive = req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined; + + const result = await usersService.findAll({ tenantId, page, limit, search, isActive }); + + res.status(200).json({ + success: true, + data: result.users, + pagination: { + page, + limit, + total: result.total, + totalPages: Math.ceil(result.total / limit), + }, + }); + } catch (error) { + next(error); + } + }); + + /** + * GET /users/roles + * Listar roles disponibles + */ + router.get('/roles', authMiddleware.authenticate, async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const roles = await usersService.listRoles(); + res.status(200).json({ success: true, data: roles }); + } catch (error) { + next(error); + } + }); + + /** + * GET /users/:id + * Obtener usuario por ID + */ + router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const user = await usersService.findById(req.params.id, tenantId); + if (!user) { + res.status(404).json({ error: 'Not Found', message: 'User not found' }); + return; + } + + const roles = await usersService.getUserRoles(user.id, tenantId); + + res.status(200).json({ + success: true, + data: { ...user, assignedRoles: roles }, + }); + } catch (error) { + next(error); + } + }); + + /** + * POST /users + * Crear usuario + */ + router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'super_admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: CreateUserDto = { + ...req.body, + tenantId, + }; + + if (!dto.email || !dto.password || !dto.firstName || !dto.lastName) { + res.status(400).json({ + error: 'Bad Request', + message: 'Email, password, firstName and lastName are required', + }); + return; + } + + const user = await usersService.create(dto, req.user?.sub); + res.status(201).json({ success: true, data: user }); + } catch (error) { + if (error instanceof Error && error.message === 'Email already exists in this tenant') { + res.status(409).json({ error: 'Conflict', message: error.message }); + return; + } + next(error); + } + }); + + /** + * PATCH /users/:id + * Actualizar usuario + */ + router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'super_admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const dto: UpdateUserDto = req.body; + const user = await usersService.update(req.params.id, tenantId, dto); + res.status(200).json({ success: true, data: user }); + } catch (error) { + if (error instanceof Error && error.message === 'User not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * DELETE /users/:id + * Eliminar usuario (soft delete) + */ + router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'super_admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + await usersService.delete(req.params.id, tenantId, req.user?.sub); + res.status(200).json({ success: true, message: 'User deleted' }); + } catch (error) { + if (error instanceof Error && error.message === 'User not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * POST /users/:id/roles + * Asignar rol a usuario + */ + router.post('/:id/roles', authMiddleware.authenticate, authMiddleware.authorize('admin', 'super_admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const { roleCode } = req.body; + if (!roleCode) { + res.status(400).json({ error: 'Bad Request', message: 'roleCode is required' }); + return; + } + + const userRole = await usersService.assignRole( + { userId: req.params.id, roleCode, tenantId }, + req.user?.sub + ); + res.status(200).json({ success: true, data: userRole }); + } catch (error) { + if (error instanceof Error && error.message === 'Role not found') { + res.status(404).json({ error: 'Not Found', message: error.message }); + return; + } + next(error); + } + }); + + /** + * DELETE /users/:id/roles/:roleCode + * Remover rol de usuario + */ + router.delete('/:id/roles/:roleCode', authMiddleware.authenticate, authMiddleware.authorize('admin', 'super_admin'), async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + await usersService.removeRole(req.params.id, req.params.roleCode, tenantId); + res.status(200).json({ success: true, message: 'Role removed' }); + } catch (error) { + next(error); + } + }); + + /** + * GET /users/:id/roles + * Obtener roles de usuario + */ + router.get('/:id/roles', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const tenantId = req.tenantId; + if (!tenantId) { + res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' }); + return; + } + + const roles = await usersService.getUserRoles(req.params.id, tenantId); + res.status(200).json({ success: true, data: roles }); + } catch (error) { + next(error); + } + }); + + return router; +} + +export default createUsersController; diff --git a/src/modules/users/index.ts b/src/modules/users/index.ts new file mode 100644 index 0000000..f9202e6 --- /dev/null +++ b/src/modules/users/index.ts @@ -0,0 +1,10 @@ +/** + * Users Module - Main Exports + * + * Gestión de usuarios y roles RBAC. + * + * @module Users + */ + +export * from './services/users.service'; +export * from './controllers/users.controller'; diff --git a/src/modules/users/services/index.ts b/src/modules/users/services/index.ts new file mode 100644 index 0000000..be64bca --- /dev/null +++ b/src/modules/users/services/index.ts @@ -0,0 +1 @@ +export * from './users.service'; diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts new file mode 100644 index 0000000..bdb43f4 --- /dev/null +++ b/src/modules/users/services/users.service.ts @@ -0,0 +1,254 @@ +/** + * UsersService - Gestión de usuarios + * + * CRUD de usuarios con soporte multi-tenant y RBAC. + * + * @module Users + */ + +import { Repository, IsNull } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; +import { User } from '../../core/entities/user.entity'; +import { Role } from '../../auth/entities/role.entity'; +import { UserRole } from '../../auth/entities/user-role.entity'; + +export interface CreateUserDto { + email: string; + password: string; + firstName: string; + lastName: string; + tenantId: string; + roles?: string[]; +} + +export interface UpdateUserDto { + firstName?: string; + lastName?: string; + isActive?: boolean; +} + +export interface AssignRoleDto { + userId: string; + roleCode: string; + tenantId: string; +} + +export interface UserListOptions { + tenantId: string; + page?: number; + limit?: number; + search?: string; + isActive?: boolean; +} + +export class UsersService { + constructor( + private readonly userRepository: Repository, + private readonly roleRepository: Repository, + private readonly userRoleRepository: Repository + ) {} + + /** + * Listar usuarios de un tenant + */ + async findAll(options: UserListOptions): Promise<{ users: User[]; total: number }> { + const { tenantId, page = 1, limit = 20, search, isActive } = options; + + const query = this.userRepository + .createQueryBuilder('user') + .leftJoinAndSelect('user.tenant', 'tenant') + .where('user.tenant_id = :tenantId', { tenantId }) + .andWhere('user.deleted_at IS NULL'); + + if (search) { + query.andWhere( + '(user.email ILIKE :search OR user.first_name ILIKE :search OR user.last_name ILIKE :search)', + { search: `%${search}%` } + ); + } + + if (isActive !== undefined) { + query.andWhere('user.is_active = :isActive', { isActive }); + } + + const total = await query.getCount(); + const users = await query + .skip((page - 1) * limit) + .take(limit) + .orderBy('user.created_at', 'DESC') + .getMany(); + + return { users, total }; + } + + /** + * Obtener usuario por ID + */ + async findById(id: string, tenantId: string): Promise { + return this.userRepository.findOne({ + where: { id, tenantId, deletedAt: IsNull() } as any, + relations: ['tenant'], + }); + } + + /** + * Obtener usuario por email + */ + async findByEmail(email: string, tenantId: string): Promise { + return this.userRepository.findOne({ + where: { email, tenantId, deletedAt: IsNull() } as any, + }); + } + + /** + * Crear usuario + */ + async create(dto: CreateUserDto, createdBy?: string): Promise { + // Verificar email único en tenant + const existing = await this.findByEmail(dto.email, dto.tenantId); + if (existing) { + throw new Error('Email already exists in this tenant'); + } + + const passwordHash = await bcrypt.hash(dto.password, 12); + + const user = await this.userRepository.save( + this.userRepository.create({ + email: dto.email, + passwordHash, + firstName: dto.firstName, + lastName: dto.lastName, + tenantId: dto.tenantId, + defaultTenantId: dto.tenantId, + isActive: true, + roles: dto.roles || ['viewer'], + }) + ); + + // Asignar roles si se especificaron + if (dto.roles && dto.roles.length > 0) { + for (const roleCode of dto.roles) { + await this.assignRole({ userId: user.id, roleCode, tenantId: dto.tenantId }, createdBy); + } + } + + return user; + } + + /** + * Actualizar usuario + */ + async update(id: string, tenantId: string, dto: UpdateUserDto): Promise { + const user = await this.findById(id, tenantId); + if (!user) { + throw new Error('User not found'); + } + + await this.userRepository.update(id, { + ...dto, + updatedAt: new Date(), + }); + + return this.findById(id, tenantId) as Promise; + } + + /** + * Soft delete de usuario + */ + async delete(id: string, tenantId: string, _deletedBy?: string): Promise { + const user = await this.findById(id, tenantId); + if (!user) { + throw new Error('User not found'); + } + + await this.userRepository.update(id, { + deletedAt: new Date(), + isActive: false, + }); + } + + /** + * Activar/Desactivar usuario + */ + async setActive(id: string, tenantId: string, isActive: boolean): Promise { + const user = await this.findById(id, tenantId); + if (!user) { + throw new Error('User not found'); + } + + await this.userRepository.update(id, { isActive }); + return this.findById(id, tenantId) as Promise; + } + + /** + * Asignar rol a usuario + */ + async assignRole(dto: AssignRoleDto, assignedBy?: string): Promise { + const role = await this.roleRepository.findOne({ + where: { code: dto.roleCode, isActive: true }, + }); + + if (!role) { + throw new Error('Role not found'); + } + + // Verificar si ya tiene el rol + const existing = await this.userRoleRepository.findOne({ + where: { userId: dto.userId, roleId: role.id, tenantId: dto.tenantId }, + }); + + if (existing) { + return existing; + } + + return this.userRoleRepository.save( + this.userRoleRepository.create({ + userId: dto.userId, + roleId: role.id, + tenantId: dto.tenantId, + assignedBy, + }) + ); + } + + /** + * Remover rol de usuario + */ + async removeRole(userId: string, roleCode: string, tenantId: string): Promise { + const role = await this.roleRepository.findOne({ + where: { code: roleCode }, + }); + + if (!role) { + throw new Error('Role not found'); + } + + await this.userRoleRepository.delete({ + userId, + roleId: role.id, + tenantId, + }); + } + + /** + * Obtener roles de usuario + */ + async getUserRoles(userId: string, tenantId: string): Promise { + const userRoles = await this.userRoleRepository.find({ + where: { userId, tenantId }, + relations: ['role'], + }); + + return userRoles.map((ur) => ur.role); + } + + /** + * Listar todos los roles disponibles + */ + async listRoles(): Promise { + return this.roleRepository.find({ + where: { isActive: true }, + order: { name: 'ASC' }, + }); + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..a78e577 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,364 @@ +/** + * Server Entry Point + * MVP Sistema Administración de Obra e INFONAVIT + * + * @author Backend-Agent + * @date 2025-11-20 + */ + +import 'reflect-metadata'; +import express, { Application } from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import dotenv from 'dotenv'; +import { AppDataSource } from './shared/database/typeorm.config'; + +// Cargar variables de entorno +dotenv.config(); + +const app: Application = express(); +const PORT = process.env.APP_PORT || 3000; +const API_VERSION = process.env.API_VERSION || 'v1'; + +/** + * Middlewares + */ +app.use(helmet()); // Seguridad HTTP headers +app.use(cors({ + origin: process.env.CORS_ORIGIN?.split(',') || '*', + credentials: process.env.CORS_CREDENTIALS === 'true', +})); +app.use(morgan(process.env.LOG_FORMAT || 'dev')); // Logging +app.use(express.json()); // Parse JSON bodies +app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies + +/** + * Health Check + */ +app.get('/health', (_req, res) => { + res.status(200).json({ + status: 'ok', + timestamp: new Date().toISOString(), + environment: process.env.NODE_ENV || 'development', + version: API_VERSION, + }); +}); + +/** + * API Routes + */ +import { proyectoController, fraccionamientoController, createEtapaController, createManzanaController, createLoteController, createPrototipoController } from './modules/construction/controllers'; +import { createAuthController } from './modules/auth/controllers/auth.controller'; +import { createUsersController } from './modules/users/controllers/users.controller'; +import { createConceptoController, createPresupuestoController } from './modules/budgets/controllers'; +import { createAvanceObraController, createBitacoraObraController } from './modules/progress/controllers'; +import { createEstimacionController, createAnticipoController, createFondoGarantiaController, createRetencionController } from './modules/estimates/controllers'; +import { createPuestoController, createEmployeeController } from './modules/hr/controllers'; +import { createCapacitacionController, createIncidenteController } from './modules/hse/controllers'; +import { createRequisicionController, createConsumoObraController } from './modules/inventory/controllers'; +import { createComparativoController } from './modules/purchase/controllers'; +import { createDerechohabienteController, createAsignacionController } from './modules/infonavit/controllers'; +import { createInspectionController, createTicketController } from './modules/quality/controllers'; +import { createContractController, createSubcontractorController } from './modules/contracts/controllers'; +import { createReportController, createDashboardController, createKpiController } from './modules/reports/controllers'; +import { createCostCenterController, createAuditLogController, createSystemSettingController, createBackupController } from './modules/admin/controllers'; +import { createOpportunityController, createBidController, createBidBudgetController, createBidAnalyticsController } from './modules/bidding/controllers'; +import { createAccountingController, createAPController, createARController, createCashFlowController, createBankReconciliationController, createReportsController } from './modules/finance/controllers'; + +// Root API info +app.get(`/api/${API_VERSION}`, (_req, res) => { + res.status(200).json({ + message: 'API MVP Sistema Administración de Obra', + version: API_VERSION, + endpoints: { + health: '/health', + docs: `/api/${API_VERSION}/docs`, + auth: `/api/${API_VERSION}/auth`, + users: `/api/${API_VERSION}/users`, + proyectos: `/api/${API_VERSION}/proyectos`, + fraccionamientos: `/api/${API_VERSION}/fraccionamientos`, + etapas: `/api/${API_VERSION}/etapas`, + manzanas: `/api/${API_VERSION}/manzanas`, + lotes: `/api/${API_VERSION}/lotes`, + prototipos: `/api/${API_VERSION}/prototipos`, + conceptos: `/api/${API_VERSION}/conceptos`, + presupuestos: `/api/${API_VERSION}/presupuestos`, + avances: `/api/${API_VERSION}/avances`, + bitacora: `/api/${API_VERSION}/bitacora`, + estimaciones: `/api/${API_VERSION}/estimaciones`, + anticipos: `/api/${API_VERSION}/anticipos`, + 'fondos-garantia': `/api/${API_VERSION}/fondos-garantia`, + retenciones: `/api/${API_VERSION}/retenciones`, + puestos: `/api/${API_VERSION}/puestos`, + empleados: `/api/${API_VERSION}/empleados`, + capacitaciones: `/api/${API_VERSION}/capacitaciones`, + incidentes: `/api/${API_VERSION}/incidentes`, + requisiciones: `/api/${API_VERSION}/requisiciones`, + consumos: `/api/${API_VERSION}/consumos`, + comparativos: `/api/${API_VERSION}/comparativos`, + derechohabientes: `/api/${API_VERSION}/derechohabientes`, + asignaciones: `/api/${API_VERSION}/asignaciones`, + inspections: `/api/${API_VERSION}/inspections`, + tickets: `/api/${API_VERSION}/tickets`, + contracts: `/api/${API_VERSION}/contracts`, + subcontractors: `/api/${API_VERSION}/subcontractors`, + reports: `/api/${API_VERSION}/reports`, + dashboards: `/api/${API_VERSION}/dashboards`, + kpis: `/api/${API_VERSION}/kpis`, + 'cost-centers': `/api/${API_VERSION}/cost-centers`, + 'audit-logs': `/api/${API_VERSION}/audit-logs`, + settings: `/api/${API_VERSION}/settings`, + backups: `/api/${API_VERSION}/backups`, + opportunities: `/api/${API_VERSION}/opportunities`, + bids: `/api/${API_VERSION}/bids`, + 'bid-budgets': `/api/${API_VERSION}/bid-budgets`, + 'bid-analytics': `/api/${API_VERSION}/bid-analytics`, + accounting: `/api/${API_VERSION}/accounting`, + 'accounts-payable': `/api/${API_VERSION}/accounts-payable`, + 'accounts-receivable': `/api/${API_VERSION}/accounts-receivable`, + 'cash-flow': `/api/${API_VERSION}/cash-flow`, + 'bank-reconciliation': `/api/${API_VERSION}/bank-reconciliation`, + 'financial-reports': `/api/${API_VERSION}/financial-reports`, + }, + }); +}); + +// Construction Module Routes +app.use(`/api/${API_VERSION}/proyectos`, proyectoController); +app.use(`/api/${API_VERSION}/fraccionamientos`, fraccionamientoController); + +// Auth y Users Module Routes - Se inicializan después de la conexión a BD +let authController: ReturnType; +let usersController: ReturnType; + +/** + * 404 Handler + */ +app.use((req, res) => { + res.status(404).json({ + error: 'Not Found', + message: `Cannot ${req.method} ${req.path}`, + }); +}); + +/** + * Error Handler + */ +app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error('Error:', err); + res.status(500).json({ + error: 'Internal Server Error', + message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong', + }); +}); + +/** + * Inicializar Base de Datos y Servidor + */ +async function bootstrap() { + try { + // Conectar a base de datos + console.log('🔌 Conectando a base de datos...'); + await AppDataSource.initialize(); + console.log('✅ Base de datos conectada'); + + // Inicializar Auth Controller (requiere DataSource) + authController = createAuthController(AppDataSource); + app.use(`/api/${API_VERSION}/auth`, authController); + console.log('🔐 Auth module inicializado'); + + // Inicializar Users Controller (requiere DataSource) + usersController = createUsersController(AppDataSource); + app.use(`/api/${API_VERSION}/users`, usersController); + console.log('👥 Users module inicializado'); + + // Inicializar Construction Controllers (requieren DataSource para auth) + const etapaController = createEtapaController(AppDataSource); + app.use(`/api/${API_VERSION}/etapas`, etapaController); + + const manzanaController = createManzanaController(AppDataSource); + app.use(`/api/${API_VERSION}/manzanas`, manzanaController); + + const loteController = createLoteController(AppDataSource); + app.use(`/api/${API_VERSION}/lotes`, loteController); + + const prototipoController = createPrototipoController(AppDataSource); + app.use(`/api/${API_VERSION}/prototipos`, prototipoController); + + console.log('🏗️ Construction module inicializado'); + + // Inicializar Budgets Controllers (requieren DataSource para auth) + const conceptoController = createConceptoController(AppDataSource); + app.use(`/api/${API_VERSION}/conceptos`, conceptoController); + + const presupuestoController = createPresupuestoController(AppDataSource); + app.use(`/api/${API_VERSION}/presupuestos`, presupuestoController); + + console.log('💰 Budgets module inicializado'); + + // Inicializar Progress Controllers (requieren DataSource para auth) + const avanceObraController = createAvanceObraController(AppDataSource); + app.use(`/api/${API_VERSION}/avances`, avanceObraController); + + const bitacoraObraController = createBitacoraObraController(AppDataSource); + app.use(`/api/${API_VERSION}/bitacora`, bitacoraObraController); + + console.log('📊 Progress module inicializado'); + + // Inicializar Estimates Controllers (requieren DataSource para auth) + const estimacionController = createEstimacionController(AppDataSource); + app.use(`/api/${API_VERSION}/estimaciones`, estimacionController); + + const anticipoController = createAnticipoController(AppDataSource); + app.use(`/api/${API_VERSION}/anticipos`, anticipoController); + + const fondoGarantiaController = createFondoGarantiaController(AppDataSource); + app.use(`/api/${API_VERSION}/fondos-garantia`, fondoGarantiaController); + + const retencionController = createRetencionController(AppDataSource); + app.use(`/api/${API_VERSION}/retenciones`, retencionController); + + console.log('📝 Estimates module inicializado'); + + // Inicializar HR Controllers (requieren DataSource para auth) + const puestoController = createPuestoController(AppDataSource); + app.use(`/api/${API_VERSION}/puestos`, puestoController); + + const employeeController = createEmployeeController(AppDataSource); + app.use(`/api/${API_VERSION}/empleados`, employeeController); + + console.log('👷 HR module inicializado'); + + // Inicializar HSE Controllers (requieren DataSource para auth) + const capacitacionController = createCapacitacionController(AppDataSource); + app.use(`/api/${API_VERSION}/capacitaciones`, capacitacionController); + + const incidenteController = createIncidenteController(AppDataSource); + app.use(`/api/${API_VERSION}/incidentes`, incidenteController); + + console.log('🦺 HSE module inicializado'); + + // Inicializar Inventory Controllers (requieren DataSource para auth) + const requisicionController = createRequisicionController(AppDataSource); + app.use(`/api/${API_VERSION}/requisiciones`, requisicionController); + + const consumoObraController = createConsumoObraController(AppDataSource); + app.use(`/api/${API_VERSION}/consumos`, consumoObraController); + + console.log('📦 Inventory module inicializado'); + + // Inicializar Purchase Controllers (requieren DataSource para auth) + const comparativoController = createComparativoController(AppDataSource); + app.use(`/api/${API_VERSION}/comparativos`, comparativoController); + + console.log('🛒 Purchase module inicializado'); + + // Inicializar Infonavit Controllers (requieren DataSource para auth) + const derechohabienteController = createDerechohabienteController(AppDataSource); + app.use(`/api/${API_VERSION}/derechohabientes`, derechohabienteController); + + const asignacionController = createAsignacionController(AppDataSource); + app.use(`/api/${API_VERSION}/asignaciones`, asignacionController); + + console.log('🏠 Infonavit module inicializado'); + + // Inicializar Quality Controllers (requieren DataSource para auth) + const inspectionController = createInspectionController(AppDataSource); + app.use(`/api/${API_VERSION}/inspections`, inspectionController); + + const ticketController = createTicketController(AppDataSource); + app.use(`/api/${API_VERSION}/tickets`, ticketController); + + console.log('✅ Quality module inicializado'); + + // Inicializar Contracts Controllers (requieren DataSource para auth) + const contractController = createContractController(AppDataSource); + app.use(`/api/${API_VERSION}/contracts`, contractController); + + const subcontractorController = createSubcontractorController(AppDataSource); + app.use(`/api/${API_VERSION}/subcontractors`, subcontractorController); + + console.log('📄 Contracts module inicializado'); + + // Inicializar Reports Controllers (requieren DataSource para auth) + const reportController = createReportController(AppDataSource); + app.use(`/api/${API_VERSION}/reports`, reportController); + + const dashboardController = createDashboardController(AppDataSource); + app.use(`/api/${API_VERSION}/dashboards`, dashboardController); + + const kpiController = createKpiController(AppDataSource); + app.use(`/api/${API_VERSION}/kpis`, kpiController); + + console.log('📈 Reports module inicializado'); + + // Inicializar Admin Controllers (requieren DataSource para auth) + const costCenterController = createCostCenterController(AppDataSource); + app.use(`/api/${API_VERSION}/cost-centers`, costCenterController); + + const auditLogController = createAuditLogController(AppDataSource); + app.use(`/api/${API_VERSION}/audit-logs`, auditLogController); + + const systemSettingController = createSystemSettingController(AppDataSource); + app.use(`/api/${API_VERSION}/settings`, systemSettingController); + + const backupController = createBackupController(AppDataSource); + app.use(`/api/${API_VERSION}/backups`, backupController); + + console.log('⚙️ Admin module inicializado'); + + // Inicializar Bidding Controllers (requieren DataSource para auth) + const opportunityController = createOpportunityController(AppDataSource); + app.use(`/api/${API_VERSION}/opportunities`, opportunityController); + + const bidController = createBidController(AppDataSource); + app.use(`/api/${API_VERSION}/bids`, bidController); + + const bidBudgetController = createBidBudgetController(AppDataSource); + app.use(`/api/${API_VERSION}/bid-budgets`, bidBudgetController); + + const bidAnalyticsController = createBidAnalyticsController(AppDataSource); + app.use(`/api/${API_VERSION}/bid-analytics`, bidAnalyticsController); + + console.log('📋 Bidding module inicializado'); + + // Inicializar Finance Controllers (requieren DataSource para auth) + const accountingController = createAccountingController(AppDataSource); + app.use(`/api/${API_VERSION}/accounting`, accountingController); + + const apController = createAPController(AppDataSource); + app.use(`/api/${API_VERSION}/accounts-payable`, apController); + + const arController = createARController(AppDataSource); + app.use(`/api/${API_VERSION}/accounts-receivable`, arController); + + const cashFlowController = createCashFlowController(AppDataSource); + app.use(`/api/${API_VERSION}/cash-flow`, cashFlowController); + + const bankReconciliationController = createBankReconciliationController(AppDataSource); + app.use(`/api/${API_VERSION}/bank-reconciliation`, bankReconciliationController); + + const financialReportsController = createReportsController(AppDataSource); + app.use(`/api/${API_VERSION}/financial-reports`, financialReportsController); + + console.log('💵 Finance module inicializado'); + + // Iniciar servidor + app.listen(PORT, () => { + console.log('🚀 Servidor iniciado'); + console.log(`📍 URL: http://localhost:${PORT}`); + console.log(`📍 API: http://localhost:${PORT}/api/${API_VERSION}`); + console.log(`📍 Health: http://localhost:${PORT}/health`); + console.log(`🌍 Entorno: ${process.env.NODE_ENV || 'development'}`); + }); + } catch (error) { + console.error('❌ Error al iniciar servidor:', error); + process.exit(1); + } +} + +// Iniciar aplicación +bootstrap(); + +export default app; diff --git a/src/shared/constants/api.constants.ts b/src/shared/constants/api.constants.ts new file mode 100644 index 0000000..b8d04af --- /dev/null +++ b/src/shared/constants/api.constants.ts @@ -0,0 +1,249 @@ +/** + * API Constants - SSOT (Single Source of Truth) + * + * Todas las rutas de API, versiones y endpoints. + * NO hardcodear rutas en controllers o frontend. + * + * @module @shared/constants/api + */ + +/** + * API Version + */ +export const API_VERSION = 'v1'; +export const API_PREFIX = `/api/${API_VERSION}`; + +/** + * API Routes organized by Module + */ +export const API_ROUTES = { + // Base + ROOT: '/', + HEALTH: '/health', + DOCS: `${API_PREFIX}/docs`, + + // Auth Module + AUTH: { + BASE: `${API_PREFIX}/auth`, + LOGIN: `${API_PREFIX}/auth/login`, + LOGOUT: `${API_PREFIX}/auth/logout`, + REFRESH: `${API_PREFIX}/auth/refresh`, + REGISTER: `${API_PREFIX}/auth/register`, + FORGOT_PASSWORD: `${API_PREFIX}/auth/forgot-password`, + RESET_PASSWORD: `${API_PREFIX}/auth/reset-password`, + ME: `${API_PREFIX}/auth/me`, + CHANGE_PASSWORD: `${API_PREFIX}/auth/change-password`, + }, + + // Users Module + USERS: { + BASE: `${API_PREFIX}/users`, + BY_ID: (id: string) => `${API_PREFIX}/users/${id}`, + ROLES: (id: string) => `${API_PREFIX}/users/${id}/roles`, + }, + + // Tenants Module + TENANTS: { + BASE: `${API_PREFIX}/tenants`, + BY_ID: (id: string) => `${API_PREFIX}/tenants/${id}`, + CURRENT: `${API_PREFIX}/tenants/current`, + }, + + // Construction Module + PROYECTOS: { + BASE: `${API_PREFIX}/proyectos`, + BY_ID: (id: string) => `${API_PREFIX}/proyectos/${id}`, + FRACCIONAMIENTOS: (id: string) => `${API_PREFIX}/proyectos/${id}/fraccionamientos`, + DASHBOARD: (id: string) => `${API_PREFIX}/proyectos/${id}/dashboard`, + PROGRESS: (id: string) => `${API_PREFIX}/proyectos/${id}/progress`, + }, + + FRACCIONAMIENTOS: { + BASE: `${API_PREFIX}/fraccionamientos`, + BY_ID: (id: string) => `${API_PREFIX}/fraccionamientos/${id}`, + ETAPAS: (id: string) => `${API_PREFIX}/fraccionamientos/${id}/etapas`, + MANZANAS: (id: string) => `${API_PREFIX}/fraccionamientos/${id}/manzanas`, + LOTES: (id: string) => `${API_PREFIX}/fraccionamientos/${id}/lotes`, + }, + + PRESUPUESTOS: { + BASE: `${API_PREFIX}/presupuestos`, + BY_ID: (id: string) => `${API_PREFIX}/presupuestos/${id}`, + PARTIDAS: (id: string) => `${API_PREFIX}/presupuestos/${id}/partidas`, + COMPARE: (id: string) => `${API_PREFIX}/presupuestos/${id}/compare`, + VERSIONS: (id: string) => `${API_PREFIX}/presupuestos/${id}/versions`, + }, + + AVANCES: { + BASE: `${API_PREFIX}/avances`, + BY_ID: (id: string) => `${API_PREFIX}/avances/${id}`, + BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/avances`, + CURVA_S: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/curva-s`, + FOTOS: (id: string) => `${API_PREFIX}/avances/${id}/fotos`, + }, + + // HR Module + EMPLOYEES: { + BASE: `${API_PREFIX}/employees`, + BY_ID: (id: string) => `${API_PREFIX}/employees/${id}`, + ASISTENCIAS: (id: string) => `${API_PREFIX}/employees/${id}/asistencias`, + CAPACITACIONES: (id: string) => `${API_PREFIX}/employees/${id}/capacitaciones`, + }, + + ASISTENCIAS: { + BASE: `${API_PREFIX}/asistencias`, + BY_ID: (id: string) => `${API_PREFIX}/asistencias/${id}`, + CHECK_IN: `${API_PREFIX}/asistencias/check-in`, + CHECK_OUT: `${API_PREFIX}/asistencias/check-out`, + BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/asistencias`, + }, + + CUADRILLAS: { + BASE: `${API_PREFIX}/cuadrillas`, + BY_ID: (id: string) => `${API_PREFIX}/cuadrillas/${id}`, + MIEMBROS: (id: string) => `${API_PREFIX}/cuadrillas/${id}/miembros`, + }, + + // HSE Module + INCIDENTES: { + BASE: `${API_PREFIX}/incidentes`, + BY_ID: (id: string) => `${API_PREFIX}/incidentes/${id}`, + INVOLUCRADOS: (id: string) => `${API_PREFIX}/incidentes/${id}/involucrados`, + ACCIONES: (id: string) => `${API_PREFIX}/incidentes/${id}/acciones`, + BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/incidentes`, + }, + + CAPACITACIONES: { + BASE: `${API_PREFIX}/capacitaciones`, + BY_ID: (id: string) => `${API_PREFIX}/capacitaciones/${id}`, + PARTICIPANTES: (id: string) => `${API_PREFIX}/capacitaciones/${id}/participantes`, + CERTIFICADOS: (id: string) => `${API_PREFIX}/capacitaciones/${id}/certificados`, + }, + + INSPECCIONES: { + BASE: `${API_PREFIX}/inspecciones`, + BY_ID: (id: string) => `${API_PREFIX}/inspecciones/${id}`, + HALLAZGOS: (id: string) => `${API_PREFIX}/inspecciones/${id}/hallazgos`, + }, + + EPP: { + BASE: `${API_PREFIX}/epp`, + BY_ID: (id: string) => `${API_PREFIX}/epp/${id}`, + ASIGNACIONES: `${API_PREFIX}/epp/asignaciones`, + ENTREGAS: `${API_PREFIX}/epp/entregas`, + STOCK: `${API_PREFIX}/epp/stock`, + }, + + // Estimates Module + ESTIMACIONES: { + BASE: `${API_PREFIX}/estimaciones`, + BY_ID: (id: string) => `${API_PREFIX}/estimaciones/${id}`, + CONCEPTOS: (id: string) => `${API_PREFIX}/estimaciones/${id}/conceptos`, + GENERADORES: (id: string) => `${API_PREFIX}/estimaciones/${id}/generadores`, + WORKFLOW: (id: string) => `${API_PREFIX}/estimaciones/${id}/workflow`, + SUBMIT: (id: string) => `${API_PREFIX}/estimaciones/${id}/submit`, + APPROVE: (id: string) => `${API_PREFIX}/estimaciones/${id}/approve`, + REJECT: (id: string) => `${API_PREFIX}/estimaciones/${id}/reject`, + }, + + // INFONAVIT Module + INFONAVIT: { + BASE: `${API_PREFIX}/infonavit`, + REGISTRO: `${API_PREFIX}/infonavit/registro`, + OFERTA: `${API_PREFIX}/infonavit/oferta`, + DERECHOHABIENTES: `${API_PREFIX}/infonavit/derechohabientes`, + ASIGNACIONES: `${API_PREFIX}/infonavit/asignaciones`, + ACTAS: `${API_PREFIX}/infonavit/actas`, + REPORTES: `${API_PREFIX}/infonavit/reportes`, + }, + + // Inventory Module + ALMACENES: { + BASE: `${API_PREFIX}/almacenes`, + BY_ID: (id: string) => `${API_PREFIX}/almacenes/${id}`, + BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/almacenes`, + STOCK: (id: string) => `${API_PREFIX}/almacenes/${id}/stock`, + }, + + REQUISICIONES: { + BASE: `${API_PREFIX}/requisiciones`, + BY_ID: (id: string) => `${API_PREFIX}/requisiciones/${id}`, + LINEAS: (id: string) => `${API_PREFIX}/requisiciones/${id}/lineas`, + SUBMIT: (id: string) => `${API_PREFIX}/requisiciones/${id}/submit`, + APPROVE: (id: string) => `${API_PREFIX}/requisiciones/${id}/approve`, + }, + + // Purchase Module + COMPRAS: { + BASE: `${API_PREFIX}/compras`, + BY_ID: (id: string) => `${API_PREFIX}/compras/${id}`, + LINEAS: (id: string) => `${API_PREFIX}/compras/${id}/lineas`, + COMPARATIVO: `${API_PREFIX}/compras/comparativo`, + RECEPCIONES: (id: string) => `${API_PREFIX}/compras/${id}/recepciones`, + }, + + PROVEEDORES: { + BASE: `${API_PREFIX}/proveedores`, + BY_ID: (id: string) => `${API_PREFIX}/proveedores/${id}`, + COTIZACIONES: (id: string) => `${API_PREFIX}/proveedores/${id}/cotizaciones`, + }, + + // Contracts Module + CONTRATOS: { + BASE: `${API_PREFIX}/contratos`, + BY_ID: (id: string) => `${API_PREFIX}/contratos/${id}`, + PARTIDAS: (id: string) => `${API_PREFIX}/contratos/${id}/partidas`, + ESTIMACIONES: (id: string) => `${API_PREFIX}/contratos/${id}/estimaciones`, + }, + + // Reports Module + REPORTS: { + BASE: `${API_PREFIX}/reports`, + DASHBOARD: `${API_PREFIX}/reports/dashboard`, + AVANCE_FISICO: `${API_PREFIX}/reports/avance-fisico`, + AVANCE_FINANCIERO: `${API_PREFIX}/reports/avance-financiero`, + CURVA_S: `${API_PREFIX}/reports/curva-s`, + PRESUPUESTO_VS_REAL: `${API_PREFIX}/reports/presupuesto-vs-real`, + KPI_HSE: `${API_PREFIX}/reports/kpi-hse`, + EXPORT: `${API_PREFIX}/reports/export`, + }, +} as const; + +/** + * HTTP Methods + */ +export const HTTP_METHODS = { + GET: 'GET', + POST: 'POST', + PUT: 'PUT', + PATCH: 'PATCH', + DELETE: 'DELETE', +} as const; + +/** + * HTTP Status Codes + */ +export const HTTP_STATUS = { + OK: 200, + CREATED: 201, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + UNPROCESSABLE_ENTITY: 422, + INTERNAL_SERVER_ERROR: 500, + SERVICE_UNAVAILABLE: 503, +} as const; + +/** + * Content Types + */ +export const CONTENT_TYPES = { + JSON: 'application/json', + FORM_URLENCODED: 'application/x-www-form-urlencoded', + MULTIPART: 'multipart/form-data', + PDF: 'application/pdf', + EXCEL: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +} as const; diff --git a/src/shared/constants/database.constants.ts b/src/shared/constants/database.constants.ts new file mode 100644 index 0000000..14c4d78 --- /dev/null +++ b/src/shared/constants/database.constants.ts @@ -0,0 +1,315 @@ +/** + * Database Constants - SSOT (Single Source of Truth) + * + * IMPORTANTE: Este archivo es la UNICA fuente de verdad para nombres de + * schemas, tablas y columnas. Cualquier hardcoding sera detectado por + * el script validate-constants-usage.ts + * + * @module @shared/constants/database + */ + +/** + * Database Schemas + * Todos los schemas de la base de datos PostgreSQL + */ +export const DB_SCHEMAS = { + // Auth & Core + AUTH: 'auth', + CORE: 'core', + + // Domain Schemas + CONSTRUCTION: 'construction', + HR: 'hr', + HSE: 'hse', + ESTIMATES: 'estimates', + INFONAVIT: 'infonavit', + INVENTORY: 'inventory', + PURCHASE: 'purchase', + + // System Schemas + FINANCIAL: 'financial', + ANALYTICS: 'analytics', + AUDIT: 'audit', + SYSTEM: 'system', +} as const; + +export type DBSchema = typeof DB_SCHEMAS[keyof typeof DB_SCHEMAS]; + +/** + * Database Tables organized by Schema + */ +export const DB_TABLES = { + // Auth Schema + [DB_SCHEMAS.AUTH]: { + USERS: 'users', + ROLES: 'roles', + PERMISSIONS: 'permissions', + ROLE_PERMISSIONS: 'role_permissions', + USER_ROLES: 'user_roles', + SESSIONS: 'sessions', + REFRESH_TOKENS: 'refresh_tokens', + TENANTS: 'tenants', + TENANT_USERS: 'tenant_users', + PASSWORD_RESETS: 'password_resets', + }, + + // Core Schema + [DB_SCHEMAS.CORE]: { + COMPANIES: 'companies', + PARTNERS: 'partners', + CURRENCIES: 'currencies', + COUNTRIES: 'countries', + STATES: 'states', + CITIES: 'cities', + UOM: 'units_of_measure', + UOM_CATEGORIES: 'uom_categories', + SEQUENCES: 'sequences', + ATTACHMENTS: 'attachments', + }, + + // Construction Schema (24 tables) + [DB_SCHEMAS.CONSTRUCTION]: { + // Project Structure (8) + PROYECTOS: 'proyectos', + FRACCIONAMIENTOS: 'fraccionamientos', + ETAPAS: 'etapas', + MANZANAS: 'manzanas', + LOTES: 'lotes', + TORRES: 'torres', + NIVELES: 'niveles', + DEPARTAMENTOS: 'departamentos', + PROTOTIPOS: 'prototipos', + + // Budget & Concepts (3) + CONCEPTOS: 'conceptos', + PRESUPUESTOS: 'presupuestos', + PRESUPUESTO_PARTIDAS: 'presupuesto_partidas', + + // Schedule & Progress (5) + PROGRAMA_OBRA: 'programa_obra', + PROGRAMA_ACTIVIDADES: 'programa_actividades', + AVANCES_OBRA: 'avances_obra', + FOTOS_AVANCE: 'fotos_avance', + BITACORA_OBRA: 'bitacora_obra', + + // Quality (5) + CHECKLISTS: 'checklists', + CHECKLIST_ITEMS: 'checklist_items', + INSPECCIONES: 'inspecciones', + INSPECCION_RESULTADOS: 'inspeccion_resultados', + TICKETS_POSTVENTA: 'tickets_postventa', + + // Contracts (3) + SUBCONTRATISTAS: 'subcontratistas', + CONTRATOS: 'contratos', + CONTRATO_PARTIDAS: 'contrato_partidas', + }, + + // HR Schema (8 tables) + [DB_SCHEMAS.HR]: { + EMPLOYEES: 'employees', + EMPLOYEE_CONSTRUCTION: 'employee_construction', + PUESTOS: 'puestos', + ASISTENCIAS: 'asistencias', + ASISTENCIA_BIOMETRICO: 'asistencia_biometrico', + GEOCERCAS: 'geocercas', + DESTAJO: 'destajo', + DESTAJO_DETALLE: 'destajo_detalle', + CUADRILLAS: 'cuadrillas', + CUADRILLA_MIEMBROS: 'cuadrilla_miembros', + EMPLOYEE_FRACCIONAMIENTOS: 'employee_fraccionamientos', + }, + + // HSE Schema (58 tables - main groups) + [DB_SCHEMAS.HSE]: { + // Incidents (5) + INCIDENTES: 'incidentes', + INCIDENTE_INVOLUCRADOS: 'incidente_involucrados', + INCIDENTE_ACCIONES: 'incidente_acciones', + INCIDENTE_EVIDENCIAS: 'incidente_evidencias', + INCIDENTE_CAUSAS: 'incidente_causas', + + // Training (6) + CAPACITACIONES: 'capacitaciones', + CAPACITACION_PARTICIPANTES: 'capacitacion_participantes', + CAPACITACION_MATERIALES: 'capacitacion_materiales', + CERTIFICACIONES: 'certificaciones', + CERTIFICACION_EMPLEADOS: 'certificacion_empleados', + PLAN_CAPACITACION: 'plan_capacitacion', + + // Inspections (7) + INSPECCIONES_SEGURIDAD: 'inspecciones_seguridad', + INSPECCION_HALLAZGOS: 'inspeccion_hallazgos', + CHECKLIST_SEGURIDAD: 'checklist_seguridad', + CHECKLIST_SEGURIDAD_ITEMS: 'checklist_seguridad_items', + AREAS_RIESGO: 'areas_riesgo', + RONDAS_SEGURIDAD: 'rondas_seguridad', + RONDA_PUNTOS: 'ronda_puntos', + + // EPP (7) + EPP_CATALOGO: 'epp_catalogo', + EPP_ASIGNACIONES: 'epp_asignaciones', + EPP_ENTREGAS: 'epp_entregas', + EPP_DEVOLUCIONES: 'epp_devoluciones', + EPP_INSPECCIONES: 'epp_inspecciones', + EPP_VIDA_UTIL: 'epp_vida_util', + EPP_STOCK: 'epp_stock', + + // STPS Compliance (11) + NORMAS_STPS: 'normas_stps', + REQUISITOS_NORMA: 'requisitos_norma', + CUMPLIMIENTO_NORMA: 'cumplimiento_norma', + AUDITORIAS_STPS: 'auditorias_stps', + AUDITORIA_HALLAZGOS: 'auditoria_hallazgos', + PLANES_ACCION: 'planes_accion', + ACCIONES_CORRECTIVAS: 'acciones_correctivas', + COMISION_SEGURIDAD: 'comision_seguridad', + COMISION_MIEMBROS: 'comision_miembros', + RECORRIDOS_COMISION: 'recorridos_comision', + ACTAS_COMISION: 'actas_comision', + + // Environmental (9) + IMPACTOS_AMBIENTALES: 'impactos_ambientales', + RESIDUOS: 'residuos', + RESIDUO_MOVIMIENTOS: 'residuo_movimientos', + MANIFIESTOS_RESIDUOS: 'manifiestos_residuos', + MONITOREO_AMBIENTAL: 'monitoreo_ambiental', + PERMISOS_AMBIENTALES: 'permisos_ambientales', + PROGRAMAS_AMBIENTALES: 'programas_ambientales', + INDICADORES_AMBIENTALES: 'indicadores_ambientales', + EVENTOS_AMBIENTALES: 'eventos_ambientales', + + // Work Permits (8) + PERMISOS_TRABAJO: 'permisos_trabajo', + PERMISO_RIESGOS: 'permiso_riesgos', + PERMISO_AUTORIZACIONES: 'permiso_autorizaciones', + PERMISOS_ALTURA: 'permisos_altura', + PERMISOS_CALIENTE: 'permisos_caliente', + PERMISOS_CONFINADO: 'permisos_confinado', + PERMISOS_ELECTRICO: 'permisos_electrico', + PERMISOS_EXCAVACION: 'permisos_excavacion', + + // KPIs (7) + KPI_CONFIGURACION: 'kpi_configuracion', + KPI_VALORES: 'kpi_valores', + KPI_METAS: 'kpi_metas', + DASHBOARDS_HSE: 'dashboards_hse', + ALERTAS_HSE: 'alertas_hse', + REPORTES_HSE: 'reportes_hse', + ESTADISTICAS_PERIODO: 'estadisticas_periodo', + }, + + // Estimates Schema (8 tables) + [DB_SCHEMAS.ESTIMATES]: { + ESTIMACIONES: 'estimaciones', + ESTIMACION_CONCEPTOS: 'estimacion_conceptos', + GENERADORES: 'generadores', + ANTICIPOS: 'anticipos', + AMORTIZACIONES: 'amortizaciones', + RETENCIONES: 'retenciones', + FONDO_GARANTIA: 'fondo_garantia', + ESTIMACION_WORKFLOW: 'estimacion_workflow', + }, + + // INFONAVIT Schema (8 tables) + [DB_SCHEMAS.INFONAVIT]: { + REGISTRO_INFONAVIT: 'registro_infonavit', + OFERTA_VIVIENDA: 'oferta_vivienda', + DERECHOHABIENTES: 'derechohabientes', + ASIGNACION_VIVIENDA: 'asignacion_vivienda', + ACTAS: 'actas', + ACTA_VIVIENDAS: 'acta_viviendas', + REPORTES_INFONAVIT: 'reportes_infonavit', + HISTORICO_PUNTOS: 'historico_puntos', + }, + + // Inventory Extension Schema (4 tables) + [DB_SCHEMAS.INVENTORY]: { + ALMACENES_PROYECTO: 'almacenes_proyecto', + REQUISICIONES_OBRA: 'requisiciones_obra', + REQUISICION_LINEAS: 'requisicion_lineas', + CONSUMOS_OBRA: 'consumos_obra', + // Base tables (reference) + PRODUCTS: 'products', + LOCATIONS: 'locations', + STOCK_MOVES: 'stock_moves', + STOCK_QUANTS: 'stock_quants', + }, + + // Purchase Extension Schema (5 tables) + [DB_SCHEMAS.PURCHASE]: { + PURCHASE_ORDER_CONSTRUCTION: 'purchase_order_construction', + SUPPLIER_CONSTRUCTION: 'supplier_construction', + COMPARATIVO_COTIZACIONES: 'comparativo_cotizaciones', + COMPARATIVO_PROVEEDORES: 'comparativo_proveedores', + COMPARATIVO_PRODUCTOS: 'comparativo_productos', + // Base tables (reference) + PURCHASE_ORDERS: 'purchase_orders', + PURCHASE_ORDER_LINES: 'purchase_order_lines', + SUPPLIERS: 'suppliers', + }, + + // Audit Schema + [DB_SCHEMAS.AUDIT]: { + AUDIT_LOG: 'audit_log', + CHANGE_HISTORY: 'change_history', + USER_ACTIVITY: 'user_activity', + }, +} as const; + +/** + * Common Column Names (to avoid hardcoding) + */ +export const DB_COLUMNS = { + // Audit columns + ID: 'id', + CREATED_AT: 'created_at', + UPDATED_AT: 'updated_at', + CREATED_BY: 'created_by', + UPDATED_BY: 'updated_by', + DELETED_AT: 'deleted_at', + + // Multi-tenant columns + TENANT_ID: 'tenant_id', + + // Common FK columns + USER_ID: 'user_id', + PROJECT_ID: 'proyecto_id', + FRACCIONAMIENTO_ID: 'fraccionamiento_id', + EMPLOYEE_ID: 'employee_id', + + // Status columns + STATUS: 'status', + IS_ACTIVE: 'is_active', + + // Analytic columns (Odoo pattern) + ANALYTIC_ACCOUNT_ID: 'analytic_account_id', +} as const; + +/** + * Helper function to get full table name + */ +export function getFullTableName(schema: DBSchema, table: string): string { + return `${schema}.${table}`; +} + +/** + * Get schema.table reference + */ +export const TABLE_REFS = { + // Auth + USERS: getFullTableName(DB_SCHEMAS.AUTH, DB_TABLES[DB_SCHEMAS.AUTH].USERS), + TENANTS: getFullTableName(DB_SCHEMAS.AUTH, DB_TABLES[DB_SCHEMAS.AUTH].TENANTS), + ROLES: getFullTableName(DB_SCHEMAS.AUTH, DB_TABLES[DB_SCHEMAS.AUTH].ROLES), + + // Construction + PROYECTOS: getFullTableName(DB_SCHEMAS.CONSTRUCTION, DB_TABLES[DB_SCHEMAS.CONSTRUCTION].PROYECTOS), + FRACCIONAMIENTOS: getFullTableName(DB_SCHEMAS.CONSTRUCTION, DB_TABLES[DB_SCHEMAS.CONSTRUCTION].FRACCIONAMIENTOS), + + // HR + EMPLOYEES: getFullTableName(DB_SCHEMAS.HR, DB_TABLES[DB_SCHEMAS.HR].EMPLOYEES), + + // HSE + INCIDENTES: getFullTableName(DB_SCHEMAS.HSE, DB_TABLES[DB_SCHEMAS.HSE].INCIDENTES), + CAPACITACIONES: getFullTableName(DB_SCHEMAS.HSE, DB_TABLES[DB_SCHEMAS.HSE].CAPACITACIONES), +} as const; diff --git a/src/shared/constants/enums.constants.ts b/src/shared/constants/enums.constants.ts new file mode 100644 index 0000000..03c4330 --- /dev/null +++ b/src/shared/constants/enums.constants.ts @@ -0,0 +1,494 @@ +/** + * Enums Constants - SSOT (Single Source of Truth) + * + * Todos los enums del sistema. Estos se sincronizan automaticamente + * al frontend usando el script sync-enums.ts + * + * @module @shared/constants/enums + */ + +// ============================================================================ +// AUTH & USERS +// ============================================================================ + +/** + * Roles del sistema de construccion + */ +export const ROLES = { + // Super Admin (Plataforma) + SUPER_ADMIN: 'super_admin', + + // Tenant Admin + ADMIN: 'admin', + + // Direccion + DIRECTOR_GENERAL: 'director_general', + DIRECTOR_PROYECTOS: 'director_proyectos', + DIRECTOR_CONSTRUCCION: 'director_construccion', + + // Gerencias + GERENTE_ADMINISTRATIVO: 'gerente_administrativo', + GERENTE_OPERACIONES: 'gerente_operaciones', + + // Ingenieria y Control + INGENIERO_RESIDENTE: 'ingeniero_residente', + INGENIERO_COSTOS: 'ingeniero_costos', + CONTROL_OBRA: 'control_obra', + PLANEADOR: 'planeador', + + // Supervisores + SUPERVISOR_OBRA: 'supervisor_obra', + SUPERVISOR_HSE: 'supervisor_hse', + SUPERVISOR_CALIDAD: 'supervisor_calidad', + + // Compras y Almacen + COMPRAS: 'compras', + ALMACENISTA: 'almacenista', + + // RRHH + RRHH: 'rrhh', + NOMINA: 'nomina', + + // Finanzas + CONTADOR: 'contador', + TESORERO: 'tesorero', + + // Postventa + POSTVENTA: 'postventa', + + // Externos + SUBCONTRATISTA: 'subcontratista', + PROVEEDOR: 'proveedor', + DERECHOHABIENTE: 'derechohabiente', + + // Solo lectura + VIEWER: 'viewer', +} as const; + +export type Role = typeof ROLES[keyof typeof ROLES]; + +/** + * Estados de cuenta de usuario + */ +export const USER_STATUS = { + ACTIVE: 'active', + INACTIVE: 'inactive', + PENDING: 'pending', + SUSPENDED: 'suspended', + BLOCKED: 'blocked', +} as const; + +export type UserStatus = typeof USER_STATUS[keyof typeof USER_STATUS]; + +// ============================================================================ +// PROYECTOS Y ESTRUCTURA +// ============================================================================ + +/** + * Estados de proyecto + */ +export const PROJECT_STATUS = { + DRAFT: 'draft', + PLANNING: 'planning', + BIDDING: 'bidding', + AWARDED: 'awarded', + ACTIVE: 'active', + PAUSED: 'paused', + COMPLETED: 'completed', + CANCELLED: 'cancelled', +} as const; + +export type ProjectStatus = typeof PROJECT_STATUS[keyof typeof PROJECT_STATUS]; + +/** + * Tipos de proyecto + */ +export const PROJECT_TYPE = { + HORIZONTAL: 'horizontal', // Casas individuales + VERTICAL: 'vertical', // Edificios/Torres + MIXED: 'mixed', // Combinado + INFRASTRUCTURE: 'infrastructure', // Infraestructura +} as const; + +export type ProjectType = typeof PROJECT_TYPE[keyof typeof PROJECT_TYPE]; + +/** + * Estados de fraccionamiento + */ +export const FRACCIONAMIENTO_STATUS = { + ACTIVE: 'activo', + PAUSED: 'pausado', + COMPLETED: 'completado', + CANCELLED: 'cancelado', +} as const; + +export type FraccionamientoStatus = typeof FRACCIONAMIENTO_STATUS[keyof typeof FRACCIONAMIENTO_STATUS]; + +/** + * Estados de lote + */ +export const LOT_STATUS = { + AVAILABLE: 'disponible', + RESERVED: 'apartado', + SOLD: 'vendido', + IN_CONSTRUCTION: 'en_construccion', + DELIVERED: 'entregado', + WARRANTY: 'en_garantia', +} as const; + +export type LotStatus = typeof LOT_STATUS[keyof typeof LOT_STATUS]; + +// ============================================================================ +// PRESUPUESTOS Y COSTOS +// ============================================================================ + +/** + * Tipos de concepto de obra + */ +export const CONCEPT_TYPE = { + MATERIAL: 'material', + LABOR: 'mano_obra', + EQUIPMENT: 'equipo', + SUBCONTRACT: 'subcontrato', + INDIRECT: 'indirecto', + OVERHEAD: 'overhead', + UTILITY: 'utilidad', +} as const; + +export type ConceptType = typeof CONCEPT_TYPE[keyof typeof CONCEPT_TYPE]; + +/** + * Estados de presupuesto + */ +export const BUDGET_STATUS = { + DRAFT: 'borrador', + SUBMITTED: 'enviado', + APPROVED: 'aprobado', + CONTRACTED: 'contratado', + CLOSED: 'cerrado', +} as const; + +export type BudgetStatus = typeof BUDGET_STATUS[keyof typeof BUDGET_STATUS]; + +// ============================================================================ +// COMPRAS E INVENTARIOS +// ============================================================================ + +/** + * Estados de orden de compra + */ +export const PURCHASE_ORDER_STATUS = { + DRAFT: 'borrador', + SUBMITTED: 'enviado', + APPROVED: 'aprobado', + CONFIRMED: 'confirmado', + PARTIAL: 'parcial', + RECEIVED: 'recibido', + CANCELLED: 'cancelado', +} as const; + +export type PurchaseOrderStatus = typeof PURCHASE_ORDER_STATUS[keyof typeof PURCHASE_ORDER_STATUS]; + +/** + * Estados de requisicion + */ +export const REQUISITION_STATUS = { + DRAFT: 'borrador', + SUBMITTED: 'enviado', + APPROVED: 'aprobado', + REJECTED: 'rechazado', + ORDERED: 'ordenado', + CLOSED: 'cerrado', +} as const; + +export type RequisitionStatus = typeof REQUISITION_STATUS[keyof typeof REQUISITION_STATUS]; + +/** + * Tipos de movimiento de inventario + */ +export const STOCK_MOVE_TYPE = { + INCOMING: 'entrada', + OUTGOING: 'salida', + TRANSFER: 'traspaso', + ADJUSTMENT: 'ajuste', + RETURN: 'devolucion', + CONSUMPTION: 'consumo', +} as const; + +export type StockMoveType = typeof STOCK_MOVE_TYPE[keyof typeof STOCK_MOVE_TYPE]; + +// ============================================================================ +// ESTIMACIONES +// ============================================================================ + +/** + * Estados de estimacion + */ +export const ESTIMATION_STATUS = { + DRAFT: 'borrador', + IN_REVIEW: 'en_revision', + SUBMITTED: 'enviado', + CLIENT_REVIEW: 'revision_cliente', + APPROVED: 'aprobado', + REJECTED: 'rechazado', + PAID: 'pagado', + CANCELLED: 'cancelado', +} as const; + +export type EstimationStatus = typeof ESTIMATION_STATUS[keyof typeof ESTIMATION_STATUS]; + +/** + * Tipos de retencion + */ +export const RETENTION_TYPE = { + WARRANTY: 'garantia', + ADVANCE_AMORTIZATION: 'amortizacion_anticipo', + IMSS: 'imss', + ISR: 'isr', + OTHER: 'otro', +} as const; + +export type RetentionType = typeof RETENTION_TYPE[keyof typeof RETENTION_TYPE]; + +// ============================================================================ +// HSE (Health, Safety & Environment) +// ============================================================================ + +/** + * Severidad de incidente + */ +export const INCIDENT_SEVERITY = { + LOW: 'bajo', + MEDIUM: 'medio', + HIGH: 'alto', + CRITICAL: 'critico', + FATAL: 'fatal', +} as const; + +export type IncidentSeverity = typeof INCIDENT_SEVERITY[keyof typeof INCIDENT_SEVERITY]; + +/** + * Tipos de incidente + */ +export const INCIDENT_TYPE = { + ACCIDENT: 'accidente', + NEAR_MISS: 'casi_accidente', + UNSAFE_CONDITION: 'condicion_insegura', + UNSAFE_ACT: 'acto_inseguro', + FIRST_AID: 'primeros_auxilios', + ENVIRONMENTAL: 'ambiental', +} as const; + +export type IncidentType = typeof INCIDENT_TYPE[keyof typeof INCIDENT_TYPE]; + +/** + * Estados de incidente + */ +export const INCIDENT_STATUS = { + REPORTED: 'reportado', + UNDER_INVESTIGATION: 'en_investigacion', + PENDING_ACTIONS: 'pendiente_acciones', + ACTIONS_IN_PROGRESS: 'acciones_en_progreso', + CLOSED: 'cerrado', +} as const; + +export type IncidentStatus = typeof INCIDENT_STATUS[keyof typeof INCIDENT_STATUS]; + +/** + * Tipos de capacitacion + */ +export const TRAINING_TYPE = { + INDUCTION: 'induccion', + SAFETY: 'seguridad', + TECHNICAL: 'tecnico', + REGULATORY: 'normativo', + REFRESHER: 'actualizacion', + CERTIFICATION: 'certificacion', +} as const; + +export type TrainingType = typeof TRAINING_TYPE[keyof typeof TRAINING_TYPE]; + +/** + * Tipos de permiso de trabajo + */ +export const WORK_PERMIT_TYPE = { + HOT_WORK: 'trabajo_caliente', + CONFINED_SPACE: 'espacio_confinado', + HEIGHT_WORK: 'trabajo_altura', + ELECTRICAL: 'electrico', + EXCAVATION: 'excavacion', + LIFTING: 'izaje', +} as const; + +export type WorkPermitType = typeof WORK_PERMIT_TYPE[keyof typeof WORK_PERMIT_TYPE]; + +// ============================================================================ +// RRHH +// ============================================================================ + +/** + * Tipos de empleado + */ +export const EMPLOYEE_TYPE = { + PERMANENT: 'planta', + TEMPORARY: 'temporal', + CONTRACTOR: 'contratista', + INTERN: 'practicante', +} as const; + +export type EmployeeType = typeof EMPLOYEE_TYPE[keyof typeof EMPLOYEE_TYPE]; + +/** + * Tipos de asistencia + */ +export const ATTENDANCE_TYPE = { + CHECK_IN: 'entrada', + CHECK_OUT: 'salida', + BREAK_START: 'inicio_descanso', + BREAK_END: 'fin_descanso', +} as const; + +export type AttendanceType = typeof ATTENDANCE_TYPE[keyof typeof ATTENDANCE_TYPE]; + +/** + * Metodos de validacion de asistencia + */ +export const ATTENDANCE_VALIDATION = { + GPS: 'gps', + BIOMETRIC: 'biometrico', + QR: 'qr', + MANUAL: 'manual', + NFC: 'nfc', +} as const; + +export type AttendanceValidation = typeof ATTENDANCE_VALIDATION[keyof typeof ATTENDANCE_VALIDATION]; + +// ============================================================================ +// INFONAVIT +// ============================================================================ + +/** + * Estados de asignacion INFONAVIT + */ +export const INFONAVIT_ASSIGNMENT_STATUS = { + AVAILABLE: 'disponible', + IN_PROCESS: 'en_proceso', + ASSIGNED: 'asignado', + DOCUMENTED: 'documentado', + REGISTERED: 'registrado', + DELIVERED: 'entregado', +} as const; + +export type InfonavitAssignmentStatus = typeof INFONAVIT_ASSIGNMENT_STATUS[keyof typeof INFONAVIT_ASSIGNMENT_STATUS]; + +/** + * Programas INFONAVIT + */ +export const INFONAVIT_PROGRAM = { + TRADICIONAL: 'tradicional', + COFINAVIT: 'cofinavit', + APOYO_INFONAVIT: 'apoyo_infonavit', + UNAMOS_CREDITOS: 'unamos_creditos', + MEJORAVIT: 'mejoravit', +} as const; + +export type InfonavitProgram = typeof INFONAVIT_PROGRAM[keyof typeof INFONAVIT_PROGRAM]; + +// ============================================================================ +// CALIDAD Y POSTVENTA +// ============================================================================ + +/** + * Estados de ticket postventa + */ +export const TICKET_STATUS = { + OPEN: 'abierto', + IN_PROGRESS: 'en_proceso', + PENDING_CUSTOMER: 'pendiente_cliente', + PENDING_PARTS: 'pendiente_refacciones', + RESOLVED: 'resuelto', + CLOSED: 'cerrado', +} as const; + +export type TicketStatus = typeof TICKET_STATUS[keyof typeof TICKET_STATUS]; + +/** + * Prioridad de ticket + */ +export const TICKET_PRIORITY = { + LOW: 'baja', + MEDIUM: 'media', + HIGH: 'alta', + URGENT: 'urgente', +} as const; + +export type TicketPriority = typeof TICKET_PRIORITY[keyof typeof TICKET_PRIORITY]; + +// ============================================================================ +// DOCUMENTOS +// ============================================================================ + +/** + * Estados de documento + */ +export const DOCUMENT_STATUS = { + DRAFT: 'borrador', + PENDING_REVIEW: 'pendiente_revision', + APPROVED: 'aprobado', + REJECTED: 'rechazado', + OBSOLETE: 'obsoleto', +} as const; + +export type DocumentStatus = typeof DOCUMENT_STATUS[keyof typeof DOCUMENT_STATUS]; + +/** + * Tipos de documento + */ +export const DOCUMENT_TYPE = { + PLAN: 'plano', + CONTRACT: 'contrato', + PERMIT: 'permiso', + CERTIFICATE: 'certificado', + REPORT: 'reporte', + PHOTO: 'fotografia', + OTHER: 'otro', +} as const; + +export type DocumentType = typeof DOCUMENT_TYPE[keyof typeof DOCUMENT_TYPE]; + +// ============================================================================ +// WORKFLOW +// ============================================================================ + +/** + * Acciones de workflow + */ +export const WORKFLOW_ACTION = { + SUBMIT: 'submit', + APPROVE: 'approve', + REJECT: 'reject', + RETURN: 'return', + CANCEL: 'cancel', + REOPEN: 'reopen', +} as const; + +export type WorkflowAction = typeof WORKFLOW_ACTION[keyof typeof WORKFLOW_ACTION]; + +// ============================================================================ +// AUDIT +// ============================================================================ + +/** + * Tipos de accion de auditoria + */ +export const AUDIT_ACTION = { + CREATE: 'create', + UPDATE: 'update', + DELETE: 'delete', + VIEW: 'view', + EXPORT: 'export', + LOGIN: 'login', + LOGOUT: 'logout', +} as const; + +export type AuditAction = typeof AUDIT_ACTION[keyof typeof AUDIT_ACTION]; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts new file mode 100644 index 0000000..c56cd3d --- /dev/null +++ b/src/shared/constants/index.ts @@ -0,0 +1,194 @@ +/** + * Constants - SSOT Entry Point + * + * Este archivo es el punto de entrada para todas las constantes del sistema. + * Exporta desde los modulos especializados para mantener SSOT. + * + * @module @shared/constants + */ + +// ============================================================================ +// DATABASE CONSTANTS +// ============================================================================ +export { + DB_SCHEMAS, + DB_TABLES, + DB_COLUMNS, + TABLE_REFS, + getFullTableName, + type DBSchema, +} from './database.constants'; + +// ============================================================================ +// API CONSTANTS +// ============================================================================ +export { + API_VERSION, + API_PREFIX, + API_ROUTES, + HTTP_METHODS, + HTTP_STATUS, + CONTENT_TYPES, +} from './api.constants'; + +// ============================================================================ +// ENUMS +// ============================================================================ +export { + // Auth + ROLES, + USER_STATUS, + type Role, + type UserStatus, + + // Projects + PROJECT_STATUS, + PROJECT_TYPE, + FRACCIONAMIENTO_STATUS, + LOT_STATUS, + type ProjectStatus, + type ProjectType, + type FraccionamientoStatus, + type LotStatus, + + // Budget + CONCEPT_TYPE, + BUDGET_STATUS, + type ConceptType, + type BudgetStatus, + + // Purchases + PURCHASE_ORDER_STATUS, + REQUISITION_STATUS, + STOCK_MOVE_TYPE, + type PurchaseOrderStatus, + type RequisitionStatus, + type StockMoveType, + + // Estimates + ESTIMATION_STATUS, + RETENTION_TYPE, + type EstimationStatus, + type RetentionType, + + // HSE + INCIDENT_SEVERITY, + INCIDENT_TYPE, + INCIDENT_STATUS, + TRAINING_TYPE, + WORK_PERMIT_TYPE, + type IncidentSeverity, + type IncidentType, + type IncidentStatus, + type TrainingType, + type WorkPermitType, + + // HR + EMPLOYEE_TYPE, + ATTENDANCE_TYPE, + ATTENDANCE_VALIDATION, + type EmployeeType, + type AttendanceType, + type AttendanceValidation, + + // INFONAVIT + INFONAVIT_ASSIGNMENT_STATUS, + INFONAVIT_PROGRAM, + type InfonavitAssignmentStatus, + type InfonavitProgram, + + // Quality + TICKET_STATUS, + TICKET_PRIORITY, + type TicketStatus, + type TicketPriority, + + // Documents + DOCUMENT_STATUS, + DOCUMENT_TYPE, + type DocumentStatus, + type DocumentType, + + // Workflow + WORKFLOW_ACTION, + type WorkflowAction, + + // Audit + AUDIT_ACTION, + type AuditAction, +} from './enums.constants'; + +// ============================================================================ +// APP CONFIG CONSTANTS +// ============================================================================ + +/** + * Application Context for PostgreSQL RLS + */ +export const APP_CONTEXT = { + TENANT_ID: 'app.current_tenant_id', + USER_ID: 'app.current_user_id', +} as const; + +/** + * Custom HTTP Headers + */ +export const CUSTOM_HEADERS = { + TENANT_ID: 'x-tenant-id', + CORRELATION_ID: 'x-correlation-id', + API_KEY: 'x-api-key', +} as const; + +/** + * Pagination Defaults + */ +export const PAGINATION = { + DEFAULT_PAGE: 1, + DEFAULT_LIMIT: 20, + MAX_LIMIT: 100, +} as const; + +/** + * Regex Patterns for Validation + */ +export const PATTERNS = { + UUID: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + RFC: /^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/, + CURP: /^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[0-9A-Z][0-9]$/, + NSS: /^[0-9]{11}$/, + EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + PHONE_MX: /^(\+52)?[0-9]{10}$/, + POSTAL_CODE_MX: /^[0-9]{5}$/, +} as const; + +/** + * File Upload Limits + */ +export const FILE_LIMITS = { + MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB + MAX_FILES: 10, + ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/webp'], + ALLOWED_DOC_TYPES: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ALLOWED_SPREADSHEET_TYPES: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], +} as const; + +/** + * Cache TTL (Time To Live) in seconds + */ +export const CACHE_TTL = { + SHORT: 60, // 1 minute + MEDIUM: 300, // 5 minutes + LONG: 3600, // 1 hour + DAY: 86400, // 24 hours +} as const; + +/** + * Date Formats + */ +export const DATE_FORMATS = { + ISO: 'YYYY-MM-DDTHH:mm:ss.sssZ', + DATE_ONLY: 'YYYY-MM-DD', + TIME_ONLY: 'HH:mm:ss', + DISPLAY_MX: 'DD/MM/YYYY', + DISPLAY_FULL_MX: 'DD/MM/YYYY HH:mm', +} as const; diff --git a/src/shared/database/typeorm.config.ts b/src/shared/database/typeorm.config.ts new file mode 100644 index 0000000..0b64b9a --- /dev/null +++ b/src/shared/database/typeorm.config.ts @@ -0,0 +1,62 @@ +/** + * TypeORM Configuration + * Configuración de conexión a PostgreSQL + * + * @see https://typeorm.io/data-source-options + */ + +import { DataSource } from 'typeorm'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const AppDataSource = new DataSource({ + type: 'postgres', + url: process.env.DATABASE_URL, + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + username: process.env.DB_USER || 'erp_user', + password: process.env.DB_PASSWORD || 'erp_dev_password', + database: process.env.DB_NAME || 'erp_construccion', + synchronize: process.env.DB_SYNCHRONIZE === 'true', // ⚠️ NUNCA en producción + logging: process.env.DB_LOGGING === 'true', + entities: [ + __dirname + '/../../modules/**/entities/*.entity{.ts,.js}', + ], + migrations: [ + __dirname + '/../../../migrations/*{.ts,.js}', + ], + subscribers: [], + // Configuración de pool + extra: { + max: 20, // Máximo de conexiones + min: 5, // Mínimo de conexiones + idleTimeoutMillis: 30000, + }, +}); + +/** + * Inicializar conexión + */ +export async function initializeDatabase(): Promise { + try { + await AppDataSource.initialize(); + console.log('✅ TypeORM conectado a PostgreSQL'); + } catch (error) { + console.error('❌ Error al conectar TypeORM:', error); + throw error; + } +} + +/** + * Cerrar conexión + */ +export async function closeDatabase(): Promise { + try { + await AppDataSource.destroy(); + console.log('✅ Conexión TypeORM cerrada'); + } catch (error) { + console.error('❌ Error al cerrar TypeORM:', error); + throw error; + } +} diff --git a/src/shared/interfaces/base.interface.ts b/src/shared/interfaces/base.interface.ts new file mode 100644 index 0000000..c6ff3ba --- /dev/null +++ b/src/shared/interfaces/base.interface.ts @@ -0,0 +1,79 @@ +/** + * Base Interfaces + * Interfaces compartidas para todo el proyecto + */ + +import { Request } from 'express'; + +/** + * Entidad base con campos de auditoria + */ +export interface BaseEntity { + id: string; + createdAt: Date; + updatedAt: Date; + createdBy?: string; +} + +/** + * Entidad con tenant + */ +export interface TenantEntity extends BaseEntity { + tenantId: string; +} + +/** + * Request con contexto de tenant y usuario + */ +export interface AuthenticatedRequest extends Request { + user?: { + sub: string; + id: string; + email: string; + tenantId: string; + roles: string[]; + type: 'access' | 'refresh'; + }; + tenantId?: string; +} + +/** + * Respuesta paginada + */ +export interface PaginatedResponse { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; + }; +} + +/** + * Query params para paginacion + */ +export interface PaginationQuery { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + search?: string; +} + +/** + * Respuesta API estandar + */ +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + errors?: Array<{ + field?: string; + message: string; + code?: string; + }>; + meta?: Record; +} diff --git a/src/shared/services/base.service.ts b/src/shared/services/base.service.ts new file mode 100644 index 0000000..41a87ae --- /dev/null +++ b/src/shared/services/base.service.ts @@ -0,0 +1,217 @@ +/** + * BaseService - Abstract Service with Common CRUD Operations + * + * Provides multi-tenant aware CRUD operations using TypeORM. + * All domain services should extend this base class. + * + * @module @shared/services + */ + +import { + Repository, + FindOptionsWhere, + FindManyOptions, + DeepPartial, + ObjectLiteral, +} from 'typeorm'; + +export interface PaginationOptions { + page?: number; + limit?: number; +} + +export interface PaginatedResult { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export abstract class BaseService { + constructor(protected readonly repository: Repository) {} + + /** + * Find all records for a tenant with optional pagination + */ + async findAll( + ctx: ServiceContext, + options?: PaginationOptions & { where?: FindOptionsWhere } + ): Promise> { + const page = options?.page || 1; + const limit = options?.limit || 20; + const skip = (page - 1) * limit; + + const where = { + tenantId: ctx.tenantId, + deletedAt: null, + ...options?.where, + } as unknown as FindOptionsWhere; + + const [data, total] = await this.repository.findAndCount({ + where, + take: limit, + skip, + order: { createdAt: 'DESC' } as any, + }); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Find one record by ID for a tenant + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as unknown as FindOptionsWhere, + }); + } + + /** + * Find one record by criteria + */ + async findOne( + ctx: ServiceContext, + where: FindOptionsWhere + ): Promise { + return this.repository.findOne({ + where: { + tenantId: ctx.tenantId, + deletedAt: null, + ...where, + } as FindOptionsWhere, + }); + } + + /** + * Find records by custom options + */ + async find( + ctx: ServiceContext, + options: FindManyOptions + ): Promise { + return this.repository.find({ + ...options, + where: { + tenantId: ctx.tenantId, + deletedAt: null, + ...(options.where || {}), + } as FindOptionsWhere, + }); + } + + /** + * Create a new record + */ + async create( + ctx: ServiceContext, + data: DeepPartial + ): Promise { + const entity = this.repository.create({ + ...data, + tenantId: ctx.tenantId, + createdById: ctx.userId, + } as DeepPartial); + + return this.repository.save(entity); + } + + /** + * Update an existing record + */ + async update( + ctx: ServiceContext, + id: string, + data: DeepPartial + ): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return null; + } + + const updated = this.repository.merge(existing, { + ...data, + updatedById: ctx.userId, + } as DeepPartial); + + return this.repository.save(updated); + } + + /** + * Soft delete a record + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return false; + } + + await this.repository.update( + { id, tenantId: ctx.tenantId } as unknown as FindOptionsWhere, + { + deletedAt: new Date(), + deletedById: ctx.userId, + } as any + ); + + return true; + } + + /** + * Hard delete a record (use with caution) + */ + async hardDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.delete({ + id, + tenantId: ctx.tenantId, + } as unknown as FindOptionsWhere); + + return (result.affected ?? 0) > 0; + } + + /** + * Count records + */ + async count( + ctx: ServiceContext, + where?: FindOptionsWhere + ): Promise { + return this.repository.count({ + where: { + tenantId: ctx.tenantId, + deletedAt: null, + ...where, + } as unknown as FindOptionsWhere, + }); + } + + /** + * Check if a record exists + */ + async exists( + ctx: ServiceContext, + where: FindOptionsWhere + ): Promise { + const count = await this.count(ctx, where); + return count > 0; + } +} diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts new file mode 100644 index 0000000..74d25cf --- /dev/null +++ b/src/shared/services/index.ts @@ -0,0 +1,5 @@ +/** + * Shared Services - Exports + */ + +export * from './base.service'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..aeeb34d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictPropertyInitialization": false, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": "./src", + "paths": { + "@shared/*": ["shared/*"], + "@modules/*": ["modules/*"], + "@config/*": ["shared/config/*"], + "@types/*": ["shared/types/*"], + "@utils/*": ["shared/utils/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] +}