erp-transportistas-backend-v2/src/modules/carta-porte/controllers/carta-porte.controller.ts
Adrian Flores Cortes 825e349f37 [SYNC] feat: Add Carta Porte DTOs and controller
- Add carta-porte controller with CRUD operations
- Add DTOs: create, update, cancelar, timbrar
- Add DTOs: autotransporte, mercancia, ubicacion, figura-transporte
- Add common utilities folder
- Add ordenes-transporte service tests
- Update index exports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 08:11:29 -06:00

696 lines
22 KiB
TypeScript

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