[SPRINT-6] feat: Implement transport module controllers

Carta Porte Module (4 controllers):
- mercancia.controller.ts: Cargo management endpoints
- ubicacion-carta-porte.controller.ts: Location endpoints
- figura-transporte.controller.ts: Transport figures endpoints
- inspeccion-pre-viaje.controller.ts: Pre-trip inspection endpoints

Gestion Flota Module (2 controllers):
- documento-flota.controller.ts: Fleet document management
- asignacion.controller.ts: Unit-operator assignment endpoints

Tarifas Transporte Module (2 controllers):
- factura-transporte.controller.ts: Invoice endpoints with IVA 16%
- recargos.controller.ts: Surcharge catalog and fuel surcharge endpoints

GPS Module (1 controller):
- evento-geocerca.controller.ts: Geofence event endpoints

Total: 9 new controllers following Express Router pattern

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 03:00:12 -06:00
parent 2134ff98e5
commit a4b1b2fd34
13 changed files with 4059 additions and 3 deletions

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -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)

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -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';

View File

@ -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<string>();
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<string, typeof eventos> = {};
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;
}

View File

@ -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';

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -1,4 +1,6 @@
/**
* Tarifas Controllers
* Tarifas-Transporte Controllers
*/
export * from './tarifas.controller';
export * from './factura-transporte.controller';
export * from './recargos.controller';

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
}