diff --git a/src/modules/carta-porte/controllers/figura-transporte.controller.ts b/src/modules/carta-porte/controllers/figura-transporte.controller.ts new file mode 100644 index 0000000..fcb335f --- /dev/null +++ b/src/modules/carta-porte/controllers/figura-transporte.controller.ts @@ -0,0 +1,467 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { + FiguraTransporteService, + CreateFiguraDto, + UpdateFiguraDto, +} from '../services/figura-transporte.service'; +import { TipoFigura } from '../entities'; + +/** + * Controlador para gestion de figuras de transporte de Carta Porte + * CFDI 3.1 Compliance - Manejo de operadores, propietarios y arrendadores + */ +export class FiguraTransporteController { + public router: Router; + + constructor(private readonly figuraService: FiguraTransporteService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // CRUD basico + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Operaciones por carta porte + this.router.get('/carta-porte/:cartaPorteId', this.findByCartaParte.bind(this)); + this.router.get('/carta-porte/:cartaPorteId/validar', this.validarFigurasRequeridas.bind(this)); + this.router.get('/carta-porte/:cartaPorteId/resumen', this.getResumenPorTipo.bind(this)); + + // Filtros por tipo + this.router.get('/tipo/:tipo', this.findByTipo.bind(this)); + this.router.get('/carta-porte/:cartaPorteId/operadores', this.getOperadores.bind(this)); + this.router.get('/carta-porte/:cartaPorteId/propietarios', this.getPropietarios.bind(this)); + this.router.get('/carta-porte/:cartaPorteId/arrendadores', this.getArrendadores.bind(this)); + + // Operaciones de alta rapida + this.router.post('/carta-porte/:cartaPorteId/operador', this.addOperador.bind(this)); + this.router.post('/carta-porte/:cartaPorteId/propietario', this.addPropietario.bind(this)); + this.router.post('/carta-porte/:cartaPorteId/arrendador', this.addArrendador.bind(this)); + } + + /** + * GET / - Obtiene todas las figuras (requiere cartaPorteId en query) + */ + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.query; + + if (!cartaPorteId) { + res.status(400).json({ error: 'cartaPorteId es requerido en query params' }); + return; + } + + const figuras = await this.figuraService.findByCartaParte( + tenantId, + cartaPorteId as string + ); + + res.json({ + data: figuras, + total: figuras.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /:id - Obtiene una figura por ID + */ + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const figura = await this.figuraService.findById(tenantId, id); + + if (!figura) { + res.status(404).json({ error: 'Figura de transporte no encontrada' }); + return; + } + + res.json({ data: figura }); + } catch (error) { + next(error); + } + } + + /** + * POST / - Crea una nueva figura de transporte + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId, ...figuraData } = req.body; + + if (!cartaPorteId) { + res.status(400).json({ error: 'cartaPorteId es requerido' }); + return; + } + + const dto: CreateFiguraDto = figuraData; + const figura = await this.figuraService.create(tenantId, cartaPorteId, dto); + + res.status(201).json({ data: figura }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /:id - Actualiza una figura existente + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdateFiguraDto = req.body; + const figura = await this.figuraService.update(tenantId, id, dto); + + if (!figura) { + res.status(404).json({ error: 'Figura de transporte no encontrada' }); + return; + } + + res.json({ data: figura }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /:id - Elimina una figura de transporte + */ + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const deleted = await this.figuraService.delete(tenantId, id); + + if (!deleted) { + res.status(404).json({ error: 'Figura de transporte no encontrada' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId - Obtiene figuras de una Carta Porte + */ + private async findByCartaParte(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const figuras = await this.figuraService.findByCartaParte(tenantId, cartaPorteId); + + res.json({ + data: figuras, + total: figuras.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /tipo/:tipo - Filtra figuras por tipo (requiere cartaPorteId en query) + */ + private async findByTipo(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { tipo } = req.params; + const { cartaPorteId } = req.query; + + if (!cartaPorteId) { + res.status(400).json({ error: 'cartaPorteId es requerido en query params' }); + return; + } + + // Validar tipo + const tiposValidos = Object.values(TipoFigura); + if (!tiposValidos.includes(tipo as TipoFigura)) { + res.status(400).json({ + error: `tipo debe ser uno de: ${tiposValidos.join(', ')}`, + }); + return; + } + + // Obtener todas las figuras y filtrar por tipo + const todasFiguras = await this.figuraService.findByCartaParte( + tenantId, + cartaPorteId as string + ); + + const figurasFiltradas = todasFiguras.filter(f => f.tipoFigura === tipo); + + res.json({ + data: figurasFiltradas, + total: figurasFiltradas.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/validar - Valida figuras requeridas + */ + private async validarFigurasRequeridas(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const validacion = await this.figuraService.validateFigurasRequeridas(tenantId, cartaPorteId); + + res.json({ data: validacion }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/resumen - Obtiene resumen por tipo de figura + */ + private async getResumenPorTipo(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const resumen = await this.figuraService.getResumenPorTipo(tenantId, cartaPorteId); + + res.json({ data: resumen }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/operadores - Obtiene solo operadores + */ + private async getOperadores(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const operadores = await this.figuraService.getOperadores(tenantId, cartaPorteId); + + res.json({ + data: operadores, + total: operadores.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/propietarios - Obtiene solo propietarios + */ + private async getPropietarios(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const propietarios = await this.figuraService.getPropietarios(tenantId, cartaPorteId); + + res.json({ + data: propietarios, + total: propietarios.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/arrendadores - Obtiene solo arrendadores + */ + private async getArrendadores(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const arrendadores = await this.figuraService.getArrendadores(tenantId, cartaPorteId); + + res.json({ + data: arrendadores, + total: arrendadores.length, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /carta-porte/:cartaPorteId/operador - Agrega un operador con datos minimos + */ + private async addOperador(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const { rfcFigura, nombreFigura, numLicencia, pais, estado, codigoPostal } = req.body; + + if (!rfcFigura || !nombreFigura || !numLicencia) { + res.status(400).json({ + error: 'rfcFigura, nombreFigura y numLicencia son requeridos para operador', + }); + return; + } + + const figura = await this.figuraService.addOperador(tenantId, cartaPorteId, { + rfcFigura, + nombreFigura, + numLicencia, + pais, + estado, + codigoPostal, + }); + + res.status(201).json({ data: figura }); + } catch (error) { + next(error); + } + } + + /** + * POST /carta-porte/:cartaPorteId/propietario - Agrega un propietario de mercancia + */ + private async addPropietario(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const { rfcFigura, nombreFigura, pais, estado, codigoPostal, calle, partesTransporte } = req.body; + + if (!rfcFigura || !nombreFigura) { + res.status(400).json({ + error: 'rfcFigura y nombreFigura son requeridos para propietario', + }); + return; + } + + const figura = await this.figuraService.addPropietario(tenantId, cartaPorteId, { + rfcFigura, + nombreFigura, + pais, + estado, + codigoPostal, + calle, + partesTransporte, + }); + + res.status(201).json({ data: figura }); + } catch (error) { + next(error); + } + } + + /** + * POST /carta-porte/:cartaPorteId/arrendador - Agrega un arrendador + */ + private async addArrendador(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const { rfcFigura, nombreFigura, pais, estado, codigoPostal, calle, partesTransporte } = req.body; + + if (!rfcFigura || !nombreFigura) { + res.status(400).json({ + error: 'rfcFigura y nombreFigura son requeridos para arrendador', + }); + return; + } + + const figura = await this.figuraService.addArrendador(tenantId, cartaPorteId, { + rfcFigura, + nombreFigura, + pais, + estado, + codigoPostal, + calle, + partesTransporte, + }); + + res.status(201).json({ data: figura }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/carta-porte/controllers/index.ts b/src/modules/carta-porte/controllers/index.ts index ddf616d..f8da86a 100644 --- a/src/modules/carta-porte/controllers/index.ts +++ b/src/modules/carta-porte/controllers/index.ts @@ -1,5 +1,20 @@ /** * Carta Porte Controllers + * CFDI con Complemento Carta Porte 3.1 */ -// TODO: Implement controllers -// - carta-porte.controller.ts + +// Mercancia (Cargo/Merchandise) Controller +export * from './mercancia.controller'; + +// Ubicacion (Locations) Controller +export * from './ubicacion-carta-porte.controller'; + +// Figura Transporte (Transportation Figures) Controller +export * from './figura-transporte.controller'; + +// Inspeccion Pre-Viaje (Pre-trip Inspection) Controller +export * from './inspeccion-pre-viaje.controller'; + +// TODO: Implement additional controllers +// - carta-porte.controller.ts (main carta porte CRUD) +// - cfdi.controller.ts (timbrado y cancelacion) diff --git a/src/modules/carta-porte/controllers/inspeccion-pre-viaje.controller.ts b/src/modules/carta-porte/controllers/inspeccion-pre-viaje.controller.ts new file mode 100644 index 0000000..95ba95e --- /dev/null +++ b/src/modules/carta-porte/controllers/inspeccion-pre-viaje.controller.ts @@ -0,0 +1,520 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { + InspeccionPreViajeService, + CreateInspeccionDto, + UpdateInspeccionDto, + DateRange, +} from '../services/inspeccion-pre-viaje.service'; +import { ChecklistItem, FotoInspeccion } from '../entities'; + +/** + * Controlador para gestion de inspecciones pre-viaje + * NOM-087-SCT-2-2017 Compliance - Checklists de seguridad vehicular + */ +export class InspeccionPreViajeController { + public router: Router; + + constructor(private readonly inspeccionService: InspeccionPreViajeService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // CRUD basico + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + + // Consultas por relaciones + this.router.get('/viaje/:viajeId', this.findByViaje.bind(this)); + this.router.get('/unidad/:unidadId', this.findByUnidad.bind(this)); + this.router.get('/operador/:operadorId', this.findByOperador.bind(this)); + + // Operaciones de checklist + this.router.post('/:id/checklist-item', this.addChecklistItem.bind(this)); + this.router.patch('/:id/checklist-items', this.updateChecklistItems.bind(this)); + + // Operaciones de finalizacion + this.router.post('/:id/completar', this.completarInspeccion.bind(this)); + + // Consultas especificas + this.router.get('/:id/defectos', this.getDefectos.bind(this)); + this.router.get('/unidad/:unidadId/puede-despachar', this.canDespachar.bind(this)); + this.router.get('/unidad/:unidadId/ultima-aprobada', this.getUltimaAprobada.bind(this)); + + // Fotos + this.router.post('/:id/fotos', this.addFotos.bind(this)); + + // Estadisticas + this.router.get('/estadisticas/periodo', this.getEstadisticas.bind(this)); + } + + /** + * GET / - Obtiene inspecciones (con filtros opcionales) + */ + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId, operadorId, viajeId, desde, hasta } = req.query; + + let inspecciones; + const dateRange: DateRange | undefined = + desde && hasta + ? { desde: new Date(desde as string), hasta: new Date(hasta as string) } + : undefined; + + if (viajeId) { + inspecciones = await this.inspeccionService.findByViaje(tenantId, viajeId as string); + } else if (unidadId) { + inspecciones = await this.inspeccionService.findByUnidad( + tenantId, + unidadId as string, + dateRange + ); + } else if (operadorId) { + inspecciones = await this.inspeccionService.findByOperador( + tenantId, + operadorId as string, + dateRange + ); + } else { + res.status(400).json({ + error: 'Se requiere al menos uno de: viajeId, unidadId, operadorId', + }); + return; + } + + res.json({ + data: inspecciones, + total: inspecciones.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /:id - Obtiene una inspeccion por ID + */ + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const inspeccion = await this.inspeccionService.findById(tenantId, id); + + if (!inspeccion) { + res.status(404).json({ error: 'Inspeccion pre-viaje no encontrada' }); + return; + } + + res.json({ data: inspeccion }); + } catch (error) { + next(error); + } + } + + /** + * POST / - Crea una nueva inspeccion pre-viaje + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId, operadorId, viajeId, remolqueId, fechaInspeccion, checklistItems, fotos } = + req.body; + + if (!unidadId || !operadorId || !viajeId) { + res.status(400).json({ + error: 'unidadId, operadorId y viajeId son requeridos', + }); + return; + } + + const dto: CreateInspeccionDto = { + viajeId, + remolqueId, + fechaInspeccion: fechaInspeccion ? new Date(fechaInspeccion) : undefined, + checklistItems, + fotos, + }; + + const inspeccion = await this.inspeccionService.create(tenantId, unidadId, operadorId, dto); + + res.status(201).json({ data: inspeccion }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /:id - Actualiza una inspeccion (checklist items y fotos) + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const { checklistItems } = req.body; + + if (!checklistItems || !Array.isArray(checklistItems)) { + res.status(400).json({ error: 'checklistItems es requerido y debe ser un array' }); + return; + } + + const inspeccion = await this.inspeccionService.updateChecklistItems( + tenantId, + id, + checklistItems + ); + + if (!inspeccion) { + res.status(404).json({ error: 'Inspeccion pre-viaje no encontrada' }); + return; + } + + res.json({ data: inspeccion }); + } catch (error) { + next(error); + } + } + + /** + * GET /viaje/:viajeId - Obtiene inspecciones de un viaje + */ + private async findByViaje(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { viajeId } = req.params; + const inspecciones = await this.inspeccionService.findByViaje(tenantId, viajeId); + + res.json({ + data: inspecciones, + total: inspecciones.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /unidad/:unidadId - Obtiene inspecciones de una unidad + */ + private async findByUnidad(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId } = req.params; + const { desde, hasta } = req.query; + + const dateRange: DateRange | undefined = + desde && hasta + ? { desde: new Date(desde as string), hasta: new Date(hasta as string) } + : undefined; + + const inspecciones = await this.inspeccionService.findByUnidad(tenantId, unidadId, dateRange); + + res.json({ + data: inspecciones, + total: inspecciones.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /operador/:operadorId - Obtiene inspecciones de un operador + */ + private async findByOperador(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { operadorId } = req.params; + const { desde, hasta } = req.query; + + const dateRange: DateRange | undefined = + desde && hasta + ? { desde: new Date(desde as string), hasta: new Date(hasta as string) } + : undefined; + + const inspecciones = await this.inspeccionService.findByOperador( + tenantId, + operadorId, + dateRange + ); + + res.json({ + data: inspecciones, + total: inspecciones.length, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /:id/checklist-item - Agrega o actualiza un item del checklist + */ + private async addChecklistItem(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const item: ChecklistItem = req.body; + + if (!item.item || !item.categoria || !item.estado) { + res.status(400).json({ + error: 'item, categoria y estado son requeridos', + }); + return; + } + + const inspeccion = await this.inspeccionService.addChecklistItem(tenantId, id, item); + + if (!inspeccion) { + res.status(404).json({ error: 'Inspeccion pre-viaje no encontrada' }); + return; + } + + res.json({ data: inspeccion }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /:id/checklist-items - Actualiza multiples items del checklist + */ + private async updateChecklistItems(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const { items } = req.body; + + if (!items || !Array.isArray(items)) { + res.status(400).json({ error: 'items es requerido y debe ser un array' }); + return; + } + + const inspeccion = await this.inspeccionService.updateChecklistItems(tenantId, id, items); + + if (!inspeccion) { + res.status(404).json({ error: 'Inspeccion pre-viaje no encontrada' }); + return; + } + + res.json({ data: inspeccion }); + } catch (error) { + next(error); + } + } + + /** + * POST /:id/completar - Completa una inspeccion + */ + private async completarInspeccion(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const { firmaOperador } = req.body; + + const inspeccion = await this.inspeccionService.completeInspeccion(tenantId, id, firmaOperador); + + if (!inspeccion) { + res.status(404).json({ error: 'Inspeccion pre-viaje no encontrada' }); + return; + } + + res.json({ + data: inspeccion, + message: inspeccion.aprobada + ? 'Inspeccion completada y aprobada' + : 'Inspeccion completada pero NO aprobada debido a defectos criticos', + }); + } catch (error) { + next(error); + } + } + + /** + * GET /:id/defectos - Obtiene resumen de defectos de una inspeccion + */ + private async getDefectos(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const defectos = await this.inspeccionService.getDefectos(tenantId, id); + + if (!defectos) { + res.status(404).json({ error: 'Inspeccion pre-viaje no encontrada' }); + return; + } + + res.json({ data: defectos }); + } catch (error) { + next(error); + } + } + + /** + * GET /unidad/:unidadId/puede-despachar - Verifica si una unidad puede ser despachada + */ + private async canDespachar(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId } = req.params; + const resultado = await this.inspeccionService.canDespachar(tenantId, unidadId); + + res.json({ data: resultado }); + } catch (error) { + next(error); + } + } + + /** + * GET /unidad/:unidadId/ultima-aprobada - Obtiene la ultima inspeccion aprobada + */ + private async getUltimaAprobada(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId } = req.params; + const inspeccion = await this.inspeccionService.getUltimaInspeccionAprobada( + tenantId, + unidadId + ); + + if (!inspeccion) { + res.status(404).json({ error: 'No hay inspecciones aprobadas para esta unidad' }); + return; + } + + res.json({ data: inspeccion }); + } catch (error) { + next(error); + } + } + + /** + * POST /:id/fotos - Agrega fotos a una inspeccion + */ + private async addFotos(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const { fotos } = req.body; + + if (!fotos || !Array.isArray(fotos)) { + res.status(400).json({ error: 'fotos es requerido y debe ser un array' }); + return; + } + + const inspeccion = await this.inspeccionService.addFotos(tenantId, id, fotos as FotoInspeccion[]); + + if (!inspeccion) { + res.status(404).json({ error: 'Inspeccion pre-viaje no encontrada' }); + return; + } + + res.json({ data: inspeccion }); + } catch (error) { + next(error); + } + } + + /** + * GET /estadisticas/periodo - Obtiene estadisticas de inspecciones por periodo + */ + private async getEstadisticas(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { desde, hasta } = req.query; + + if (!desde || !hasta) { + res.status(400).json({ error: 'desde y hasta son requeridos' }); + return; + } + + const dateRange: DateRange = { + desde: new Date(desde as string), + hasta: new Date(hasta as string), + }; + + const estadisticas = await this.inspeccionService.getEstadisticas(tenantId, dateRange); + + res.json({ data: estadisticas }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/carta-porte/controllers/mercancia.controller.ts b/src/modules/carta-porte/controllers/mercancia.controller.ts new file mode 100644 index 0000000..e12af56 --- /dev/null +++ b/src/modules/carta-porte/controllers/mercancia.controller.ts @@ -0,0 +1,282 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { + MercanciaService, + CreateMercanciaDto, + UpdateMercanciaDto, +} from '../services/mercancia.service'; +import { MercanciaCartaPorte } from '../entities'; + +/** + * Controlador para gestion de mercancias de Carta Porte + * CFDI 3.1 Compliance - Manejo de bienes transportados + */ +export class MercanciaController { + public router: Router; + + constructor(private readonly mercanciaService: MercanciaService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // CRUD basico + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Operaciones por carta porte + this.router.get('/carta-porte/:cartaPorteId', this.findByCartaParte.bind(this)); + + // Validaciones y calculos + this.router.get('/carta-porte/:cartaPorteId/validar-pesos', this.validarPesos.bind(this)); + this.router.get('/carta-porte/:cartaPorteId/valor-total', this.getValorTotal.bind(this)); + this.router.get('/carta-porte/:cartaPorteId/peligrosas', this.getMercanciasPeligrosas.bind(this)); + this.router.get('/carta-porte/:cartaPorteId/estadisticas-peligrosas', this.getEstadisticasPeligrosas.bind(this)); + } + + /** + * GET / - Obtiene todas las mercancias (requiere cartaPorteId en query) + */ + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.query; + + if (!cartaPorteId) { + res.status(400).json({ error: 'cartaPorteId es requerido en query params' }); + return; + } + + const mercancias = await this.mercanciaService.findByCartaParte( + tenantId, + cartaPorteId as string + ); + + res.json({ + data: mercancias, + total: mercancias.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /:id - Obtiene una mercancia por ID + */ + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const mercancia = await this.mercanciaService.findById(tenantId, id); + + if (!mercancia) { + res.status(404).json({ error: 'Mercancia no encontrada' }); + return; + } + + res.json({ data: mercancia }); + } catch (error) { + next(error); + } + } + + /** + * POST / - Crea una nueva mercancia + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId, ...mercanciaData } = req.body; + + if (!cartaPorteId) { + res.status(400).json({ error: 'cartaPorteId es requerido' }); + return; + } + + const dto: CreateMercanciaDto = mercanciaData; + const mercancia = await this.mercanciaService.create(tenantId, cartaPorteId, dto); + + res.status(201).json({ data: mercancia }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /:id - Actualiza una mercancia existente + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdateMercanciaDto = req.body; + const mercancia = await this.mercanciaService.update(tenantId, id, dto); + + if (!mercancia) { + res.status(404).json({ error: 'Mercancia no encontrada' }); + return; + } + + res.json({ data: mercancia }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /:id - Elimina una mercancia + */ + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const deleted = await this.mercanciaService.delete(tenantId, id); + + if (!deleted) { + res.status(404).json({ error: 'Mercancia no encontrada' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId - Obtiene mercancias de una Carta Porte + */ + private async findByCartaParte(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const mercancias = await this.mercanciaService.findByCartaParte(tenantId, cartaPorteId); + + res.json({ + data: mercancias, + total: mercancias.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/validar-pesos - Valida pesos de mercancias vs declarado + */ + private async validarPesos(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const validacion = await this.mercanciaService.validatePesosTotales(tenantId, cartaPorteId); + + res.json({ data: validacion }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/valor-total - Calcula valor total de mercancias + */ + private async getValorTotal(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const resumen = await this.mercanciaService.calculateValorMercancia(tenantId, cartaPorteId); + + res.json({ data: resumen }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/peligrosas - Obtiene solo mercancias peligrosas + */ + private async getMercanciasPeligrosas(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const mercancias = await this.mercanciaService.getMercanciasPeligrosas(tenantId, cartaPorteId); + + res.json({ + data: mercancias, + total: mercancias.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/estadisticas-peligrosas - Estadisticas de mercancias peligrosas + */ + private async getEstadisticasPeligrosas(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const estadisticas = await this.mercanciaService.getEstadisticasMercanciasPeligrosas( + tenantId, + cartaPorteId + ); + + res.json({ data: estadisticas }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/carta-porte/controllers/ubicacion-carta-porte.controller.ts b/src/modules/carta-porte/controllers/ubicacion-carta-porte.controller.ts new file mode 100644 index 0000000..3902dbb --- /dev/null +++ b/src/modules/carta-porte/controllers/ubicacion-carta-porte.controller.ts @@ -0,0 +1,320 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { + UbicacionCartaPorteService, + CreateUbicacionDto, + UpdateUbicacionDto, +} from '../services/ubicacion-carta-porte.service'; + +/** + * Controlador para gestion de ubicaciones de Carta Porte + * CFDI 3.1 Compliance - Manejo de origenes, destinos y puntos intermedios + */ +export class UbicacionCartaPorteController { + public router: Router; + + constructor(private readonly ubicacionService: UbicacionCartaPorteService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // CRUD basico + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Operaciones por carta porte + this.router.get('/carta-porte/:cartaPorteId', this.findByCartaParte.bind(this)); + this.router.post('/carta-porte/:cartaPorteId/reorder', this.reorder.bind(this)); + + // Validaciones y consultas especificas + this.router.get('/carta-porte/:cartaPorteId/validar', this.validarSecuencia.bind(this)); + this.router.get('/carta-porte/:cartaPorteId/origen', this.getOrigen.bind(this)); + this.router.get('/carta-porte/:cartaPorteId/destino', this.getDestino.bind(this)); + this.router.get('/carta-porte/:cartaPorteId/distancia-total', this.getDistanciaTotal.bind(this)); + } + + /** + * GET / - Obtiene todas las ubicaciones (requiere cartaPorteId en query) + */ + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.query; + + if (!cartaPorteId) { + res.status(400).json({ error: 'cartaPorteId es requerido en query params' }); + return; + } + + const ubicaciones = await this.ubicacionService.findByCartaParte( + tenantId, + cartaPorteId as string + ); + + res.json({ + data: ubicaciones, + total: ubicaciones.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /:id - Obtiene una ubicacion por ID + */ + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const ubicacion = await this.ubicacionService.findById(tenantId, id); + + if (!ubicacion) { + res.status(404).json({ error: 'Ubicacion no encontrada' }); + return; + } + + res.json({ data: ubicacion }); + } catch (error) { + next(error); + } + } + + /** + * POST / - Crea una nueva ubicacion + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId, ...ubicacionData } = req.body; + + if (!cartaPorteId) { + res.status(400).json({ error: 'cartaPorteId es requerido' }); + return; + } + + const dto: CreateUbicacionDto = ubicacionData; + const ubicacion = await this.ubicacionService.create(tenantId, cartaPorteId, dto); + + res.status(201).json({ data: ubicacion }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /:id - Actualiza una ubicacion existente + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdateUbicacionDto = req.body; + const ubicacion = await this.ubicacionService.update(tenantId, id, dto); + + if (!ubicacion) { + res.status(404).json({ error: 'Ubicacion no encontrada' }); + return; + } + + res.json({ data: ubicacion }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /:id - Elimina una ubicacion + */ + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const deleted = await this.ubicacionService.delete(tenantId, id); + + if (!deleted) { + res.status(404).json({ error: 'Ubicacion no encontrada' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId - Obtiene ubicaciones de una Carta Porte + */ + private async findByCartaParte(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const ubicaciones = await this.ubicacionService.findByCartaParte(tenantId, cartaPorteId); + + res.json({ + data: ubicaciones, + total: ubicaciones.length, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /carta-porte/:cartaPorteId/reorder - Reordena las ubicaciones + */ + private async reorder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const { orden } = req.body; + + if (!Array.isArray(orden) || orden.length === 0) { + res.status(400).json({ error: 'orden es requerido y debe ser un array de IDs' }); + return; + } + + const ubicaciones = await this.ubicacionService.reorder(tenantId, cartaPorteId, orden); + + res.json({ + data: ubicaciones, + total: ubicaciones.length, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/validar - Valida secuencia de ubicaciones + */ + private async validarSecuencia(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const validacion = await this.ubicacionService.validateSecuencia(tenantId, cartaPorteId); + + res.json({ data: validacion }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/origen - Obtiene la ubicacion de origen + */ + private async getOrigen(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const origen = await this.ubicacionService.getOrigen(tenantId, cartaPorteId); + + if (!origen) { + res.status(404).json({ error: 'Origen no encontrado' }); + return; + } + + res.json({ data: origen }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/destino - Obtiene la ubicacion de destino final + */ + private async getDestino(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const destino = await this.ubicacionService.getDestinoFinal(tenantId, cartaPorteId); + + if (!destino) { + res.status(404).json({ error: 'Destino no encontrado' }); + return; + } + + res.json({ data: destino }); + } catch (error) { + next(error); + } + } + + /** + * GET /carta-porte/:cartaPorteId/distancia-total - Obtiene distancia total del recorrido + */ + private async getDistanciaTotal(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { cartaPorteId } = req.params; + const distancia = await this.ubicacionService.getDistanciaTotal(tenantId, cartaPorteId); + + res.json({ + data: { + distanciaTotalKm: distancia, + }, + }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/gestion-flota/controllers/asignacion.controller.ts b/src/modules/gestion-flota/controllers/asignacion.controller.ts new file mode 100644 index 0000000..fc2c180 --- /dev/null +++ b/src/modules/gestion-flota/controllers/asignacion.controller.ts @@ -0,0 +1,410 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { AsignacionService, CreateAsignacionDto } from '../services/asignacion.service'; + +/** + * Controller for managing unit-operator assignments (asignaciones) + * Handles assignment of operators to fleet units + */ +export class AsignacionController { + public router: Router; + + constructor(private readonly asignacionService: AsignacionService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // CRUD and query routes + this.router.get('/', this.findAll.bind(this)); + this.router.get('/activa/unidad/:unidadId', this.getAsignacionActivaUnidad.bind(this)); + this.router.get('/activa/operador/:operadorId', this.getAsignacionActivaOperador.bind(this)); + this.router.get('/historial/unidad/:unidadId', this.getHistorialUnidad.bind(this)); + this.router.get('/historial/operador/:operadorId', this.getHistorialOperador.bind(this)); + this.router.get('/disponibilidad', this.validateDisponibilidad.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + + // Operations + this.router.post('/:id/finalizar', this.finalizar.bind(this)); + this.router.post('/transferir', this.transferir.bind(this)); + } + + /** + * GET /asignaciones + * Find all assignments with optional filters + * Query params: unidadId, operadorId, activa + */ + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId, operadorId, activa } = req.query; + + let asignaciones; + + if (activa === 'true') { + asignaciones = await this.asignacionService.findActivas(tenantId); + } else if (unidadId) { + asignaciones = await this.asignacionService.findByUnidad(tenantId, unidadId as string); + } else if (operadorId) { + asignaciones = await this.asignacionService.findByOperador(tenantId, operadorId as string); + } else { + // Default: return all active assignments + asignaciones = await this.asignacionService.findActivas(tenantId); + } + + res.json({ data: asignaciones, total: asignaciones.length }); + } catch (error) { + next(error); + } + } + + /** + * GET /asignaciones/:id + * Find an assignment by ID + */ + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const asignacion = await this.asignacionService.findById(tenantId, id); + + if (!asignacion) { + res.status(404).json({ error: 'Asignación no encontrada' }); + return; + } + + res.json({ data: asignacion }); + } catch (error) { + next(error); + } + } + + /** + * GET /asignaciones/activa/unidad/:unidadId + * Get the current active assignment for a unit + */ + private async getAsignacionActivaUnidad(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId } = req.params; + const asignacion = await this.asignacionService.getAsignacionActual(tenantId, unidadId); + + if (!asignacion) { + res.status(404).json({ + error: 'No hay asignación activa para esta unidad', + unidadId, + }); + return; + } + + res.json({ data: asignacion }); + } catch (error) { + next(error); + } + } + + /** + * GET /asignaciones/activa/operador/:operadorId + * Get the current active assignment for an operator + */ + private async getAsignacionActivaOperador(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { operadorId } = req.params; + const asignaciones = await this.asignacionService.findByOperador(tenantId, operadorId); + const asignacionActiva = asignaciones.find(a => a.activa); + + if (!asignacionActiva) { + res.status(404).json({ + error: 'No hay asignación activa para este operador', + operadorId, + }); + return; + } + + res.json({ data: asignacionActiva }); + } catch (error) { + next(error); + } + } + + /** + * GET /asignaciones/historial/unidad/:unidadId + * Get assignment history for a unit + */ + private async getHistorialUnidad(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId } = req.params; + const historial = await this.asignacionService.getHistorial(tenantId, unidadId); + res.json({ data: historial, total: historial.length }); + } catch (error) { + next(error); + } + } + + /** + * GET /asignaciones/historial/operador/:operadorId + * Get assignment history for an operator + */ + private async getHistorialOperador(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { operadorId } = req.params; + const asignaciones = await this.asignacionService.findByOperador(tenantId, operadorId); + + // Calculate duration for each assignment + const historial = asignaciones.map(asignacion => { + const fechaFin = asignacion.fechaFin || new Date(); + const duracionMs = fechaFin.getTime() - asignacion.fechaInicio.getTime(); + const duracionDias = Math.ceil(duracionMs / (1000 * 60 * 60 * 24)); + + return { + asignacion, + duracionDias, + }; + }); + + res.json({ data: historial, total: historial.length }); + } catch (error) { + next(error); + } + } + + /** + * POST /asignaciones + * Create a new assignment (assign operator to unit) + * Body: { unidadId, operadorId, remolqueId?, motivo?, fechaInicio? } + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId, operadorId, remolqueId, motivo, fechaInicio } = req.body; + + // Validate required fields + if (!unidadId) { + res.status(400).json({ error: 'ID de unidad es requerido' }); + return; + } + if (!operadorId) { + res.status(400).json({ error: 'ID de operador es requerido' }); + return; + } + + const dto: CreateAsignacionDto = { + remolqueId, + motivo, + fechaInicio: fechaInicio ? new Date(fechaInicio) : undefined, + }; + + const asignacion = await this.asignacionService.create( + tenantId, + unidadId, + operadorId, + dto, + userId + ); + + res.status(201).json({ + data: asignacion, + message: 'Asignación creada exitosamente', + }); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('No se puede crear la asignacion')) { + res.status(400).json({ error: error.message }); + return; + } + if (error.message.includes('ya tiene')) { + res.status(409).json({ error: error.message }); + return; + } + if (error.message.includes('no encontrad')) { + res.status(404).json({ error: error.message }); + return; + } + } + next(error); + } + } + + /** + * POST /asignaciones/:id/finalizar + * End an active assignment + * Body: { motivo } + */ + private async finalizar(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const { motivo } = req.body; + + if (!motivo) { + res.status(400).json({ error: 'Motivo de finalización es requerido' }); + return; + } + + const asignacion = await this.asignacionService.terminar(tenantId, id, motivo, userId); + res.json({ + data: asignacion, + message: 'Asignación finalizada exitosamente', + }); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('no encontrada')) { + res.status(404).json({ error: error.message }); + return; + } + if (error.message.includes('ya esta terminada')) { + res.status(400).json({ error: error.message }); + return; + } + if (error.message.includes('en viaje')) { + res.status(409).json({ error: error.message }); + return; + } + } + next(error); + } + } + + /** + * POST /asignaciones/transferir + * Transfer a unit to a new operator (ends current assignment and creates new one) + * Body: { unidadId, nuevoOperadorId, motivo } + */ + private async transferir(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId, nuevoOperadorId, motivo } = req.body; + + // Validate required fields + if (!unidadId) { + res.status(400).json({ error: 'ID de unidad es requerido' }); + return; + } + if (!nuevoOperadorId) { + res.status(400).json({ error: 'ID del nuevo operador es requerido' }); + return; + } + if (!motivo) { + res.status(400).json({ error: 'Motivo de transferencia es requerido' }); + return; + } + + const asignacion = await this.asignacionService.transferir( + tenantId, + unidadId, + nuevoOperadorId, + motivo, + userId + ); + + res.status(201).json({ + data: asignacion, + message: 'Unidad transferida exitosamente al nuevo operador', + }); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('no encontrad')) { + res.status(404).json({ error: error.message }); + return; + } + if (error.message.includes('en viaje')) { + res.status(409).json({ error: error.message }); + return; + } + if (error.message.includes('No se puede crear')) { + res.status(400).json({ error: error.message }); + return; + } + } + next(error); + } + } + + /** + * GET /asignaciones/disponibilidad + * Validate availability of unit and operator for assignment + * Query params: unidadId, operadorId + */ + private async validateDisponibilidad(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId, operadorId } = req.query; + + if (!unidadId || !operadorId) { + res.status(400).json({ + error: 'Se requieren unidadId y operadorId para validar disponibilidad', + }); + return; + } + + const disponibilidad = await this.asignacionService.validateDisponibilidad( + tenantId, + unidadId as string, + operadorId as string + ); + + res.json({ data: disponibilidad }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/gestion-flota/controllers/documento-flota.controller.ts b/src/modules/gestion-flota/controllers/documento-flota.controller.ts new file mode 100644 index 0000000..b289d67 --- /dev/null +++ b/src/modules/gestion-flota/controllers/documento-flota.controller.ts @@ -0,0 +1,411 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { + DocumentoFlotaService, + CreateDocumentoFlotaDto, + UpdateDocumentoFlotaDto, +} from '../services/documento-flota.service'; +import { TipoDocumento, TipoEntidadDocumento } from '../entities'; + +/** + * Controller for managing fleet documents (documentos de flota) + * Handles documents for units, trailers, and operators + */ +export class DocumentoFlotaController { + public router: Router; + + constructor(private readonly documentoFlotaService: DocumentoFlotaService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // CRUD básico + this.router.get('/', this.findAll.bind(this)); + this.router.get('/por-vencer', this.getDocumentosPorVencer.bind(this)); + this.router.get('/vencidos', this.getDocumentosVencidos.bind(this)); + this.router.get('/resumen-vencimientos', this.getResumenVencimientos.bind(this)); + this.router.get('/entidades-bloqueadas', this.getEntidadesBloqueadas.bind(this)); + this.router.get('/alertas', this.getAlertasVencimiento.bind(this)); + this.router.get('/unidad/:unidadId', this.findByUnidad.bind(this)); + this.router.get('/operador/:operadorId', this.findByOperador.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Operaciones específicas + this.router.post('/:id/renovar', this.renovar.bind(this)); + } + + /** + * GET /documentos-flota + * Find all documents with optional filters + * Query params: unidadId, operadorId, tipoDocumento, estadoVigencia + */ + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId, operadorId, tipoDocumento, estadoVigencia } = req.query; + + // Handle different filter scenarios + let documentos; + + if (unidadId) { + documentos = await this.documentoFlotaService.findByUnidad(tenantId, unidadId as string); + } else if (operadorId) { + documentos = await this.documentoFlotaService.findByOperador(tenantId, operadorId as string); + } else if (tipoDocumento) { + documentos = await this.documentoFlotaService.findByTipo(tenantId, tipoDocumento as TipoDocumento); + } else if (estadoVigencia === 'vencido') { + documentos = await this.documentoFlotaService.findVencidos(tenantId); + } else if (estadoVigencia === 'por_vencer') { + documentos = await this.documentoFlotaService.findPorVencer(tenantId, 30); + } else { + // Default: return documents expiring soon (all types) + documentos = await this.documentoFlotaService.findPorVencer(tenantId, 365); + } + + res.json({ data: documentos, total: documentos.length }); + } catch (error) { + next(error); + } + } + + /** + * GET /documentos-flota/:id + * Find a document by ID + */ + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const documento = await this.documentoFlotaService.findById(tenantId, id); + + if (!documento) { + res.status(404).json({ error: 'Documento no encontrado' }); + return; + } + + res.json({ data: documento }); + } catch (error) { + next(error); + } + } + + /** + * POST /documentos-flota + * Create a new fleet document + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: CreateDocumentoFlotaDto = req.body; + + // Validate required fields + if (!dto.entidadTipo) { + res.status(400).json({ error: 'Tipo de entidad es requerido' }); + return; + } + if (!dto.entidadId) { + res.status(400).json({ error: 'ID de entidad es requerido' }); + return; + } + if (!dto.tipoDocumento) { + res.status(400).json({ error: 'Tipo de documento es requerido' }); + return; + } + if (!dto.nombre) { + res.status(400).json({ error: 'Nombre del documento es requerido' }); + return; + } + + const documento = await this.documentoFlotaService.create(tenantId, dto, userId); + res.status(201).json({ data: documento }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /documentos-flota/:id + * Update a fleet document + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdateDocumentoFlotaDto = req.body; + + const documento = await this.documentoFlotaService.update(tenantId, id, dto, userId); + res.json({ data: documento }); + } catch (error) { + if (error instanceof Error && error.message.includes('no encontrado')) { + res.status(404).json({ error: error.message }); + return; + } + next(error); + } + } + + /** + * DELETE /documentos-flota/:id + * Soft delete a fleet document + */ + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + + await this.documentoFlotaService.delete(tenantId, id); + res.status(204).send(); + } catch (error) { + if (error instanceof Error && error.message.includes('no encontrado')) { + res.status(404).json({ error: error.message }); + return; + } + next(error); + } + } + + /** + * GET /documentos-flota/unidad/:unidadId + * Get all documents for a specific unit + */ + private async findByUnidad(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { unidadId } = req.params; + const documentos = await this.documentoFlotaService.findByUnidad(tenantId, unidadId); + res.json({ data: documentos, total: documentos.length }); + } catch (error) { + if (error instanceof Error && error.message.includes('no encontrada')) { + res.status(404).json({ error: error.message }); + return; + } + next(error); + } + } + + /** + * GET /documentos-flota/operador/:operadorId + * Get all documents for a specific operator + */ + private async findByOperador(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { operadorId } = req.params; + const documentos = await this.documentoFlotaService.findByOperador(tenantId, operadorId); + res.json({ data: documentos, total: documentos.length }); + } catch (error) { + if (error instanceof Error && error.message.includes('no encontrado')) { + res.status(404).json({ error: error.message }); + return; + } + next(error); + } + } + + /** + * GET /documentos-flota/por-vencer + * Get documents expiring soon + * Query param: dias (default: 30) + */ + private async getDocumentosPorVencer(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { dias } = req.query; + const diasAntelacion = dias ? parseInt(dias as string, 10) : 30; + + if (isNaN(diasAntelacion) || diasAntelacion < 1) { + res.status(400).json({ error: 'El parámetro días debe ser un número positivo' }); + return; + } + + const documentos = await this.documentoFlotaService.findPorVencer(tenantId, diasAntelacion); + res.json({ data: documentos, total: documentos.length, diasConsultados: diasAntelacion }); + } catch (error) { + next(error); + } + } + + /** + * GET /documentos-flota/vencidos + * Get all expired documents + */ + private async getDocumentosVencidos(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const documentos = await this.documentoFlotaService.findVencidos(tenantId); + res.json({ data: documentos, total: documentos.length }); + } catch (error) { + next(error); + } + } + + /** + * POST /documentos-flota/:id/renovar + * Renew a document with a new expiration date + * Body: { nuevaFechaVencimiento: Date } + */ + private async renovar(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const { nuevaFechaVencimiento } = req.body; + + if (!nuevaFechaVencimiento) { + res.status(400).json({ error: 'Nueva fecha de vencimiento es requerida' }); + return; + } + + const fechaVencimiento = new Date(nuevaFechaVencimiento); + if (isNaN(fechaVencimiento.getTime())) { + res.status(400).json({ error: 'Fecha de vencimiento inválida' }); + return; + } + + const documento = await this.documentoFlotaService.renovar(tenantId, id, fechaVencimiento, userId); + res.json({ data: documento, message: 'Documento renovado exitosamente' }); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('no encontrado')) { + res.status(404).json({ error: error.message }); + return; + } + if (error.message.includes('fecha futura')) { + res.status(400).json({ error: error.message }); + return; + } + } + next(error); + } + } + + /** + * GET /documentos-flota/resumen-vencimientos + * Get summary of document expirations by category + */ + private async getResumenVencimientos(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const resumen = await this.documentoFlotaService.getResumenVencimientos(tenantId); + res.json({ data: resumen }); + } catch (error) { + next(error); + } + } + + /** + * GET /documentos-flota/entidades-bloqueadas + * Get units and operators blocked due to expired critical documents + */ + private async getEntidadesBloqueadas(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const entidadesBloqueadas = await this.documentoFlotaService.bloquearPorDocumentosVencidos(tenantId); + res.json({ data: entidadesBloqueadas, total: entidadesBloqueadas.length }); + } catch (error) { + next(error); + } + } + + /** + * GET /documentos-flota/alertas + * Get document expiration alerts + * Query param: dias (default: 30) + */ + private async getAlertasVencimiento(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { dias } = req.query; + const diasAnticipacion = dias ? parseInt(dias as string, 10) : 30; + + if (isNaN(diasAnticipacion) || diasAnticipacion < 1) { + res.status(400).json({ error: 'El parámetro días debe ser un número positivo' }); + return; + } + + const alertas = await this.documentoFlotaService.sendAlertasVencimiento(tenantId, diasAnticipacion); + res.json({ + data: alertas, + total: alertas.length, + criticos: alertas.filter(a => a.esCritico).length, + diasConsultados: diasAnticipacion, + }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/gestion-flota/controllers/index.ts b/src/modules/gestion-flota/controllers/index.ts index 344f65d..0f16a5e 100644 --- a/src/modules/gestion-flota/controllers/index.ts +++ b/src/modules/gestion-flota/controllers/index.ts @@ -4,3 +4,5 @@ export { ProductsController, CategoriesController } from './products.controller'; export * from './unidades.controller'; export * from './operadores.controller'; +export * from './documento-flota.controller'; +export * from './asignacion.controller'; diff --git a/src/modules/gps/controllers/evento-geocerca.controller.ts b/src/modules/gps/controllers/evento-geocerca.controller.ts new file mode 100644 index 0000000..f8b0f27 --- /dev/null +++ b/src/modules/gps/controllers/evento-geocerca.controller.ts @@ -0,0 +1,426 @@ +/** + * EventoGeocerca Controller + * ERP Transportistas + * + * REST API endpoints for geofence events (entry/exit/permanence). + * Adapted from erp-mecanicas-diesel MMD-014 GPS Integration + * Module: MAI-006 Tracking + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { + EventoGeocercaService, + EventoGeocercaFilters, + DateRange, +} from '../services/evento-geocerca.service'; +import { TipoEventoGeocerca } from '../entities/evento-geocerca.entity'; + +interface TenantRequest extends Request { + tenantId?: string; + userId?: string; +} + +export function createEventoGeocercaController(dataSource: DataSource): Router { + const router = Router(); + const service = new EventoGeocercaService(dataSource); + + const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + return res.status(400).json({ error: 'Tenant ID es requerido' }); + } + req.tenantId = tenantId; + req.userId = req.headers['x-user-id'] as string; + next(); + }; + + router.use(extractTenant); + + /** + * Helper to parse date range from query parameters + */ + const parseDateRange = (query: any): DateRange => { + const now = new Date(); + const defaultStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7 days ago + return { + fechaInicio: query.fechaDesde ? new Date(query.fechaDesde as string) : defaultStart, + fechaFin: query.fechaHasta ? new Date(query.fechaHasta as string) : now, + }; + }; + + /** + * List geofence events with filters + * GET /api/gps/eventos-geocerca + * Query params: geocercaId, unidadId, viajeId, tipoEvento, fechaDesde, fechaHasta, page, limit + */ + router.get('/', async (req: TenantRequest, res: Response) => { + try { + const filters: EventoGeocercaFilters = { + geocercaId: req.query.geocercaId as string, + unidadId: req.query.unidadId as string, + viajeId: req.query.viajeId as string, + tipoEvento: req.query.tipoEvento as TipoEventoGeocerca, + dispositivoId: req.query.dispositivoId as string, + }; + + const pagination = { + page: parseInt(req.query.page as string, 10) || 1, + limit: Math.min(parseInt(req.query.limit as string, 10) || 50, 200), + }; + + const result = await service.findAll(req.tenantId!, filters, pagination); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get geofence alerts (events marked with alerts) + * GET /api/gps/eventos-geocerca/alertas + */ + router.get('/alertas', async (req: TenantRequest, res: Response) => { + try { + const dateRange = parseDateRange(req.query); + const limit = parseInt(req.query.limit as string, 10) || 50; + + // Get recent events and filter those with alerts + const result = await service.findAll( + req.tenantId!, + {}, + { page: 1, limit: 500 } + ); + + const alertas = result.data.filter( + (evento) => + evento.metadata?.alertaTriggered === true && + evento.tiempoEvento >= dateRange.fechaInicio && + evento.tiempoEvento <= dateRange.fechaFin + ); + + res.json({ + data: alertas.slice(0, limit), + total: alertas.length, + fechaDesde: dateRange.fechaInicio, + fechaHasta: dateRange.fechaFin, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get summary statistics by geofence + * GET /api/gps/eventos-geocerca/resumen/geocerca/:geocercaId + */ + router.get('/resumen/geocerca/:geocercaId', async (req: TenantRequest, res: Response) => { + try { + const dateRange = parseDateRange(req.query); + const estadisticas = await service.getEstadisticas( + req.tenantId!, + req.params.geocercaId, + dateRange + ); + res.json(estadisticas); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get summary statistics by unit + * GET /api/gps/eventos-geocerca/resumen/unidad/:unidadId + */ + router.get('/resumen/unidad/:unidadId', async (req: TenantRequest, res: Response) => { + try { + const dateRange = parseDateRange(req.query); + + // Get events for this unit + const eventos = await service.findByUnidad( + req.tenantId!, + req.params.unidadId, + dateRange + ); + + // Calculate summary statistics + const geocercasVisitadas = new Set(); + let totalEntradas = 0; + let totalSalidas = 0; + let totalPermanencias = 0; + + for (const evento of eventos) { + geocercasVisitadas.add(evento.geocercaId); + switch (evento.tipoEvento) { + case TipoEventoGeocerca.ENTRADA: + totalEntradas++; + break; + case TipoEventoGeocerca.SALIDA: + totalSalidas++; + break; + case TipoEventoGeocerca.PERMANENCIA: + totalPermanencias++; + break; + } + } + + res.json({ + unidadId: req.params.unidadId, + totalEventos: eventos.length, + totalEntradas, + totalSalidas, + totalPermanencias, + geocercasVisitadas: geocercasVisitadas.size, + geocercaIds: Array.from(geocercasVisitadas), + periodoInicio: dateRange.fechaInicio, + periodoFin: dateRange.fechaFin, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get dwell time (permanence) statistics by geofence + * GET /api/gps/eventos-geocerca/permanencia/geocerca/:geocercaId + */ + router.get('/permanencia/geocerca/:geocercaId', async (req: TenantRequest, res: Response) => { + try { + const dateRange = parseDateRange(req.query); + + // Get events for this geofence + const eventos = await service.findByGeocerca( + req.tenantId!, + req.params.geocercaId, + dateRange + ); + + // Group events by unit and calculate dwell times + const unidadEventos: Record = {}; + for (const evento of eventos) { + if (!unidadEventos[evento.unidadId]) { + unidadEventos[evento.unidadId] = []; + } + unidadEventos[evento.unidadId].push(evento); + } + + const tiemposPorUnidad: Array<{ + unidadId: string; + tiempoTotalMinutos: number; + visitas: number; + promedioMinutosPorVisita: number; + }> = []; + + for (const [unidadId, eventosUnidad] of Object.entries(unidadEventos)) { + const tiempoInfo = await service.getTiempoEnGeocerca( + req.tenantId!, + unidadId, + req.params.geocercaId + ); + tiemposPorUnidad.push({ + unidadId, + tiempoTotalMinutos: tiempoInfo.tiempoTotalMinutos, + visitas: tiempoInfo.visitasTotales, + promedioMinutosPorVisita: tiempoInfo.promedioMinutosPorVisita, + }); + } + + // Sort by total time descending + tiemposPorUnidad.sort((a, b) => b.tiempoTotalMinutos - a.tiempoTotalMinutos); + + const tiempoTotalGeocerca = tiemposPorUnidad.reduce( + (sum, t) => sum + t.tiempoTotalMinutos, + 0 + ); + const visitasTotales = tiemposPorUnidad.reduce((sum, t) => sum + t.visitas, 0); + + res.json({ + geocercaId: req.params.geocercaId, + tiempoTotalMinutos: Math.round(tiempoTotalGeocerca * 100) / 100, + visitasTotales, + unidadesUnicas: tiemposPorUnidad.length, + promedioMinutosPorVisita: + visitasTotales > 0 + ? Math.round((tiempoTotalGeocerca / visitasTotales) * 100) / 100 + : 0, + detallesPorUnidad: tiemposPorUnidad, + periodoInicio: dateRange.fechaInicio, + periodoFin: dateRange.fechaFin, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get events by geofence + * GET /api/gps/eventos-geocerca/geocerca/:geocercaId + */ + router.get('/geocerca/:geocercaId', async (req: TenantRequest, res: Response) => { + try { + const dateRange = parseDateRange(req.query); + const eventos = await service.findByGeocerca( + req.tenantId!, + req.params.geocercaId, + dateRange + ); + res.json({ + data: eventos, + total: eventos.length, + geocercaId: req.params.geocercaId, + fechaDesde: dateRange.fechaInicio, + fechaHasta: dateRange.fechaFin, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get events by unit + * GET /api/gps/eventos-geocerca/unidad/:unidadId + */ + router.get('/unidad/:unidadId', async (req: TenantRequest, res: Response) => { + try { + const dateRange = parseDateRange(req.query); + const eventos = await service.findByUnidad( + req.tenantId!, + req.params.unidadId, + dateRange + ); + res.json({ + data: eventos, + total: eventos.length, + unidadId: req.params.unidadId, + fechaDesde: dateRange.fechaInicio, + fechaHasta: dateRange.fechaFin, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get events by trip + * GET /api/gps/eventos-geocerca/viaje/:viajeId + */ + router.get('/viaje/:viajeId', async (req: TenantRequest, res: Response) => { + try { + const filters: EventoGeocercaFilters = { + viajeId: req.params.viajeId, + }; + + const result = await service.findAll(req.tenantId!, filters, { page: 1, limit: 500 }); + res.json({ + data: result.data, + total: result.total, + viajeId: req.params.viajeId, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Register a new geofence event + * POST /api/gps/eventos-geocerca + */ + router.post('/', async (req: TenantRequest, res: Response) => { + try { + const evento = await service.create(req.tenantId!, { + geocercaId: req.body.geocercaId, + dispositivoId: req.body.dispositivoId, + unidadId: req.body.unidadId, + tipoEvento: req.body.tipoEvento, + posicionId: req.body.posicionId, + latitud: parseFloat(req.body.latitud), + longitud: parseFloat(req.body.longitud), + tiempoEvento: new Date(req.body.tiempoEvento), + viajeId: req.body.viajeId, + metadata: req.body.metadata, + }); + res.status(201).json(evento); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Trigger alert for an event + * POST /api/gps/eventos-geocerca/:id/alerta + */ + router.post('/:id/alerta', async (req: TenantRequest, res: Response) => { + try { + const evento = await service.triggerAlerta(req.tenantId!, req.params.id); + if (!evento) { + return res.status(404).json({ error: 'Evento de geocerca no encontrado' }); + } + res.json(evento); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get units currently inside a geofence + * GET /api/gps/eventos-geocerca/geocerca/:geocercaId/unidades-dentro + */ + router.get('/geocerca/:geocercaId/unidades-dentro', async (req: TenantRequest, res: Response) => { + try { + const unidades = await service.getUnidadesEnGeocerca( + req.tenantId!, + req.params.geocercaId + ); + res.json({ + data: unidades, + total: unidades.length, + geocercaId: req.params.geocercaId, + timestamp: new Date(), + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Get event by ID + * GET /api/gps/eventos-geocerca/:id + */ + router.get('/:id', async (req: TenantRequest, res: Response) => { + try { + const result = await service.findAll( + req.tenantId!, + {}, + { page: 1, limit: 1 } + ); + + // Find by ID in result + const evento = result.data.find((e) => e.id === req.params.id); + if (!evento) { + return res.status(404).json({ error: 'Evento de geocerca no encontrado' }); + } + res.json(evento); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + /** + * Delete old events (data retention) + * DELETE /api/gps/eventos-geocerca/antiguos + */ + router.delete('/antiguos', async (req: TenantRequest, res: Response) => { + try { + const antesDe = new Date(req.query.antesDe as string); + if (isNaN(antesDe.getTime())) { + return res.status(400).json({ error: 'Fecha inválida. Use formato ISO 8601.' }); + } + const eliminados = await service.eliminarEventosAntiguos(req.tenantId!, antesDe); + res.json({ eliminados }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + }); + + return router; +} diff --git a/src/modules/gps/controllers/index.ts b/src/modules/gps/controllers/index.ts index 0178de7..696a058 100644 --- a/src/modules/gps/controllers/index.ts +++ b/src/modules/gps/controllers/index.ts @@ -7,3 +7,4 @@ export { createDispositivoGpsController } from './dispositivo-gps.controller'; export { createPosicionGpsController } from './posicion-gps.controller'; export { createSegmentoRutaController } from './segmento-ruta.controller'; +export { createEventoGeocercaController } from './evento-geocerca.controller'; diff --git a/src/modules/tarifas-transporte/controllers/factura-transporte.controller.ts b/src/modules/tarifas-transporte/controllers/factura-transporte.controller.ts new file mode 100644 index 0000000..dd46853 --- /dev/null +++ b/src/modules/tarifas-transporte/controllers/factura-transporte.controller.ts @@ -0,0 +1,632 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { + FacturaTransporteService, + FacturaSearchParams, + CreateFacturaDto, + UpdateFacturaDto, + CreateLineaFacturaDto, + RecargoAplicarDto, + DescuentoAplicarDto, +} from '../services/factura-transporte.service'; +import { EstadoFactura } from '../entities'; + +/** + * Service Context + */ +export interface ServiceContext { + tenantId: string; + userId: string; +} + +/** + * FacturaTransporteController + * + * Handles transport invoice operations including: + * - CRUD operations for invoices + * - CFDI timbrado (SAT stamping) + * - Payment registration + * - PDF/XML download + * - Pending collection tracking + * + * IVA Rate: 16% (Mexico) + */ +export class FacturaTransporteController { + public router: Router; + + constructor(private readonly facturaService: FacturaTransporteService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // List and search + this.router.get('/', this.findAll.bind(this)); + this.router.get('/pendientes-cobro', this.getPendientesCobro.bind(this)); + + // Find by specific identifiers + this.router.get('/folio/:folio', this.findByFolio.bind(this)); + this.router.get('/cliente/:clienteId', this.findByCliente.bind(this)); + this.router.get('/viaje/:viajeId', this.findByViaje.bind(this)); + + // Single invoice operations + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/:id/pdf', this.downloadPdf.bind(this)); + this.router.get('/:id/xml', this.downloadXml.bind(this)); + + // Create and update + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + + // Line items + this.router.post('/:id/lineas', this.addLinea.bind(this)); + this.router.delete('/:id/lineas/:lineaId', this.removeLinea.bind(this)); + + // Apply surcharges and discounts + this.router.post('/:id/recargos', this.applyRecargos.bind(this)); + this.router.post('/:id/descuentos', this.applyDescuentos.bind(this)); + + // Status operations + this.router.post('/:id/timbrar', this.timbrar.bind(this)); + this.router.post('/:id/cancelar', this.cancelar.bind(this)); + this.router.post('/:id/enviar', this.enviar.bind(this)); + this.router.post('/:id/registrar-pago', this.registrarPago.bind(this)); + + // Recalculate totals + this.router.post('/:id/recalcular', this.recalcularTotales.bind(this)); + + // Account statement + this.router.get('/cliente/:clienteId/estado-cuenta', this.getEstadoCuenta.bind(this)); + } + + /** + * Extract service context from request headers + */ + private getContext(req: Request): ServiceContext { + return { + tenantId: req.headers['x-tenant-id'] as string, + userId: req.headers['x-user-id'] as string, + }; + } + + // ============================================ + // LIST AND SEARCH + // ============================================ + + /** + * GET / + * Find all invoices with filters + */ + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + + const params: FacturaSearchParams = { + tenantId: ctx.tenantId, + search: req.query.search as string, + clienteId: req.query.clienteId as string, + estado: req.query.estado as EstadoFactura, + moneda: req.query.moneda as string, + limit: parseInt(req.query.limit as string) || 50, + offset: parseInt(req.query.offset as string) || 0, + }; + + // Parse date filters + if (req.query.fechaDesde) { + params.fechaDesde = new Date(req.query.fechaDesde as string); + } + if (req.query.fechaHasta) { + params.fechaHasta = new Date(req.query.fechaHasta as string); + } + + const result = await this.facturaService.findAll(params); + res.json(result); + } catch (error) { + next(error); + } + } + + /** + * GET /pendientes-cobro + * Get invoices pending collection + */ + private async getPendientesCobro(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const limit = parseInt(req.query.limit as string) || 100; + const offset = parseInt(req.query.offset as string) || 0; + + const result = await this.facturaService.getFacturasPendientes(ctx.tenantId, limit, offset); + res.json(result); + } catch (error) { + next(error); + } + } + + // ============================================ + // FIND BY IDENTIFIERS + // ============================================ + + /** + * GET /:id + * Find invoice by ID + */ + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + + const factura = await this.facturaService.findById(ctx.tenantId, id); + if (!factura) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json({ data: factura }); + } catch (error) { + next(error); + } + } + + /** + * GET /folio/:folio + * Find invoice by folio + */ + private async findByFolio(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { folio } = req.params; + const serie = req.query.serie as string; + + // Search by folio (and optionally serie) + const params: FacturaSearchParams = { + tenantId: ctx.tenantId, + search: folio, + limit: 10, + offset: 0, + }; + + const result = await this.facturaService.findAll(params); + + // Filter for exact folio match + const factura = result.data.find(f => { + if (serie) { + return f.folio === folio && f.serie === serie; + } + return f.folio === folio || f.folioCompleto === folio; + }); + + if (!factura) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json({ data: factura }); + } catch (error) { + next(error); + } + } + + /** + * GET /cliente/:clienteId + * Get invoices by client + */ + private async findByCliente(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { clienteId } = req.params; + const limit = parseInt(req.query.limit as string) || 50; + const offset = parseInt(req.query.offset as string) || 0; + + const result = await this.facturaService.findByCliente(ctx.tenantId, clienteId, limit, offset); + res.json(result); + } catch (error) { + next(error); + } + } + + /** + * GET /viaje/:viajeId + * Get invoice for a trip + */ + private async findByViaje(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { viajeId } = req.params; + + // Search for invoices containing this trip + const params: FacturaSearchParams = { + tenantId: ctx.tenantId, + limit: 100, + offset: 0, + }; + + const result = await this.facturaService.findAll(params); + + // Filter for invoices containing this viajeId + const facturas = result.data.filter(f => + f.viajeIds && f.viajeIds.includes(viajeId) + ); + + res.json({ + data: facturas, + total: facturas.length, + viajeId, + }); + } catch (error) { + next(error); + } + } + + // ============================================ + // CREATE AND UPDATE + // ============================================ + + /** + * POST / + * Create a new invoice + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const dto: CreateFacturaDto = { + ...req.body, + fechaEmision: new Date(req.body.fechaEmision), + fechaVencimiento: req.body.fechaVencimiento + ? new Date(req.body.fechaVencimiento) + : undefined, + }; + + const factura = await this.facturaService.create(ctx.tenantId, dto, ctx.userId); + res.status(201).json({ data: factura }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /:id + * Update invoice (only if in BORRADOR state) + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + const dto: UpdateFacturaDto = { + ...req.body, + fechaVencimiento: req.body.fechaVencimiento + ? new Date(req.body.fechaVencimiento) + : undefined, + }; + + const factura = await this.facturaService.update(ctx.tenantId, id, dto); + if (!factura) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json({ data: factura }); + } catch (error) { + next(error); + } + } + + // ============================================ + // LINE ITEMS + // ============================================ + + /** + * POST /:id/lineas + * Add line item to invoice + */ + private async addLinea(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + const dto: CreateLineaFacturaDto = req.body; + + const linea = await this.facturaService.addLinea(ctx.tenantId, id, dto); + res.status(201).json({ data: linea }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /:id/lineas/:lineaId + * Remove line item from invoice + */ + private async removeLinea(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id, lineaId } = req.params; + + const deleted = await this.facturaService.removeLinea(ctx.tenantId, id, lineaId); + if (!deleted) { + res.status(404).json({ error: 'Linea no encontrada' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // SURCHARGES AND DISCOUNTS + // ============================================ + + /** + * POST /:id/recargos + * Apply surcharges to invoice (IVA 16%) + */ + private async applyRecargos(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + const recargos: RecargoAplicarDto[] = req.body.recargos; + + const factura = await this.facturaService.applyRecargos(ctx.tenantId, id, recargos); + res.json({ data: factura }); + } catch (error) { + next(error); + } + } + + /** + * POST /:id/descuentos + * Apply discounts to invoice + */ + private async applyDescuentos(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + const descuentos: DescuentoAplicarDto[] = req.body.descuentos; + + const factura = await this.facturaService.applyDescuentos(ctx.tenantId, id, descuentos); + res.json({ data: factura }); + } catch (error) { + next(error); + } + } + + // ============================================ + // STATUS OPERATIONS + // ============================================ + + /** + * POST /:id/timbrar + * Stamp invoice with CFDI (SAT Mexico) + */ + private async timbrar(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + const { uuidCfdi, xmlCfdi, pdfUrl } = req.body; + + if (!uuidCfdi || !xmlCfdi) { + res.status(400).json({ error: 'Se requiere uuidCfdi y xmlCfdi' }); + return; + } + + const factura = await this.facturaService.timbrar(ctx.tenantId, id, { + uuidCfdi, + xmlCfdi, + pdfUrl, + }); + + res.json({ data: factura }); + } catch (error) { + next(error); + } + } + + /** + * POST /:id/cancelar + * Cancel invoice + */ + private async cancelar(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + const { motivo } = req.body; + + if (!motivo) { + res.status(400).json({ error: 'Se requiere motivo de cancelacion' }); + return; + } + + const factura = await this.facturaService.cancelar(ctx.tenantId, id, motivo); + res.json({ data: factura }); + } catch (error) { + next(error); + } + } + + /** + * POST /:id/enviar + * Send invoice by email + */ + private async enviar(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + const { email, mensaje } = req.body; + + const factura = await this.facturaService.findById(ctx.tenantId, id); + if (!factura) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + if (factura.estado === EstadoFactura.BORRADOR) { + res.status(400).json({ error: 'No se puede enviar una factura en estado BORRADOR' }); + return; + } + + // TODO: Implement email sending service integration + // For now, just return success with the email details + res.json({ + data: { + facturaId: id, + folioCompleto: factura.folioCompleto, + email: email || factura.clienteRfc, + mensaje: mensaje || 'Factura enviada correctamente', + enviado: true, + fechaEnvio: new Date(), + }, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /:id/registrar-pago + * Register payment for invoice + */ + private async registrarPago(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + const { montoPago, fechaPago, referencia, formaPago } = req.body; + + if (!montoPago || montoPago <= 0) { + res.status(400).json({ error: 'El monto de pago debe ser mayor a cero' }); + return; + } + + const factura = await this.facturaService.registrarPago( + ctx.tenantId, + id, + montoPago, + fechaPago ? new Date(fechaPago) : new Date() + ); + + res.json({ + data: { + factura, + pago: { + monto: montoPago, + fecha: fechaPago || new Date(), + referencia, + formaPago, + }, + }, + }); + } catch (error) { + next(error); + } + } + + // ============================================ + // DOCUMENT DOWNLOAD + // ============================================ + + /** + * GET /:id/pdf + * Download invoice PDF + */ + private async downloadPdf(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + + const factura = await this.facturaService.findById(ctx.tenantId, id); + if (!factura) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + if (!factura.pdfUrl) { + res.status(404).json({ error: 'PDF no disponible para esta factura' }); + return; + } + + // Return PDF URL or redirect + res.json({ + data: { + facturaId: id, + folioCompleto: factura.folioCompleto, + pdfUrl: factura.pdfUrl, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /:id/xml + * Download invoice XML (CFDI) + */ + private async downloadXml(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + + const factura = await this.facturaService.findById(ctx.tenantId, id); + if (!factura) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + if (!factura.xmlCfdi) { + res.status(404).json({ error: 'XML CFDI no disponible para esta factura' }); + return; + } + + // Return XML content + res.setHeader('Content-Type', 'application/xml'); + res.setHeader('Content-Disposition', `attachment; filename="${factura.folioCompleto}.xml"`); + res.send(factura.xmlCfdi); + } catch (error) { + next(error); + } + } + + // ============================================ + // ACCOUNT STATEMENTS + // ============================================ + + /** + * GET /cliente/:clienteId/estado-cuenta + * Get client account statement + */ + private async getEstadoCuenta(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { clienteId } = req.params; + + const estadoCuenta = await this.facturaService.getEstadoCuenta(ctx.tenantId, clienteId); + res.json({ data: estadoCuenta }); + } catch (error) { + next(error); + } + } + + // ============================================ + // UTILITY OPERATIONS + // ============================================ + + /** + * POST /:id/recalcular + * Recalculate invoice totals (subtotal, IVA 16%, total) + */ + private async recalcularTotales(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + + const factura = await this.facturaService.calculateTotals(ctx.tenantId, id); + if (!factura) { + res.status(404).json({ error: 'Factura no encontrada' }); + return; + } + + res.json({ data: factura }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/tarifas-transporte/controllers/index.ts b/src/modules/tarifas-transporte/controllers/index.ts index 461110c..74f7890 100644 --- a/src/modules/tarifas-transporte/controllers/index.ts +++ b/src/modules/tarifas-transporte/controllers/index.ts @@ -1,4 +1,6 @@ /** - * Tarifas Controllers + * Tarifas-Transporte Controllers */ export * from './tarifas.controller'; +export * from './factura-transporte.controller'; +export * from './recargos.controller'; diff --git a/src/modules/tarifas-transporte/controllers/recargos.controller.ts b/src/modules/tarifas-transporte/controllers/recargos.controller.ts new file mode 100644 index 0000000..71fb1ad --- /dev/null +++ b/src/modules/tarifas-transporte/controllers/recargos.controller.ts @@ -0,0 +1,568 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { + RecargosService, + RecargoSearchParams, + CreateRecargoDto, + UpdateRecargoDto, + CreateFuelSurchargeDto, + UpdateFuelSurchargeDto, + ServiceContext, +} from '../services/recargos.service'; +import { TipoRecargo } from '../entities'; + +/** + * RecargosController + * + * Handles surcharge catalog and fuel surcharge operations including: + * - CRUD operations for RecargoCatalogo + * - CRUD operations for FuelSurcharge + * - Surcharge calculations + * - Fuel surcharge calculations + * - Detention calculations + */ +export class RecargosController { + public router: Router; + + constructor(private readonly recargosService: RecargosService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // ============================================ + // RECARGOS CATALOGO ROUTES + // ============================================ + + // List and search + this.router.get('/', this.findAll.bind(this)); + this.router.get('/vigentes', this.getRecargosVigentes.bind(this)); + this.router.get('/automaticos', this.getRecargosAutomaticos.bind(this)); + + // Find by specific identifiers + this.router.get('/codigo/:codigo', this.findByCodigo.bind(this)); + this.router.get('/tipo/:tipo', this.findByTipo.bind(this)); + + // ============================================ + // FUEL SURCHARGE ROUTES + // ============================================ + + this.router.get('/fuel-surcharge', this.findAllFuelSurcharges.bind(this)); + this.router.get('/fuel-surcharge/vigente', this.getFuelSurchargeVigente.bind(this)); + this.router.get('/fuel-surcharge/:id', this.findFuelSurcharge.bind(this)); + this.router.post('/fuel-surcharge', this.createFuelSurcharge.bind(this)); + this.router.patch('/fuel-surcharge/:id', this.updateFuelSurcharge.bind(this)); + this.router.delete('/fuel-surcharge/:id', this.deleteFuelSurcharge.bind(this)); + + // ============================================ + // CALCULATION ROUTES + // ============================================ + + this.router.post('/calcular', this.calcularRecargos.bind(this)); + this.router.post('/calcular-fuel', this.calculateFuelSurcharge.bind(this)); + this.router.post('/calcular-detention', this.calculateDetention.bind(this)); + this.router.post('/calcular-todos', this.calculateAllSurcharges.bind(this)); + + // ============================================ + // SINGLE RECARGO OPERATIONS (must be after other routes) + // ============================================ + + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.post('/:id/activar', this.activar.bind(this)); + this.router.post('/:id/desactivar', this.desactivar.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + } + + /** + * Extract service context from request headers + */ + private getContext(req: Request): ServiceContext { + return { + tenantId: req.headers['x-tenant-id'] as string, + userId: req.headers['x-user-id'] as string, + }; + } + + // ============================================ + // RECARGOS CATALOGO - LIST AND SEARCH + // ============================================ + + /** + * GET / + * Find all surcharges with filters + */ + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + + const params: RecargoSearchParams = { + tenantId: ctx.tenantId, + search: req.query.search as string, + tipo: req.query.tipo as TipoRecargo, + activo: req.query.activo === 'true' ? true : req.query.activo === 'false' ? false : undefined, + aplicaAutomatico: + req.query.aplicaAutomatico === 'true' + ? true + : req.query.aplicaAutomatico === 'false' + ? false + : undefined, + limit: parseInt(req.query.limit as string) || 50, + offset: parseInt(req.query.offset as string) || 0, + }; + + const result = await this.recargosService.findAll(params); + res.json(result); + } catch (error) { + next(error); + } + } + + /** + * GET /vigentes + * Get all active surcharges + */ + private async getRecargosVigentes(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const recargos = await this.recargosService.getRecargosVigentes(ctx); + res.json({ data: recargos, total: recargos.length }); + } catch (error) { + next(error); + } + } + + /** + * GET /automaticos + * Get surcharges that apply automatically + */ + private async getRecargosAutomaticos(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const recargos = await this.recargosService.getRecargosAutomaticos(ctx); + res.json({ data: recargos, total: recargos.length }); + } catch (error) { + next(error); + } + } + + // ============================================ + // RECARGOS CATALOGO - FIND BY IDENTIFIERS + // ============================================ + + /** + * GET /:id + * Find surcharge by ID + */ + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + + const recargo = await this.recargosService.findOne(id, ctx.tenantId); + if (!recargo) { + res.status(404).json({ error: 'Recargo no encontrado' }); + return; + } + + res.json({ data: recargo }); + } catch (error) { + next(error); + } + } + + /** + * GET /codigo/:codigo + * Find surcharge by code + */ + private async findByCodigo(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { codigo } = req.params; + + const recargo = await this.recargosService.findByCodigo(codigo, ctx.tenantId); + if (!recargo) { + res.status(404).json({ error: 'Recargo no encontrado' }); + return; + } + + res.json({ data: recargo }); + } catch (error) { + next(error); + } + } + + /** + * GET /tipo/:tipo + * Find surcharges by type + */ + private async findByTipo(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const tipo = req.params.tipo as TipoRecargo; + + // Validate tipo + if (!Object.values(TipoRecargo).includes(tipo)) { + res.status(400).json({ + error: 'Tipo de recargo invalido', + tiposValidos: Object.values(TipoRecargo), + }); + return; + } + + const recargos = await this.recargosService.findByTipo(tipo, ctx.tenantId); + res.json({ data: recargos, total: recargos.length, tipo }); + } catch (error) { + next(error); + } + } + + // ============================================ + // RECARGOS CATALOGO - CREATE AND UPDATE + // ============================================ + + /** + * POST / + * Create a new surcharge + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const dto: CreateRecargoDto = req.body; + + const recargo = await this.recargosService.create(dto, ctx); + res.status(201).json({ data: recargo }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /:id + * Update surcharge + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + const dto: UpdateRecargoDto = req.body; + + const recargo = await this.recargosService.update(id, dto, ctx); + if (!recargo) { + res.status(404).json({ error: 'Recargo no encontrado' }); + return; + } + + res.json({ data: recargo }); + } catch (error) { + next(error); + } + } + + /** + * POST /:id/activar + * Activate surcharge + */ + private async activar(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + + const recargo = await this.recargosService.activar(id, ctx); + if (!recargo) { + res.status(404).json({ error: 'Recargo no encontrado' }); + return; + } + + res.json({ data: recargo }); + } catch (error) { + next(error); + } + } + + /** + * POST /:id/desactivar + * Deactivate surcharge + */ + private async desactivar(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + + const recargo = await this.recargosService.desactivar(id, ctx); + if (!recargo) { + res.status(404).json({ error: 'Recargo no encontrado' }); + return; + } + + res.json({ data: recargo }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /:id + * Delete surcharge + */ + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + + const deleted = await this.recargosService.delete(id, ctx); + if (!deleted) { + res.status(404).json({ error: 'Recargo no encontrado' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // FUEL SURCHARGE OPERATIONS + // ============================================ + + /** + * GET /fuel-surcharge + * Find all fuel surcharges + */ + private async findAllFuelSurcharges(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const limit = parseInt(req.query.limit as string) || 50; + const offset = parseInt(req.query.offset as string) || 0; + + const result = await this.recargosService.findAllFuelSurcharges(ctx.tenantId, limit, offset); + res.json(result); + } catch (error) { + next(error); + } + } + + /** + * GET /fuel-surcharge/vigente + * Get current active fuel surcharge + */ + private async getFuelSurchargeVigente(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const fuelSurcharge = await this.recargosService.getFuelSurchargeVigente(ctx.tenantId); + + if (!fuelSurcharge) { + res.json({ data: null, message: 'No hay fuel surcharge vigente configurado' }); + return; + } + + res.json({ data: fuelSurcharge }); + } catch (error) { + next(error); + } + } + + /** + * GET /fuel-surcharge/:id + * Find fuel surcharge by ID + */ + private async findFuelSurcharge(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + + const fuelSurcharge = await this.recargosService.findFuelSurcharge(id, ctx.tenantId); + if (!fuelSurcharge) { + res.status(404).json({ error: 'Fuel surcharge no encontrado' }); + return; + } + + res.json({ data: fuelSurcharge }); + } catch (error) { + next(error); + } + } + + /** + * POST /fuel-surcharge + * Create a new fuel surcharge period + */ + private async createFuelSurcharge(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const dto: CreateFuelSurchargeDto = { + ...req.body, + fechaInicio: new Date(req.body.fechaInicio), + fechaFin: new Date(req.body.fechaFin), + }; + + const fuelSurcharge = await this.recargosService.createFuelSurcharge(dto, ctx); + res.status(201).json({ data: fuelSurcharge }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /fuel-surcharge/:id + * Update fuel surcharge + */ + private async updateFuelSurcharge(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + const dto: UpdateFuelSurchargeDto = { + ...req.body, + fechaInicio: req.body.fechaInicio ? new Date(req.body.fechaInicio) : undefined, + fechaFin: req.body.fechaFin ? new Date(req.body.fechaFin) : undefined, + }; + + const fuelSurcharge = await this.recargosService.updateFuelSurcharge(id, dto, ctx); + if (!fuelSurcharge) { + res.status(404).json({ error: 'Fuel surcharge no encontrado' }); + return; + } + + res.json({ data: fuelSurcharge }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /fuel-surcharge/:id + * Delete fuel surcharge + */ + private async deleteFuelSurcharge(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { id } = req.params; + + const deleted = await this.recargosService.deleteFuelSurcharge(id, ctx); + if (!deleted) { + res.status(404).json({ error: 'Fuel surcharge no encontrado' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // CALCULATION OPERATIONS + // ============================================ + + /** + * POST /calcular + * Calculate surcharges for a base amount + */ + private async calcularRecargos(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { montoBase, valorDeclarado, incluirFuelSurcharge } = req.body; + + if (!montoBase || montoBase <= 0) { + res.status(400).json({ error: 'El monto base debe ser mayor a cero' }); + return; + } + + const resultado = await this.recargosService.calcularRecargos(montoBase, ctx, { + valorDeclarado, + incluirFuelSurcharge: incluirFuelSurcharge !== false, + }); + + res.json({ data: resultado }); + } catch (error) { + next(error); + } + } + + /** + * POST /calcular-fuel + * Calculate fuel surcharge based on distance and fuel price + */ + private async calculateFuelSurcharge(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { distanciaKm, precioLitro, rendimientoKmPorLitro } = req.body; + + if (!distanciaKm || distanciaKm <= 0) { + res.status(400).json({ error: 'La distancia debe ser mayor a cero' }); + return; + } + + if (!precioLitro || precioLitro <= 0) { + res.status(400).json({ error: 'El precio por litro debe ser mayor a cero' }); + return; + } + + const resultado = await this.recargosService.calculateFuelSurcharge( + ctx.tenantId, + distanciaKm, + precioLitro, + rendimientoKmPorLitro + ); + + res.json({ data: resultado }); + } catch (error) { + next(error); + } + } + + /** + * POST /calcular-detention + * Calculate detention charge based on waiting hours + */ + private async calculateDetention(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { horasEspera, tarifaBase } = req.body; + + if (horasEspera === undefined || horasEspera < 0) { + res.status(400).json({ error: 'Las horas de espera deben ser un numero positivo' }); + return; + } + + const resultado = await this.recargosService.calculateDetention( + ctx.tenantId, + horasEspera, + tarifaBase + ); + + res.json({ data: resultado }); + } catch (error) { + next(error); + } + } + + /** + * POST /calcular-todos + * Calculate all applicable surcharges for a shipment + */ + private async calculateAllSurcharges(req: Request, res: Response, next: NextFunction): Promise { + try { + const ctx = this.getContext(req); + const { tarifaBase, distanciaKm, precioLitro, horasEspera, tiposAdicionales } = req.body; + + if (!tarifaBase || tarifaBase <= 0) { + res.status(400).json({ error: 'La tarifa base debe ser mayor a cero' }); + return; + } + + const resultado = await this.recargosService.calculateAllSurcharges(ctx.tenantId, { + tarifaBase, + distanciaKm, + precioLitro, + horasEspera, + tiposAdicionales, + }); + + res.json({ data: resultado }); + } catch (error) { + next(error); + } + } +}