import { Request, Response, NextFunction, Router } from 'express'; import { CartaPorteService, CartaPorteSearchParams, CreateCartaPorteDto, UpdateCartaPorteDto, TimbrarCartaPorteDto, CancelarCartaPorteDto, } from '../services/carta-porte.service'; import { CartaPorte, EstadoCartaPorte, TipoCfdiCartaPorte } from '../entities'; /** * Response interfaces for API documentation */ export interface CartaPorteResponse { data: CartaPorte; } export interface CartaPorteListResponse { data: CartaPorte[]; total: number; limit: number; offset: number; } export interface CartaPorteValidationResponse { data: { valid: boolean; errors: string[]; }; } export interface ExpedienteFiscalResponse { data: { viajeId: string; cartasPorte: CartaPorte[]; total: number; timbradas: number; pendientes: number; canceladas: number; }; } export interface PendientesTimbrarResponse { data: CartaPorte[]; total: number; } /** * Controlador principal para Carta Porte * CFDI 3.1 Compliance - Gestion completa de Cartas Porte * * @apiTags CartaPorte * * Endpoints: * - POST /carta-porte - Crear nueva Carta Porte desde viaje * - GET /carta-porte - Listar con filtros * - GET /carta-porte/:id - Obtener detalle con relaciones * - PUT /carta-porte/:id - Actualizar (solo BORRADOR) * - DELETE /carta-porte/:id - Eliminar (solo BORRADOR) * - POST /carta-porte/:id/validar - Validar campos SAT * - POST /carta-porte/:id/timbrar - Timbrar con PAC * - POST /carta-porte/:id/cancelar - Cancelar con motivo * - GET /carta-porte/:id/xml - Descargar XML * - GET /carta-porte/:id/pdf - Descargar PDF * - GET /carta-porte/expediente/:viajeId - Obtener expediente fiscal por viaje * - GET /carta-porte/pendientes-timbrar - Listar listas para timbrar */ export class CartaPorteController { public router: Router; constructor(private readonly cartaPorteService: CartaPorteService) { this.router = Router(); this.initializeRoutes(); } private initializeRoutes(): void { // Special routes (must be before :id routes) this.router.get('/pendientes-timbrar', this.getPendientesTimbrar.bind(this)); this.router.get('/expediente/:viajeId', this.getExpedienteFiscal.bind(this)); // CRUD basico this.router.post('/', this.create.bind(this)); this.router.get('/', this.findAll.bind(this)); this.router.get('/:id', this.findOne.bind(this)); this.router.put('/:id', this.update.bind(this)); this.router.delete('/:id', this.delete.bind(this)); // Workflow: Validacion, Timbrado, Cancelacion this.router.post('/:id/validar', this.validar.bind(this)); this.router.post('/:id/timbrar', this.timbrar.bind(this)); this.router.post('/:id/cancelar', this.cancelar.bind(this)); // Documentos: XML y PDF this.router.get('/:id/xml', this.downloadXml.bind(this)); this.router.get('/:id/pdf', this.downloadPdf.bind(this)); } /** * POST / - Crear nueva Carta Porte * @apiOperation Crear Carta Porte desde viaje * @apiResponse 201 Carta Porte creada exitosamente * @apiResponse 400 Datos invalidos o tenant no especificado * @apiResponse 500 Error interno del servidor */ private async create(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const userId = req.headers['x-user-id'] as string; if (!userId) { res.status(400).json({ error: 'User ID is required' }); return; } const dto: CreateCartaPorteDto = req.body; // Validar campos requeridos if (!dto.viajeId) { res.status(400).json({ error: 'viajeId es requerido' }); return; } if (!dto.tipoCfdi) { res.status(400).json({ error: 'tipoCfdi es requerido (INGRESO o TRASLADO)' }); return; } if (!dto.emisorRfc || !dto.emisorNombre) { res.status(400).json({ error: 'Datos del emisor son requeridos (emisorRfc, emisorNombre)' }); return; } if (!dto.receptorRfc || !dto.receptorNombre) { res.status(400).json({ error: 'Datos del receptor son requeridos (receptorRfc, receptorNombre)' }); return; } const cartaPorte = await this.cartaPorteService.create(tenantId, dto, userId); res.status(201).json({ data: cartaPorte } as CartaPorteResponse); } catch (error) { next(error); } } /** * GET / - Listar Cartas Porte con filtros * @apiOperation Listar Cartas Porte * @apiQuery estado - Filtrar por estado (BORRADOR, VALIDADA, TIMBRADA, CANCELADA) * @apiQuery tipoCfdi - Filtrar por tipo CFDI (INGRESO, TRASLADO) * @apiQuery viajeId - Filtrar por ID de viaje * @apiQuery fechaDesde - Fecha inicio (ISO 8601) * @apiQuery fechaHasta - Fecha fin (ISO 8601) * @apiQuery search - Busqueda en folio, serie, emisor, receptor * @apiQuery limit - Limite de resultados (default 50) * @apiQuery offset - Offset para paginacion (default 0) * @apiResponse 200 Lista de Cartas Porte * @apiResponse 400 Tenant no especificado */ private async findAll(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const { estado, tipoCfdi, viajeId, fechaDesde, fechaHasta, search, emisorRfc, receptorRfc, limit = '50', offset = '0', } = req.query; // Validar estado si se proporciona if (estado && !Object.values(EstadoCartaPorte).includes(estado as EstadoCartaPorte)) { res.status(400).json({ error: `estado debe ser uno de: ${Object.values(EstadoCartaPorte).join(', ')}`, }); return; } // Validar tipoCfdi si se proporciona if (tipoCfdi && !Object.values(TipoCfdiCartaPorte).includes(tipoCfdi as TipoCfdiCartaPorte)) { res.status(400).json({ error: `tipoCfdi debe ser uno de: ${Object.values(TipoCfdiCartaPorte).join(', ')}`, }); return; } const params: CartaPorteSearchParams = { tenantId, estado: estado as EstadoCartaPorte | undefined, tipoCfdi: tipoCfdi as TipoCfdiCartaPorte | undefined, viajeId: viajeId as string | undefined, emisorRfc: emisorRfc as string | undefined, receptorRfc: receptorRfc as string | undefined, fechaDesde: fechaDesde ? new Date(fechaDesde as string) : undefined, fechaHasta: fechaHasta ? new Date(fechaHasta as string) : undefined, search: search as string | undefined, limit: parseInt(limit as string, 10), offset: parseInt(offset as string, 10), }; const { data, total } = await this.cartaPorteService.findAll(params); res.json({ data, total, limit: params.limit, offset: params.offset, } as CartaPorteListResponse); } catch (error) { next(error); } } /** * GET /:id - Obtener Carta Porte por ID con todas las relaciones * @apiOperation Obtener detalle de Carta Porte * @apiParam id - UUID de la Carta Porte * @apiResponse 200 Carta Porte con ubicaciones, mercancias, figuras y autotransporte * @apiResponse 404 Carta Porte no encontrada */ private async findOne(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const { id } = req.params; const cartaPorte = await this.cartaPorteService.findOne(id, tenantId); if (!cartaPorte) { res.status(404).json({ error: 'Carta Porte no encontrada' }); return; } res.json({ data: cartaPorte } as CartaPorteResponse); } catch (error) { next(error); } } /** * PUT /:id - Actualizar Carta Porte * @apiOperation Actualizar Carta Porte (solo estado BORRADOR) * @apiParam id - UUID de la Carta Porte * @apiResponse 200 Carta Porte actualizada * @apiResponse 400 Solo se puede actualizar en estado BORRADOR * @apiResponse 404 Carta Porte no encontrada */ private async update(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const userId = req.headers['x-user-id'] as string; if (!userId) { res.status(400).json({ error: 'User ID is required' }); return; } const { id } = req.params; const dto: UpdateCartaPorteDto = req.body; // Verificar existencia y estado const existing = await this.cartaPorteService.findOne(id, tenantId); if (!existing) { res.status(404).json({ error: 'Carta Porte no encontrada' }); return; } if (existing.estado !== EstadoCartaPorte.BORRADOR) { res.status(400).json({ error: `Solo se puede actualizar Carta Porte en estado BORRADOR. Estado actual: ${existing.estado}`, }); return; } const cartaPorte = await this.cartaPorteService.update(id, tenantId, dto, userId); res.json({ data: cartaPorte } as CartaPorteResponse); } catch (error) { if (error instanceof Error && error.message.includes('No se puede modificar')) { res.status(400).json({ error: error.message }); return; } next(error); } } /** * DELETE /:id - Eliminar Carta Porte * @apiOperation Eliminar Carta Porte (solo estado BORRADOR) * @apiParam id - UUID de la Carta Porte * @apiResponse 204 Carta Porte eliminada * @apiResponse 400 Solo se puede eliminar en estado BORRADOR * @apiResponse 404 Carta Porte no encontrada */ private async delete(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const { id } = req.params; // Verificar existencia y estado const existing = await this.cartaPorteService.findOne(id, tenantId); if (!existing) { res.status(404).json({ error: 'Carta Porte no encontrada' }); return; } if (existing.estado !== EstadoCartaPorte.BORRADOR) { res.status(400).json({ error: `Solo se puede eliminar Carta Porte en estado BORRADOR. Estado actual: ${existing.estado}`, }); return; } const deleted = await this.cartaPorteService.delete(id, tenantId); if (!deleted) { res.status(404).json({ error: 'Carta Porte no encontrada' }); return; } res.status(204).send(); } catch (error) { if (error instanceof Error && error.message.includes('Solo se pueden eliminar')) { res.status(400).json({ error: error.message }); return; } next(error); } } /** * POST /:id/validar - Validar campos SAT de Carta Porte * @apiOperation Validar Carta Porte para timbrado * @apiParam id - UUID de la Carta Porte * @apiResponse 200 Resultado de validacion (valid: boolean, errors: string[]) * @apiResponse 404 Carta Porte no encontrada */ private async validar(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const { id } = req.params; const { marcarValidada = false } = req.body; // Verificar existencia const existing = await this.cartaPorteService.findOne(id, tenantId); if (!existing) { res.status(404).json({ error: 'Carta Porte no encontrada' }); return; } // Validar campos requeridos const validation = await this.cartaPorteService.validar(id, tenantId); // Si se solicita marcar como validada y la validacion es exitosa if (marcarValidada && validation.valid) { const updated = await this.cartaPorteService.marcarValidada(id, tenantId); res.json({ data: { valid: validation.valid, errors: validation.errors, cartaPorte: updated, }, }); return; } res.json({ data: validation } as CartaPorteValidationResponse); } catch (error) { if (error instanceof Error && error.message.includes('no valida')) { res.status(400).json({ error: error.message }); return; } next(error); } } /** * POST /:id/timbrar - Timbrar Carta Porte con PAC * @apiOperation Timbrar CFDI con Complemento Carta Porte * @apiParam id - UUID de la Carta Porte * @apiBody uuidCfdi - UUID del CFDI timbrado (retornado por PAC) * @apiBody fechaTimbrado - Fecha de timbrado * @apiBody xmlCfdi - XML del CFDI completo * @apiBody xmlCartaPorte - XML del complemento Carta Porte * @apiBody pdfUrl - URL del PDF generado (opcional) * @apiBody qrUrl - URL del codigo QR (opcional) * @apiResponse 200 Carta Porte timbrada exitosamente * @apiResponse 400 Carta Porte debe estar en estado VALIDADA * @apiResponse 404 Carta Porte no encontrada * * NOTA: Esta implementacion es solo la estructura. * La integracion real con PAC debe implementarse en el servicio. */ private async timbrar(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const { id } = req.params; // Verificar existencia const existing = await this.cartaPorteService.findOne(id, tenantId); if (!existing) { res.status(404).json({ error: 'Carta Porte no encontrada' }); return; } // Verificar estado if (existing.estado !== EstadoCartaPorte.VALIDADA) { res.status(400).json({ error: `La Carta Porte debe estar en estado VALIDADA para timbrar. Estado actual: ${existing.estado}`, }); return; } // Datos del timbrado (vienen del PAC o de proceso de timbrado) const dto: TimbrarCartaPorteDto = req.body; // Validar campos requeridos para timbrado if (!dto.uuidCfdi || !dto.fechaTimbrado || !dto.xmlCfdi || !dto.xmlCartaPorte) { res.status(400).json({ error: 'Datos de timbrado incompletos. Se requiere: uuidCfdi, fechaTimbrado, xmlCfdi, xmlCartaPorte', }); return; } const cartaPorte = await this.cartaPorteService.timbrar(id, tenantId, dto); res.json({ data: cartaPorte, message: 'Carta Porte timbrada exitosamente', }); } catch (error) { if (error instanceof Error && error.message.includes('debe estar validada')) { res.status(400).json({ error: error.message }); return; } next(error); } } /** * POST /:id/cancelar - Cancelar Carta Porte ante SAT * @apiOperation Cancelar CFDI timbrado * @apiParam id - UUID de la Carta Porte * @apiBody motivoCancelacion - Motivo de cancelacion (requerido) * @apiBody uuidSustitucion - UUID del CFDI sustituto (opcional, para tipo 01) * @apiResponse 200 Carta Porte cancelada exitosamente * @apiResponse 400 Solo se pueden cancelar Cartas Porte timbradas * @apiResponse 404 Carta Porte no encontrada */ private async cancelar(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const { id } = req.params; const dto: CancelarCartaPorteDto = req.body; // Verificar existencia const existing = await this.cartaPorteService.findOne(id, tenantId); if (!existing) { res.status(404).json({ error: 'Carta Porte no encontrada' }); return; } // Verificar estado if (existing.estado !== EstadoCartaPorte.TIMBRADA) { res.status(400).json({ error: `Solo se pueden cancelar Cartas Porte timbradas. Estado actual: ${existing.estado}`, }); return; } // Validar motivo de cancelacion if (!dto.motivoCancelacion) { res.status(400).json({ error: 'motivoCancelacion es requerido' }); return; } const cartaPorte = await this.cartaPorteService.cancelar(id, tenantId, dto); res.json({ data: cartaPorte, message: 'Carta Porte cancelada exitosamente', }); } catch (error) { if (error instanceof Error && error.message.includes('Solo se pueden cancelar')) { res.status(400).json({ error: error.message }); return; } next(error); } } /** * GET /:id/xml - Descargar XML del CFDI * @apiOperation Obtener XML del CFDI timbrado * @apiParam id - UUID de la Carta Porte * @apiResponse 200 XML del CFDI (application/xml) * @apiResponse 400 Carta Porte no timbrada * @apiResponse 404 Carta Porte no encontrada */ private async downloadXml(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const { id } = req.params; const cartaPorte = await this.cartaPorteService.findOne(id, tenantId); if (!cartaPorte) { res.status(404).json({ error: 'Carta Porte no encontrada' }); return; } if (!cartaPorte.xmlCfdi) { res.status(400).json({ error: 'La Carta Porte no tiene XML. Debe estar timbrada para descargar XML.', }); return; } // Construir nombre del archivo const filename = cartaPorte.uuidCfdi ? `carta-porte-${cartaPorte.uuidCfdi}.xml` : `carta-porte-${cartaPorte.id}.xml`; res.setHeader('Content-Type', 'application/xml'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(cartaPorte.xmlCfdi); } catch (error) { next(error); } } /** * GET /:id/pdf - Descargar PDF de la Carta Porte * @apiOperation Obtener PDF del CFDI timbrado * @apiParam id - UUID de la Carta Porte * @apiResponse 200 Redireccion a URL del PDF o PDF generado * @apiResponse 400 Carta Porte no timbrada o PDF no disponible * @apiResponse 404 Carta Porte no encontrada * * NOTA: Esta implementacion es un placeholder. * La generacion real del PDF debe implementarse con una libreria PDF. */ private async downloadPdf(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const { id } = req.params; const cartaPorte = await this.cartaPorteService.findOne(id, tenantId); if (!cartaPorte) { res.status(404).json({ error: 'Carta Porte no encontrada' }); return; } if (cartaPorte.estado !== EstadoCartaPorte.TIMBRADA) { res.status(400).json({ error: 'Solo se puede descargar PDF de Cartas Porte timbradas.', }); return; } // Si existe URL del PDF, redirigir if (cartaPorte.pdfUrl) { res.redirect(cartaPorte.pdfUrl); return; } // Placeholder: En implementacion real, generar PDF aqui // Por ahora retornamos mensaje indicando que PDF no esta disponible res.status(501).json({ error: 'Generacion de PDF no implementada', message: 'La Carta Porte esta timbrada pero la generacion de PDF no esta disponible. ' + 'Utilice el XML para generar una representacion impresa.', cartaPorteId: cartaPorte.id, uuidCfdi: cartaPorte.uuidCfdi, }); } catch (error) { next(error); } } /** * GET /expediente/:viajeId - Obtener expediente fiscal del viaje * @apiOperation Obtener todas las Cartas Porte de un viaje * @apiParam viajeId - UUID del viaje * @apiResponse 200 Expediente fiscal con estadisticas * @apiResponse 404 No se encontraron Cartas Porte para el viaje */ private async getExpedienteFiscal(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const { viajeId } = req.params; const cartasPorte = await this.cartaPorteService.findByViaje(viajeId, tenantId); // Calcular estadisticas const timbradas = cartasPorte.filter((cp) => cp.estado === EstadoCartaPorte.TIMBRADA).length; const pendientes = cartasPorte.filter( (cp) => cp.estado === EstadoCartaPorte.BORRADOR || cp.estado === EstadoCartaPorte.VALIDADA ).length; const canceladas = cartasPorte.filter((cp) => cp.estado === EstadoCartaPorte.CANCELADA).length; res.json({ data: { viajeId, cartasPorte, total: cartasPorte.length, timbradas, pendientes, canceladas, }, } as ExpedienteFiscalResponse); } catch (error) { next(error); } } /** * GET /pendientes-timbrar - Listar Cartas Porte listas para timbrar * @apiOperation Obtener Cartas Porte en estado BORRADOR o VALIDADA * @apiResponse 200 Lista de Cartas Porte pendientes de timbrar */ private async getPendientesTimbrar(req: Request, res: Response, next: NextFunction): Promise { try { const tenantId = req.headers['x-tenant-id'] as string; if (!tenantId) { res.status(400).json({ error: 'Tenant ID is required' }); return; } const cartasPorte = await this.cartaPorteService.getPendientesTimbrar(tenantId); res.json({ data: cartasPorte, total: cartasPorte.length, } as PendientesTimbrarResponse); } catch (error) { next(error); } } }