diff --git a/src/common/index.ts b/src/common/index.ts new file mode 100644 index 0000000..f3fd122 --- /dev/null +++ b/src/common/index.ts @@ -0,0 +1 @@ +export * from './partial-type'; diff --git a/src/common/partial-type.ts b/src/common/partial-type.ts new file mode 100644 index 0000000..66286c6 --- /dev/null +++ b/src/common/partial-type.ts @@ -0,0 +1,35 @@ +import { Type } from 'class-transformer'; + +/** + * Makes all properties of T optional + * Similar to NestJS mapped-types PartialType but for plain classes + */ +export function PartialType any>(classRef: T): T { + abstract class PartialTypeClass { + constructor() { + return Object.assign(this, new classRef()); + } + } + + const propertyKeys = Object.getOwnPropertyNames(new classRef()); + propertyKeys.forEach((key) => { + const decorators = Reflect.getMetadataKeys(classRef.prototype, key); + decorators.forEach((decoratorKey) => { + const metadata = Reflect.getMetadata(decoratorKey, classRef.prototype, key); + Reflect.defineMetadata(decoratorKey, metadata, PartialTypeClass.prototype, key); + }); + }); + + return PartialTypeClass as T; +} + +/** + * Type helper for partial types + */ +export type DeepPartial = { + [P in keyof T]?: T[P] extends Array + ? Array> + : T[P] extends ReadonlyArray + ? ReadonlyArray> + : DeepPartial; +}; diff --git a/src/modules/carta-porte/controllers/carta-porte.controller.ts b/src/modules/carta-porte/controllers/carta-porte.controller.ts new file mode 100644 index 0000000..68cd103 --- /dev/null +++ b/src/modules/carta-porte/controllers/carta-porte.controller.ts @@ -0,0 +1,695 @@ +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); + } + } +} diff --git a/src/modules/carta-porte/controllers/index.ts b/src/modules/carta-porte/controllers/index.ts index f8da86a..e93ca69 100644 --- a/src/modules/carta-porte/controllers/index.ts +++ b/src/modules/carta-porte/controllers/index.ts @@ -3,6 +3,9 @@ * CFDI con Complemento Carta Porte 3.1 */ +// Main Carta Porte Controller +export * from './carta-porte.controller'; + // Mercancia (Cargo/Merchandise) Controller export * from './mercancia.controller'; @@ -14,7 +17,3 @@ export * from './figura-transporte.controller'; // Inspeccion Pre-Viaje (Pre-trip Inspection) Controller export * from './inspeccion-pre-viaje.controller'; - -// TODO: Implement additional controllers -// - carta-porte.controller.ts (main carta porte CRUD) -// - cfdi.controller.ts (timbrado y cancelacion) diff --git a/src/modules/carta-porte/dto/autotransporte-carta-porte.dto.ts b/src/modules/carta-porte/dto/autotransporte-carta-porte.dto.ts new file mode 100644 index 0000000..e139ea2 --- /dev/null +++ b/src/modules/carta-porte/dto/autotransporte-carta-porte.dto.ts @@ -0,0 +1,120 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsNumber, + IsArray, + ValidateNested, + Length, + Min, + Max, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * DTO para remolque/semirremolque + */ +export class RemolqueDto { + /** + * Subtipo de remolque (catalogo c_SubTipoRem) + */ + @IsString() + @IsNotEmpty() + @Length(4, 4) + subTipoRem!: string; + + /** + * Placa del remolque + */ + @IsString() + @IsNotEmpty() + @Length(6, 8) + placa!: string; +} + +/** + * DTO para agregar datos de autotransporte a Carta Porte + */ +export class CreateAutotransporteDto { + /** + * Tipo de permiso SCT (catalogo c_TipoPermiso) + * TPAF01 = Autotransporte Federal de Carga General + * TPAF02 = Autotransporte Federal de Mudanzas + * etc. + */ + @IsString() + @IsNotEmpty({ message: 'permSCT es requerido' }) + @Length(4, 10) + permSCT!: string; + + /** + * Numero de permiso SCT + */ + @IsString() + @IsNotEmpty({ message: 'numPermisoSCT es requerido' }) + @Length(1, 50) + numPermisoSCT!: string; + + /** + * Configuracion vehicular (catalogo c_ConfigAutotransporte) + * C2 = Camion Unitario (2 llantas eje delantero, 4-8 trasero) + * C3 = Camion Unitario (3 ejes) + * T3S2 = Tractocamion con semirremolque + * etc. + */ + @IsString() + @IsNotEmpty({ message: 'configVehicular es requerida' }) + configVehicular!: string; + + /** + * Placa del vehiculo motor + */ + @IsString() + @IsNotEmpty({ message: 'placaVM es requerida' }) + @Length(6, 8, { message: 'placaVM debe tener entre 6 y 8 caracteres' }) + placaVM!: string; + + /** + * Año modelo del vehiculo + */ + @IsOptional() + @IsNumber() + @Min(1990) + @Max(2100) + anioModeloVM?: number; + + /** + * Remolques (maximo 2) + */ + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RemolqueDto) + remolques?: RemolqueDto[]; +} + +/** + * DTO para actualizar autotransporte + */ +export class UpdateAutotransporteDto { + @IsOptional() + @IsString() + @Length(1, 50) + numPermisoSCT?: string; + + @IsOptional() + @IsString() + @Length(6, 8) + placaVM?: string; + + @IsOptional() + @IsNumber() + @Min(1990) + anioModeloVM?: number; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RemolqueDto) + remolques?: RemolqueDto[]; +} diff --git a/src/modules/carta-porte/dto/cancelar-carta-porte.dto.ts b/src/modules/carta-porte/dto/cancelar-carta-porte.dto.ts new file mode 100644 index 0000000..1289b65 --- /dev/null +++ b/src/modules/carta-porte/dto/cancelar-carta-porte.dto.ts @@ -0,0 +1,78 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsEnum, + IsUUID, + Length, +} from 'class-validator'; + +/** + * Motivos de cancelacion segun SAT + */ +export enum MotivoCancelacion { + /** 01 - Comprobante emitido con errores con relacion */ + ERRORES_CON_RELACION = '01', + /** 02 - Comprobante emitido con errores sin relacion */ + ERRORES_SIN_RELACION = '02', + /** 03 - No se llevo a cabo la operacion */ + NO_OPERACION = '03', + /** 04 - Operacion nominativa relacionada en factura global */ + NOMINATIVA_GLOBAL = '04', +} + +/** + * DTO para cancelar Carta Porte timbrada + */ +export class CancelarCartaPorteDto { + /** + * Motivo de cancelacion (catalogo SAT) + * 01 = Comprobante con errores CON relacion (requiere UUID sustituto) + * 02 = Comprobante con errores SIN relacion + * 03 = No se llevo a cabo la operacion + * 04 = Operacion nominativa relacionada en factura global + */ + @IsEnum(MotivoCancelacion, { + message: 'motivoCancelacion debe ser 01, 02, 03 o 04', + }) + @IsNotEmpty({ message: 'motivoCancelacion es requerido' }) + motivoCancelacion!: MotivoCancelacion; + + /** + * UUID del CFDI que sustituye (requerido si motivo = 01) + */ + @IsOptional() + @IsUUID() + uuidSustitucion?: string; + + /** + * Observaciones adicionales sobre la cancelacion + */ + @IsOptional() + @IsString() + @Length(1, 500) + observaciones?: string; +} + +/** + * Response de cancelacion del PAC + */ +export class CancelacionResponseDto { + /** + * Acuse de cancelacion del SAT + */ + @IsString() + acuseCancelacion!: string; + + /** + * Fecha de cancelacion + */ + @IsString() + fechaCancelacion!: string; + + /** + * Estatus de cancelacion + */ + @IsString() + estatusCancelacion!: string; +} diff --git a/src/modules/carta-porte/dto/create-carta-porte.dto.ts b/src/modules/carta-porte/dto/create-carta-porte.dto.ts new file mode 100644 index 0000000..3fdadc9 --- /dev/null +++ b/src/modules/carta-porte/dto/create-carta-porte.dto.ts @@ -0,0 +1,271 @@ +import { + IsString, + IsNotEmpty, + IsEnum, + IsOptional, + IsNumber, + IsBoolean, + IsUUID, + Length, + Matches, + Min, + Max, +} from 'class-validator'; +import { TipoCfdiCartaPorte } from '../entities'; + +/** + * DTO para crear una nueva Carta Porte + * Cumple con requerimientos SAT CFDI 3.1 + */ +export class CreateCartaPorteDto { + /** + * ID del viaje asociado + */ + @IsUUID() + @IsNotEmpty({ message: 'viajeId es requerido' }) + viajeId!: string; + + /** + * Tipo de CFDI: INGRESO (servicio cobrado) o TRASLADO (propio) + */ + @IsEnum(TipoCfdiCartaPorte, { message: 'tipoCfdi debe ser INGRESO o TRASLADO' }) + @IsNotEmpty({ message: 'tipoCfdi es requerido' }) + tipoCfdi!: TipoCfdiCartaPorte; + + /** + * Version del complemento Carta Porte (default 3.1) + */ + @IsOptional() + @IsString() + versionCartaPorte?: string; + + /** + * Serie del CFDI (opcional) + */ + @IsOptional() + @IsString() + @Length(1, 25, { message: 'serie debe tener entre 1 y 25 caracteres' }) + serie?: string; + + // ========== DATOS DEL EMISOR ========== + + /** + * RFC del emisor (12-13 caracteres) + */ + @IsString() + @IsNotEmpty({ message: 'emisorRfc es requerido' }) + @Matches(/^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/, { + message: 'emisorRfc debe ser un RFC valido', + }) + emisorRfc!: string; + + /** + * Razon social o nombre del emisor + */ + @IsString() + @IsNotEmpty({ message: 'emisorNombre es requerido' }) + @Length(1, 300) + emisorNombre!: string; + + /** + * Regimen fiscal del emisor (catalogo SAT c_RegimenFiscal) + */ + @IsOptional() + @IsString() + @Length(3, 3, { message: 'emisorRegimenFiscal debe ser codigo de 3 digitos' }) + emisorRegimenFiscal?: string; + + // ========== DATOS DEL RECEPTOR ========== + + /** + * RFC del receptor + */ + @IsString() + @IsNotEmpty({ message: 'receptorRfc es requerido' }) + @Matches(/^([A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}|XAXX010101000|XEXX010101000)$/, { + message: 'receptorRfc debe ser un RFC valido o generico (XAXX/XEXX)', + }) + receptorRfc!: string; + + /** + * Nombre o razon social del receptor + */ + @IsString() + @IsNotEmpty({ message: 'receptorNombre es requerido' }) + @Length(1, 300) + receptorNombre!: string; + + /** + * Uso del CFDI (catalogo SAT c_UsoCFDI) + */ + @IsOptional() + @IsString() + @Length(3, 4) + receptorUsoCfdi?: string; + + /** + * Codigo postal del domicilio fiscal del receptor + */ + @IsOptional() + @IsString() + @Length(5, 5, { message: 'receptorDomicilioFiscalCp debe ser de 5 digitos' }) + receptorDomicilioFiscalCp?: string; + + // ========== TOTALES (requeridos para CFDI INGRESO) ========== + + /** + * Subtotal del CFDI (sin impuestos) + */ + @IsOptional() + @IsNumber({}, { message: 'subtotal debe ser un numero' }) + @Min(0) + subtotal?: number; + + /** + * Total del CFDI (con impuestos) + */ + @IsOptional() + @IsNumber({}, { message: 'total debe ser un numero' }) + @Min(0) + total?: number; + + /** + * Moneda (default MXN) + */ + @IsOptional() + @IsString() + @Length(3, 3) + moneda?: string; + + // ========== TRANSPORTE INTERNACIONAL ========== + + /** + * Indica si es transporte internacional + */ + @IsOptional() + @IsBoolean() + transporteInternacional?: boolean; + + /** + * Entrada o Salida de mercancia (Entrada/Salida) + */ + @IsOptional() + @IsString() + @IsEnum(['Entrada', 'Salida'], { message: 'entradaSalidaMerc debe ser Entrada o Salida' }) + entradaSalidaMerc?: string; + + /** + * Pais de origen o destino (ISO 3166-1 alpha-3) + */ + @IsOptional() + @IsString() + @Length(3, 3) + paisOrigenDestino?: string; + + // ========== DATOS AUTOTRANSPORTE FEDERAL ========== + + /** + * Tipo de permiso SCT (catalogo c_TipoPermiso) + */ + @IsOptional() + @IsString() + permisoSct?: string; + + /** + * Numero de permiso SCT + */ + @IsOptional() + @IsString() + @Length(1, 50) + numPermisoSct?: string; + + /** + * Configuracion vehicular (catalogo c_ConfigAutotransporte) + */ + @IsOptional() + @IsString() + configVehicular?: string; + + /** + * Peso bruto total en KG + */ + @IsOptional() + @IsNumber() + @Min(0) + @Max(100000) + pesoBrutoTotal?: number; + + /** + * Unidad de peso (catalogo c_ClaveUnidadPeso, default KGM) + */ + @IsOptional() + @IsString() + @Length(2, 3) + unidadPeso?: string; + + /** + * Numero total de mercancias transportadas + */ + @IsOptional() + @IsNumber() + @Min(1) + numTotalMercancias?: number; + + // ========== SEGUROS ========== + + /** + * Nombre de la aseguradora de responsabilidad civil + */ + @IsOptional() + @IsString() + @Length(1, 100) + aseguraRespCivil?: string; + + /** + * Numero de poliza de responsabilidad civil + */ + @IsOptional() + @IsString() + @Length(1, 50) + polizaRespCivil?: string; + + /** + * Nombre de la aseguradora de medio ambiente + */ + @IsOptional() + @IsString() + @Length(1, 100) + aseguraMedAmbiente?: string; + + /** + * Numero de poliza de medio ambiente + */ + @IsOptional() + @IsString() + @Length(1, 50) + polizaMedAmbiente?: string; + + /** + * Nombre de la aseguradora de carga + */ + @IsOptional() + @IsString() + @Length(1, 100) + aseguraCarga?: string; + + /** + * Numero de poliza de carga + */ + @IsOptional() + @IsString() + @Length(1, 50) + polizaCarga?: string; + + /** + * Prima del seguro de carga + */ + @IsOptional() + @IsNumber() + @Min(0) + primaSeguro?: number; +} diff --git a/src/modules/carta-porte/dto/figura-transporte.dto.ts b/src/modules/carta-porte/dto/figura-transporte.dto.ts new file mode 100644 index 0000000..84cafaf --- /dev/null +++ b/src/modules/carta-porte/dto/figura-transporte.dto.ts @@ -0,0 +1,167 @@ +import { + IsString, + IsNotEmpty, + IsEnum, + IsOptional, + IsArray, + ValidateNested, + Length, + Matches, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { TipoFiguraTransporte } from '../entities'; + +/** + * DTO para partes de transporte (propietario/arrendador) + */ +export class PartesTransporteDto { + @IsString() + @IsNotEmpty() + parteTransporte!: string; +} + +/** + * DTO para domicilio de figura de transporte + */ +export class DomicilioFiguraDto { + @IsOptional() + @IsString() + @Length(2, 3) + pais?: string; + + @IsOptional() + @IsString() + @Length(2, 3) + estado?: string; + + @IsOptional() + @IsString() + @Length(5, 5) + codigoPostal?: string; + + @IsOptional() + @IsString() + @Length(1, 100) + calle?: string; + + @IsOptional() + @IsString() + @Length(1, 55) + numExterior?: string; + + @IsOptional() + @IsString() + @Length(1, 30) + numInterior?: string; + + @IsOptional() + @IsString() + @Length(1, 100) + colonia?: string; + + @IsOptional() + @IsString() + @Length(1, 100) + municipio?: string; + + @IsOptional() + @IsString() + @Length(1, 250) + referencia?: string; +} + +/** + * DTO para agregar figura de transporte a Carta Porte + * Tipos: Operador (01), Propietario (02), Arrendador (03), Notificado (04) + */ +export class CreateFiguraTransporteDto { + /** + * Tipo de figura de transporte (catalogo c_TipoFiguraTransporte) + * 01 = Operador + * 02 = Propietario + * 03 = Arrendador + * 04 = Notificado + */ + @IsEnum(TipoFiguraTransporte, { message: 'tipoFigura debe ser 01, 02, 03 o 04' }) + @IsNotEmpty() + tipoFigura!: TipoFiguraTransporte; + + /** + * RFC de la figura (opcional para operadores extranjeros) + */ + @IsOptional() + @IsString() + @Matches(/^([A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}|XEXX010101000)$/, { + message: 'rfcFigura debe ser un RFC valido o extranjero generico', + }) + rfcFigura?: string; + + /** + * Nombre de la figura de transporte + */ + @IsString() + @IsNotEmpty({ message: 'nombreFigura es requerido' }) + @Length(1, 254) + nombreFigura!: string; + + /** + * Numero de licencia (requerido para operadores tipo 01) + */ + @IsOptional() + @IsString() + @Length(1, 16) + numLicencia?: string; + + /** + * Numero de registro tributario (para extranjeros) + */ + @IsOptional() + @IsString() + @Length(1, 40) + numRegIdTribFigura?: string; + + /** + * Residencia fiscal (ISO 3166-1 alpha-3, para extranjeros) + */ + @IsOptional() + @IsString() + @Length(3, 3) + residenciaFiscalFigura?: string; + + /** + * Partes de transporte (para propietario/arrendador) + */ + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PartesTransporteDto) + partesTransporte?: PartesTransporteDto[]; + + /** + * Domicilio de la figura + */ + @IsOptional() + @ValidateNested() + @Type(() => DomicilioFiguraDto) + domicilio?: DomicilioFiguraDto; +} + +/** + * DTO para actualizar figura de transporte + */ +export class UpdateFiguraTransporteDto { + @IsOptional() + @IsString() + @Length(1, 254) + nombreFigura?: string; + + @IsOptional() + @IsString() + @Length(1, 16) + numLicencia?: string; + + @IsOptional() + @ValidateNested() + @Type(() => DomicilioFiguraDto) + domicilio?: DomicilioFiguraDto; +} diff --git a/src/modules/carta-porte/dto/index.ts b/src/modules/carta-porte/dto/index.ts index 375a598..7f08411 100644 --- a/src/modules/carta-porte/dto/index.ts +++ b/src/modules/carta-porte/dto/index.ts @@ -1,8 +1,24 @@ /** * Carta Porte DTOs + * CFDI 3.1 Compliance - SAT Mexico */ -// TODO: Implement DTOs -// - create-carta-porte.dto.ts -// - ubicacion-carta-porte.dto.ts -// - mercancia-carta-porte.dto.ts -// - timbrado-response.dto.ts + +// Carta Porte principal +export * from './create-carta-porte.dto'; +export * from './update-carta-porte.dto'; + +// Ubicaciones (origen/destino) +export * from './ubicacion-carta-porte.dto'; + +// Mercancias +export * from './mercancia-carta-porte.dto'; + +// Figuras de transporte (operador, propietario, etc) +export * from './figura-transporte.dto'; + +// Autotransporte federal +export * from './autotransporte-carta-porte.dto'; + +// Operaciones de timbrado y cancelacion +export * from './timbrar-carta-porte.dto'; +export * from './cancelar-carta-porte.dto'; diff --git a/src/modules/carta-porte/dto/mercancia-carta-porte.dto.ts b/src/modules/carta-porte/dto/mercancia-carta-porte.dto.ts new file mode 100644 index 0000000..fdf0ff4 --- /dev/null +++ b/src/modules/carta-porte/dto/mercancia-carta-porte.dto.ts @@ -0,0 +1,214 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsNumber, + IsBoolean, + Length, + Min, + Max, +} from 'class-validator'; + +/** + * DTO para agregar mercancia a Carta Porte + * Segun especificacion SAT CFDI 3.1 + */ +export class CreateMercanciaCartaPorteDto { + /** + * Clave de bienes transportados (catalogo SAT c_ClaveProdServCP) + */ + @IsString() + @IsNotEmpty({ message: 'bienesTransp es requerido' }) + @Length(6, 8, { message: 'bienesTransp debe ser codigo SAT de 6-8 caracteres' }) + bienesTransp!: string; + + /** + * Descripcion de la mercancia + */ + @IsString() + @IsNotEmpty({ message: 'descripcion es requerida' }) + @Length(1, 1000) + descripcion!: string; + + /** + * Cantidad + */ + @IsNumber() + @IsNotEmpty() + @Min(0.001) + cantidad!: number; + + /** + * Clave de unidad SAT (catalogo c_ClaveUnidad) + */ + @IsString() + @IsNotEmpty({ message: 'claveUnidad es requerida' }) + @Length(2, 3) + claveUnidad!: string; + + /** + * Descripcion de la unidad + */ + @IsOptional() + @IsString() + @Length(1, 100) + unidad?: string; + + /** + * Peso en KG + */ + @IsNumber() + @IsNotEmpty({ message: 'pesoEnKg es requerido' }) + @Min(0.001) + @Max(100000) + pesoEnKg!: number; + + /** + * Valor de la mercancia + */ + @IsOptional() + @IsNumber() + @Min(0) + valorMercancia?: number; + + /** + * Moneda del valor (default MXN) + */ + @IsOptional() + @IsString() + @Length(3, 3) + moneda?: string; + + // ========== DIMENSIONES (para calculo de volumen) ========== + + /** + * Longitud en cm + */ + @IsOptional() + @IsNumber() + @Min(0) + dimensionesLargo?: number; + + /** + * Ancho en cm + */ + @IsOptional() + @IsNumber() + @Min(0) + dimensionesAncho?: number; + + /** + * Alto en cm + */ + @IsOptional() + @IsNumber() + @Min(0) + dimensionesAlto?: number; + + // ========== MATERIAL PELIGROSO ========== + + /** + * Indica si es material peligroso + */ + @IsOptional() + @IsBoolean() + materialPeligroso?: boolean; + + /** + * Clave de material peligroso (catalogo c_MaterialPeligroso) + */ + @IsOptional() + @IsString() + @Length(4, 4) + cveMaterialPeligroso?: string; + + /** + * Tipo de embalaje (catalogo c_TipoEmbalaje) + */ + @IsOptional() + @IsString() + embalaje?: string; + + /** + * Descripcion del embalaje + */ + @IsOptional() + @IsString() + @Length(1, 100) + descripEmbalaje?: string; + + // ========== COMERCIO EXTERIOR ========== + + /** + * Fraccion arancelaria + */ + @IsOptional() + @IsString() + @Length(8, 10) + fraccionArancelaria?: string; + + /** + * UUID de comercio exterior + */ + @IsOptional() + @IsString() + uuidComercioExt?: string; + + // ========== GUIAS ========== + + /** + * Numero de guia o conocimiento de embarque + */ + @IsOptional() + @IsString() + @Length(1, 30) + guiaIdentificacion?: string; + + /** + * Descripcion de guia + */ + @IsOptional() + @IsString() + guiaDescripcion?: string; + + /** + * Peso de guia en KG + */ + @IsOptional() + @IsNumber() + @Min(0) + guiaPesoKg?: number; + + /** + * Numero de secuencia de la mercancia + */ + @IsNumber() + @IsNotEmpty() + @Min(1) + numeroSecuencia!: number; +} + +/** + * DTO para actualizar mercancia + */ +export class UpdateMercanciaCartaPorteDto { + @IsOptional() + @IsString() + @Length(1, 1000) + descripcion?: string; + + @IsOptional() + @IsNumber() + @Min(0.001) + cantidad?: number; + + @IsOptional() + @IsNumber() + @Min(0.001) + pesoEnKg?: number; + + @IsOptional() + @IsNumber() + @Min(0) + valorMercancia?: number; +} diff --git a/src/modules/carta-porte/dto/timbrar-carta-porte.dto.ts b/src/modules/carta-porte/dto/timbrar-carta-porte.dto.ts new file mode 100644 index 0000000..e798518 --- /dev/null +++ b/src/modules/carta-porte/dto/timbrar-carta-porte.dto.ts @@ -0,0 +1,91 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsDateString, + IsUrl, + IsUUID, +} from 'class-validator'; + +/** + * DTO para registrar el timbrado de Carta Porte + * Datos retornados por el PAC despues de timbrar + */ +export class TimbrarCartaPorteDto { + /** + * UUID del CFDI timbrado (Timbre Fiscal Digital) + */ + @IsUUID() + @IsNotEmpty({ message: 'uuidCfdi es requerido' }) + uuidCfdi!: string; + + /** + * Fecha y hora del timbrado (ISO 8601) + */ + @IsDateString() + @IsNotEmpty({ message: 'fechaTimbrado es requerida' }) + fechaTimbrado!: string; + + /** + * XML completo del CFDI timbrado + */ + @IsString() + @IsNotEmpty({ message: 'xmlCfdi es requerido' }) + xmlCfdi!: string; + + /** + * XML del complemento Carta Porte + */ + @IsString() + @IsNotEmpty({ message: 'xmlCartaPorte es requerido' }) + xmlCartaPorte!: string; + + /** + * URL del PDF generado (representacion impresa) + */ + @IsOptional() + @IsUrl() + pdfUrl?: string; + + /** + * URL del codigo QR + */ + @IsOptional() + @IsUrl() + qrUrl?: string; + + /** + * Sello digital del CFDI + */ + @IsOptional() + @IsString() + selloCfdi?: string; + + /** + * Sello del SAT + */ + @IsOptional() + @IsString() + selloSat?: string; + + /** + * Numero de certificado del emisor + */ + @IsOptional() + @IsString() + noCertificado?: string; + + /** + * Numero de certificado del SAT + */ + @IsOptional() + @IsString() + noCertificadoSat?: string; + + /** + * Cadena original del timbre + */ + @IsOptional() + @IsString() + cadenaOriginalTimbre?: string; +} diff --git a/src/modules/carta-porte/dto/ubicacion-carta-porte.dto.ts b/src/modules/carta-porte/dto/ubicacion-carta-porte.dto.ts new file mode 100644 index 0000000..b49baae --- /dev/null +++ b/src/modules/carta-porte/dto/ubicacion-carta-porte.dto.ts @@ -0,0 +1,176 @@ +import { + IsString, + IsNotEmpty, + IsEnum, + IsOptional, + IsNumber, + IsDateString, + Length, + Min, + Max, +} from 'class-validator'; +import { TipoUbicacionCartaPorte } from '../entities'; + +/** + * DTO para agregar ubicacion a Carta Porte + * Soporta origen, destinos intermedios y destino final + */ +export class CreateUbicacionCartaPorteDto { + /** + * Tipo de ubicacion: Origen o Destino + */ + @IsEnum(TipoUbicacionCartaPorte, { message: 'tipoUbicacion debe ser Origen o Destino' }) + @IsNotEmpty() + tipoUbicacion!: TipoUbicacionCartaPorte; + + /** + * ID de ubicacion (catalogo SAT c_CodigoPostal) + */ + @IsOptional() + @IsString() + idUbicacion?: string; + + /** + * RFC del remitente o destinatario + */ + @IsOptional() + @IsString() + @Length(12, 13) + rfcRemitenteDestinatario?: string; + + /** + * Nombre del remitente o destinatario + */ + @IsOptional() + @IsString() + @Length(1, 254) + nombreRemitenteDestinatario?: string; + + /** + * Numero de estacion (para transporte ferroviario) + */ + @IsOptional() + @IsString() + numEstacion?: string; + + /** + * Nombre de la estacion + */ + @IsOptional() + @IsString() + nombreEstacion?: string; + + /** + * Fecha y hora de salida (para origen) + */ + @IsOptional() + @IsDateString() + fechaHoraSalidaLlegada?: string; + + /** + * Distancia recorrida en KM (0 para origen) + */ + @IsOptional() + @IsNumber() + @Min(0) + @Max(10000) + distanciaRecorrida?: number; + + // ========== DOMICILIO ========== + + /** + * Codigo postal (catalogo SAT, requerido) + */ + @IsString() + @IsNotEmpty({ message: 'codigoPostal es requerido' }) + @Length(5, 5, { message: 'codigoPostal debe ser de 5 digitos' }) + codigoPostal!: string; + + /** + * Estado (catalogo SAT c_Estado) + */ + @IsOptional() + @IsString() + @Length(2, 3) + estado?: string; + + /** + * Municipio (catalogo SAT c_Municipio) + */ + @IsOptional() + @IsString() + municipio?: string; + + /** + * Localidad (catalogo SAT c_Localidad) + */ + @IsOptional() + @IsString() + localidad?: string; + + /** + * Colonia (catalogo SAT c_Colonia) + */ + @IsOptional() + @IsString() + colonia?: string; + + /** + * Calle + */ + @IsOptional() + @IsString() + @Length(1, 100) + calle?: string; + + /** + * Numero exterior + */ + @IsOptional() + @IsString() + @Length(1, 55) + numExterior?: string; + + /** + * Numero interior + */ + @IsOptional() + @IsString() + @Length(1, 30) + numInterior?: string; + + /** + * Referencia geografica + */ + @IsOptional() + @IsString() + @Length(1, 250) + referencia?: string; + + /** + * Numero de secuencia (orden de visita, 1=origen) + */ + @IsNumber() + @IsNotEmpty() + @Min(1) + numeroSecuencia!: number; +} + +/** + * DTO para actualizar ubicacion + */ +export class UpdateUbicacionCartaPorteDto { + @IsOptional() + @IsDateString() + fechaHoraSalidaLlegada?: string; + + @IsOptional() + @IsNumber() + @Min(0) + distanciaRecorrida?: number; + + @IsOptional() + @IsString() + @Length(1, 250) + referencia?: string; +} diff --git a/src/modules/carta-porte/dto/update-carta-porte.dto.ts b/src/modules/carta-porte/dto/update-carta-porte.dto.ts new file mode 100644 index 0000000..aa9606c --- /dev/null +++ b/src/modules/carta-porte/dto/update-carta-porte.dto.ts @@ -0,0 +1,9 @@ +import { PartialType } from '../../../common/partial-type'; +import { CreateCartaPorteDto } from './create-carta-porte.dto'; + +/** + * DTO para actualizar Carta Porte + * Todos los campos son opcionales + * Solo se puede actualizar en estado BORRADOR + */ +export class UpdateCartaPorteDto extends PartialType(CreateCartaPorteDto) {} diff --git a/src/modules/carta-porte/services/carta-porte-xml.service.ts b/src/modules/carta-porte/services/carta-porte-xml.service.ts new file mode 100644 index 0000000..5be1791 --- /dev/null +++ b/src/modules/carta-porte/services/carta-porte-xml.service.ts @@ -0,0 +1,462 @@ +import { + CartaPorte, + TipoCfdiCartaPorte, + UbicacionCartaPorte, + MercanciaCartaPorte, + FiguraTransporte, + AutotransporteCartaPorte, + TipoUbicacionCartaPorte, +} from '../entities'; + +/** + * Namespaces CFDI 4.0 y Carta Porte 3.1 + */ +const CFDI_NAMESPACE = 'http://www.sat.gob.mx/cfd/4'; +const CARTA_PORTE_NAMESPACE = 'http://www.sat.gob.mx/CartaPorte31'; +const XSI_NAMESPACE = 'http://www.w3.org/2001/XMLSchema-instance'; + +const CFDI_SCHEMA_LOCATION = 'http://www.sat.gob.mx/cfd/4 http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd'; +const CARTA_PORTE_SCHEMA_LOCATION = 'http://www.sat.gob.mx/CartaPorte31 http://www.sat.gob.mx/sitio_internet/cfd/CartaPorte/CartaPorte31.xsd'; + +/** + * Servicio para generar XML de Carta Porte CFDI 3.1 + * Cumple con especificacion SAT vigente desde 2024-07-17 + */ +export class CartaPorteXmlService { + /** + * Genera XML del CFDI con complemento Carta Porte 3.1 + * NOTA: Este XML es para sellado previo al timbrado con PAC + */ + generatePreTimbradoXml(cartaPorte: CartaPorte): string { + const xmlParts: string[] = []; + + // Declaracion XML + xmlParts.push(''); + + // Elemento raiz: cfdi:Comprobante + xmlParts.push(this.buildComprobanteElement(cartaPorte)); + + return xmlParts.join('\n'); + } + + /** + * Genera solo el complemento Carta Porte 3.1 (sin CFDI wrapper) + */ + generateCartaPorteComplemento(cartaPorte: CartaPorte): string { + return this.buildCartaPorteElement(cartaPorte); + } + + /** + * Construye el elemento cfdi:Comprobante + */ + private buildComprobanteElement(cp: CartaPorte): string { + const attrs: string[] = []; + + // Namespaces + attrs.push(`xmlns:cfdi="${CFDI_NAMESPACE}"`); + attrs.push(`xmlns:cartaporte31="${CARTA_PORTE_NAMESPACE}"`); + attrs.push(`xmlns:xsi="${XSI_NAMESPACE}"`); + attrs.push(`xsi:schemaLocation="${CFDI_SCHEMA_LOCATION} ${CARTA_PORTE_SCHEMA_LOCATION}"`); + + // Atributos del comprobante + attrs.push('Version="4.0"'); + if (cp.serie) attrs.push(`Serie="${this.escapeXml(cp.serie)}"`); + if (cp.folio) attrs.push(`Folio="${this.escapeXml(cp.folio)}"`); + attrs.push(`Fecha="${this.formatDateTime(new Date())}"`); + + // Forma de pago (99 = Por definir, usado en traslados) + attrs.push('FormaPago="99"'); + + // Sello y Certificado (placeholders, el PAC los llena) + attrs.push('Sello=""'); + attrs.push('NoCertificado=""'); + attrs.push('Certificado=""'); + + // Subtotal y Total + const subtotal = cp.subtotal ?? 0; + const total = cp.total ?? 0; + attrs.push(`SubTotal="${subtotal.toFixed(2)}"`); + attrs.push(`Total="${total.toFixed(2)}"`); + + // Moneda + attrs.push(`Moneda="${cp.moneda || 'MXN'}"`); + + // Tipo de comprobante + const tipoComprobante = cp.tipoCfdi === TipoCfdiCartaPorte.INGRESO ? 'I' : 'T'; + attrs.push(`TipoDeComprobante="${tipoComprobante}"`); + + // Exportacion (01 = No aplica) + attrs.push('Exportacion="01"'); + + // Metodo de pago (PUE = Pago en una sola exhibicion) + if (cp.tipoCfdi === TipoCfdiCartaPorte.INGRESO) { + attrs.push('MetodoPago="PUE"'); + } + + // Lugar de expedicion (CP del emisor) + attrs.push(`LugarExpedicion="${cp.emisorDomicilioFiscalCp || '00000'}"`); + + // Construir XML + let xml = `\n`; + + // Emisor + xml += this.buildEmisorElement(cp); + + // Receptor + xml += this.buildReceptorElement(cp); + + // Conceptos + xml += this.buildConceptosElement(cp); + + // Complemento (Carta Porte 3.1) + xml += ' \n'; + xml += this.buildCartaPorteElement(cp); + xml += ' \n'; + + xml += ''; + + return xml; + } + + /** + * Construye el elemento cfdi:Emisor + */ + private buildEmisorElement(cp: CartaPorte): string { + const attrs: string[] = []; + attrs.push(`Rfc="${this.escapeXml(cp.emisorRfc)}"`); + attrs.push(`Nombre="${this.escapeXml(cp.emisorNombre)}"`); + attrs.push(`RegimenFiscal="${cp.emisorRegimenFiscal || '601'}"`); + + return ` \n`; + } + + /** + * Construye el elemento cfdi:Receptor + */ + private buildReceptorElement(cp: CartaPorte): string { + const attrs: string[] = []; + attrs.push(`Rfc="${this.escapeXml(cp.receptorRfc)}"`); + attrs.push(`Nombre="${this.escapeXml(cp.receptorNombre)}"`); + + // Uso CFDI (S01 = Sin efectos fiscales para traslados) + const usoCfdi = cp.receptorUsoCfdi || (cp.tipoCfdi === TipoCfdiCartaPorte.TRASLADO ? 'S01' : 'G03'); + attrs.push(`UsoCFDI="${usoCfdi}"`); + + // Domicilio fiscal CP + if (cp.receptorDomicilioFiscalCp) { + attrs.push(`DomicilioFiscalReceptor="${cp.receptorDomicilioFiscalCp}"`); + } + + // Regimen fiscal receptor + attrs.push('RegimenFiscalReceptor="616"'); // Sin obligaciones fiscales (default) + + return ` \n`; + } + + /** + * Construye el elemento cfdi:Conceptos + * Para Carta Porte: 1 concepto con clave SAT 78101800 (Servicio de transporte) + */ + private buildConceptosElement(cp: CartaPorte): string { + let xml = ' \n'; + + const cantidad = 1; + const valorUnitario = cp.subtotal ?? 0; + const importe = cp.total ?? 0; + + // Clave de producto/servicio SAT para transporte de carga + const claveProdServ = '78101800'; + const claveUnidad = 'E48'; // Unidad de servicio + const descripcion = 'Servicio de transporte de carga'; + + xml += ' \n`; // 01 = No objeto de impuesto + + xml += ' \n'; + + return xml; + } + + /** + * Construye el complemento cartaporte31:CartaPorte + */ + private buildCartaPorteElement(cp: CartaPorte): string { + const attrs: string[] = []; + attrs.push('Version="3.1"'); + + // Transporte internacional + const transpInt = cp.transporteInternacional ? 'Sí' : 'No'; + attrs.push(`TranspInternac="${transpInt}"`); + + if (cp.transporteInternacional) { + if (cp.entradaSalidaMerc) attrs.push(`EntradaSalidaMerc="${cp.entradaSalidaMerc}"`); + if (cp.paisOrigenDestino) attrs.push(`PaisOrigenDestino="${cp.paisOrigenDestino}"`); + } + + // Totales de mercancias + if (cp.pesoBrutoTotal) attrs.push(`TotalDistRec="${cp.pesoBrutoTotal.toFixed(3)}"`); + + let xml = ` \n`; + + // Ubicaciones + xml += this.buildUbicacionesElement(cp.ubicaciones || []); + + // Mercancias + xml += this.buildMercanciasElement(cp); + + // Figuras de transporte + xml += this.buildFigurasTransporteElement(cp.figuras || []); + + xml += ' \n'; + + return xml; + } + + /** + * Construye el elemento cartaporte31:Ubicaciones + */ + private buildUbicacionesElement(ubicaciones: UbicacionCartaPorte[]): string { + if (ubicaciones.length === 0) return ''; + + let xml = ' \n'; + + // Ordenar por secuencia + const sorted = [...ubicaciones].sort((a, b) => a.numeroSecuencia - b.numeroSecuencia); + + for (const ub of sorted) { + const tipoUbicacion = ub.tipoUbicacion === TipoUbicacionCartaPorte.ORIGEN ? 'Origen' : 'Destino'; + const attrs: string[] = []; + + attrs.push(`TipoUbicacion="${tipoUbicacion}"`); + if (ub.idUbicacion) attrs.push(`IDUbicacion="${this.escapeXml(ub.idUbicacion)}"`); + if (ub.rfcRemitenteDestinatario) attrs.push(`RFCRemitenteDestinatario="${ub.rfcRemitenteDestinatario}"`); + if (ub.nombreRemitenteDestinatario) attrs.push(`NombreRemitenteDestinatario="${this.escapeXml(ub.nombreRemitenteDestinatario)}"`); + + if (ub.fechaHoraSalidaLlegada) { + const fechaStr = this.formatDateTime(new Date(ub.fechaHoraSalidaLlegada)); + attrs.push(`FechaHoraSalidaLlegada="${fechaStr}"`); + } + + if (ub.distanciaRecorrida !== undefined && ub.distanciaRecorrida > 0) { + attrs.push(`DistanciaRecorrida="${ub.distanciaRecorrida.toFixed(2)}"`); + } + + xml += ` \n`; + + // Domicilio + xml += this.buildDomicilioElement(ub); + + xml += ' \n'; + } + + xml += ' \n'; + + return xml; + } + + /** + * Construye el elemento cartaporte31:Domicilio + */ + private buildDomicilioElement(ub: UbicacionCartaPorte): string { + const attrs: string[] = []; + + if (ub.calle) attrs.push(`Calle="${this.escapeXml(ub.calle)}"`); + if (ub.numExterior) attrs.push(`NumeroExterior="${this.escapeXml(ub.numExterior)}"`); + if (ub.numInterior) attrs.push(`NumeroInterior="${this.escapeXml(ub.numInterior)}"`); + if (ub.colonia) attrs.push(`Colonia="${ub.colonia}"`); + if (ub.localidad) attrs.push(`Localidad="${ub.localidad}"`); + if (ub.referencia) attrs.push(`Referencia="${this.escapeXml(ub.referencia)}"`); + if (ub.municipio) attrs.push(`Municipio="${ub.municipio}"`); + if (ub.estado) attrs.push(`Estado="${ub.estado}"`); + attrs.push('Pais="MEX"'); + attrs.push(`CodigoPostal="${ub.codigoPostal}"`); + + return ` \n`; + } + + /** + * Construye el elemento cartaporte31:Mercancias + */ + private buildMercanciasElement(cp: CartaPorte): string { + const mercancias = cp.mercancias || []; + if (mercancias.length === 0) return ''; + + // Calcular totales + const pesoTotal = mercancias.reduce((sum, m) => sum + (m.pesoEnKg || 0), 0); + const numMercancias = mercancias.length; + + const attrs: string[] = []; + attrs.push(`PesoBrutoTotal="${pesoTotal.toFixed(3)}"`); + attrs.push(`UnidadPeso="${cp.unidadPeso || 'KGM'}"`); + attrs.push(`NumTotalMercancias="${numMercancias}"`); + + let xml = ` \n`; + + // Cada mercancia + for (const merc of mercancias) { + xml += this.buildMercanciaElement(merc); + } + + // Autotransporte + xml += this.buildAutotransporteElement(cp); + + xml += ' \n'; + + return xml; + } + + /** + * Construye el elemento cartaporte31:Mercancia + */ + private buildMercanciaElement(merc: MercanciaCartaPorte): string { + const attrs: string[] = []; + + attrs.push(`BienesTransp="${merc.bienesTransp}"`); + attrs.push(`Descripcion="${this.escapeXml(merc.descripcion)}"`); + attrs.push(`Cantidad="${merc.cantidad.toFixed(4)}"`); + attrs.push(`ClaveUnidad="${merc.claveUnidad}"`); + if (merc.unidad) attrs.push(`Unidad="${this.escapeXml(merc.unidad)}"`); + + if (merc.dimensionesLargo && merc.dimensionesAncho && merc.dimensionesAlto) { + const dims = `${merc.dimensionesLargo}/${merc.dimensionesAncho}/${merc.dimensionesAlto}cm`; + attrs.push(`Dimensiones="${dims}"`); + } + + if (merc.materialPeligroso) { + attrs.push('MaterialPeligroso="Sí"'); + if (merc.cveMaterialPeligroso) attrs.push(`CveMaterialPeligroso="${merc.cveMaterialPeligroso}"`); + if (merc.embalaje) attrs.push(`Embalaje="${merc.embalaje}"`); + if (merc.descripEmbalaje) attrs.push(`DescripEmbalaje="${this.escapeXml(merc.descripEmbalaje)}"`); + } else { + attrs.push('MaterialPeligroso="No"'); + } + + attrs.push(`PesoEnKg="${merc.pesoEnKg.toFixed(3)}"`); + + if (merc.valorMercancia) { + attrs.push(`ValorMercancia="${merc.valorMercancia.toFixed(2)}"`); + attrs.push(`Moneda="${merc.moneda || 'MXN'}"`); + } + + if (merc.fraccionArancelaria) attrs.push(`FraccionArancelaria="${merc.fraccionArancelaria}"`); + if (merc.uuidComercioExt) attrs.push(`UUIDComercioExt="${merc.uuidComercioExt}"`); + + return ` \n`; + } + + /** + * Construye el elemento cartaporte31:Autotransporte + */ + private buildAutotransporteElement(cp: CartaPorte): string { + const autos = cp.autotransporte || []; + if (autos.length === 0) return ''; + + const auto = autos[0]; // Usualmente solo hay uno + + const attrs: string[] = []; + attrs.push(`PermSCT="${auto.permSCT}"`); + attrs.push(`NumPermisoSCT="${auto.numPermisoSCT}"`); + + let xml = ` \n`; + + // Identificacion vehicular + const idVehAttrs: string[] = []; + idVehAttrs.push(`ConfigVehicular="${auto.configVehicular}"`); + idVehAttrs.push(`PlacaVM="${auto.placaVM}"`); + if (auto.anioModeloVM) idVehAttrs.push(`AnioModeloVM="${auto.anioModeloVM}"`); + + xml += ` \n`; + + // Seguros + if (cp.aseguraRespCivil) { + xml += ` \n`; + } + + // Remolques + if (auto.remolques && auto.remolques.length > 0) { + xml += ' \n'; + for (const rem of auto.remolques) { + xml += ` \n`; + } + xml += ' \n'; + } + + xml += ' \n'; + + return xml; + } + + /** + * Construye el elemento cartaporte31:FiguraTransporte + */ + private buildFigurasTransporteElement(figuras: FiguraTransporte[]): string { + if (figuras.length === 0) return ''; + + let xml = ' \n'; + + for (const fig of figuras) { + const attrs: string[] = []; + attrs.push(`TipoFigura="${fig.tipoFigura}"`); + if (fig.rfcFigura) attrs.push(`RFCFigura="${fig.rfcFigura}"`); + attrs.push(`NombreFigura="${this.escapeXml(fig.nombreFigura)}"`); + if (fig.numLicencia) attrs.push(`NumLicencia="${fig.numLicencia}"`); + if (fig.numRegIdTribFigura) attrs.push(`NumRegIdTribFigura="${fig.numRegIdTribFigura}"`); + if (fig.residenciaFiscalFigura) attrs.push(`ResidenciaFiscalFigura="${fig.residenciaFiscalFigura}"`); + + xml += ` 0) { + xml += '>\n'; + for (const parte of fig.partesTransporte) { + xml += ` \n`; + } + xml += ' \n'; + } else { + xml += '/>\n'; + } + } + + xml += ' \n'; + + return xml; + } + + /** + * Escapa caracteres especiales XML + */ + private escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * Formatea fecha en formato ISO 8601 para SAT + */ + private formatDateTime(date: Date): string { + return date.toISOString().replace(/\.\d{3}Z$/, ''); + } +} diff --git a/src/modules/ordenes-transporte/__tests__/ordenes-transporte.service.spec.ts b/src/modules/ordenes-transporte/__tests__/ordenes-transporte.service.spec.ts new file mode 100644 index 0000000..04eddc2 --- /dev/null +++ b/src/modules/ordenes-transporte/__tests__/ordenes-transporte.service.spec.ts @@ -0,0 +1,1235 @@ +/** + * @fileoverview Unit tests for OrdenesTransporteService + * Tests cover CRUD operations, business logic (asignarUnidad, calcularTarifa, cambiarEstado), + * and validation scenarios. + * + * Schema: transport + * Module: MAI-003 (Ordenes de Transporte) + */ + +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { Repository } from 'typeorm'; +import { + OrdenTransporte, + EstadoOrdenTransporte, + ModalidadServicio, + TipoCarga, +} from '../entities/index.js'; +import { + OrdenesTransporteService, + CreateOrdenTransporteDto, + UpdateOrdenTransporteDto, + AsignarUnidadDto, + ServiceContext, +} from '../services/ordenes-transporte.service.js'; + +describe('OrdenesTransporteService', () => { + let service: OrdenesTransporteService; + let mockRepository: any; + let mockManager: any; + + // Test fixtures - UUIDs + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockOrderId = '550e8400-e29b-41d4-a716-446655440010'; + const mockClienteId = '550e8400-e29b-41d4-a716-446655440020'; + const mockShipperId = '550e8400-e29b-41d4-a716-446655440021'; + const mockConsigneeId = '550e8400-e29b-41d4-a716-446655440022'; + const mockUnidadId = '550e8400-e29b-41d4-a716-446655440030'; + const mockOperadorId = '550e8400-e29b-41d4-a716-446655440031'; + const mockViajeId = '550e8400-e29b-41d4-a716-446655440040'; + const mockTarifaId = '550e8400-e29b-41d4-a716-446655440050'; + + // Mock OrdenTransporte entity factory + const createMockOrden = (overrides: Partial = {}): OrdenTransporte => ({ + id: mockOrderId, + tenantId: mockTenantId, + codigo: 'OT-202602-000001', + numeroOt: 'OT-202602-000001', + referenciaCliente: 'REF-CLIENTE-001', + clienteId: mockClienteId, + shipperId: mockShipperId, + shipperNombre: 'Shipper Test SA', + consigneeId: mockConsigneeId, + consigneeNombre: 'Consignee Test SA', + origenDireccion: 'Av. Insurgentes Sur 1000, CDMX', + origenCiudad: 'Ciudad de Mexico', + origenEstado: 'CDMX', + origenCodigoPostal: '03100', + origenLatitud: 19.3984, + origenLongitud: -99.1576, + origenContacto: 'Juan Perez', + origenTelefono: '5551234567', + destinoDireccion: 'Blvd. Puerta de Hierro 5000, Guadalajara', + destinoCiudad: 'Guadalajara', + destinoEstado: 'Jalisco', + destinoCodigoPostal: '44510', + destinoLatitud: 20.6597, + destinoLongitud: -103.3496, + destinoContacto: 'Maria Garcia', + destinoTelefono: '3331234567', + fechaRecoleccion: null as any, + fechaRecoleccionProgramada: new Date('2026-02-10T08:00:00Z'), + fechaEntregaProgramada: new Date('2026-02-11T18:00:00Z'), + tipoCarga: TipoCarga.GENERAL, + descripcionCarga: 'Mercancia general paletizada', + pesoKg: 15000, + volumenM3: 45, + piezas: 50, + pallets: 20, + valorDeclarado: 500000, + requiereTemperatura: false, + temperaturaMin: null as any, + temperaturaMax: null as any, + requiereGps: true, + requiereEscolta: false, + instruccionesEspeciales: 'Entregar en horario laboral', + modalidadServicio: ModalidadServicio.FTL, + tarifaId: mockTarifaId, + tarifaBase: 25000, + recargos: 2000, + descuentos: 500, + subtotal: 26500, + iva: 4240, + total: 30740, + estado: EstadoOrdenTransporte.BORRADOR, + viajeId: null as any, + embarqueId: null as any, + observaciones: 'Orden de prueba', + createdAt: new Date('2026-02-01T10:00:00Z'), + createdById: mockUserId, + updatedAt: new Date('2026-02-01T10:00:00Z'), + updatedById: null as any, + deletedAt: null as any, + ...overrides, + }); + + const mockCreateDto: CreateOrdenTransporteDto = { + clienteId: mockClienteId, + referenciaCliente: 'REF-CLIENTE-001', + modalidadServicio: ModalidadServicio.FTL, + tipoCarga: TipoCarga.GENERAL, + shipperId: mockShipperId, + shipperNombre: 'Shipper Test SA', + consigneeId: mockConsigneeId, + consigneeNombre: 'Consignee Test SA', + origenDireccion: 'Av. Insurgentes Sur 1000, CDMX', + origenCiudad: 'Ciudad de Mexico', + origenEstado: 'CDMX', + destinoDireccion: 'Blvd. Puerta de Hierro 5000, Guadalajara', + destinoCiudad: 'Guadalajara', + destinoEstado: 'Jalisco', + descripcionCarga: 'Mercancia general', + pesoKg: 15000, + tarifaBase: 25000, + }; + + const mockServiceContext: ServiceContext = { + tenantId: mockTenantId, + userId: mockUserId, + correlationId: 'test-correlation-id', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mock manager for raw queries + mockManager = { + query: jest.fn(), + }; + + // Setup mock repository + mockRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + findAndCount: jest.fn(), + count: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + manager: mockManager, + }; + + // Create service instance with mocked repository + service = new OrdenesTransporteService(mockRepository as Repository); + }); + + // =========================================================================== + // CRUD OPERATIONS + // =========================================================================== + + describe('CRUD Operations', () => { + describe('create()', () => { + it('should create a new transport order with BORRADOR state', async () => { + const mockOrden = createMockOrden(); + mockRepository.count.mockResolvedValue(0); + mockRepository.create.mockReturnValue(mockOrden); + mockRepository.save.mockResolvedValue(mockOrden); + + const result = await service.create(mockTenantId, mockCreateDto, mockUserId); + + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: mockTenantId, + clienteId: mockCreateDto.clienteId, + estado: EstadoOrdenTransporte.BORRADOR, + createdById: mockUserId, + }) + ); + expect(mockRepository.save).toHaveBeenCalled(); + expect(result).toBeDefined(); + expect(result.id).toBe(mockOrderId); + }); + + it('should calculate totals correctly on create', async () => { + const mockOrden = createMockOrden(); + mockRepository.count.mockResolvedValue(0); + mockRepository.create.mockReturnValue(mockOrden); + mockRepository.save.mockResolvedValue(mockOrden); + + const dtoWithTarifa: CreateOrdenTransporteDto = { + ...mockCreateDto, + tarifaBase: 10000, + recargos: 1000, + descuentos: 500, + }; + + await service.create(mockTenantId, dtoWithTarifa, mockUserId); + + // Subtotal = 10000 + 1000 - 500 = 10500 + // IVA = 10500 * 0.16 = 1680 + // Total = 10500 + 1680 = 12180 + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + subtotal: 10500, + iva: 1680, + total: 12180, + }) + ); + }); + + it('should generate unique codigo for the order', async () => { + const mockOrden = createMockOrden(); + mockRepository.count.mockResolvedValue(5); // 5 existing orders + mockRepository.create.mockReturnValue(mockOrden); + mockRepository.save.mockResolvedValue(mockOrden); + + await service.create(mockTenantId, mockCreateDto, mockUserId); + + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + codigo: expect.stringMatching(/^OT-\d{6}-\d{6}$/), + }) + ); + }); + }); + + describe('findAll()', () => { + it('should return paginated list of orders', async () => { + const mockOrdenes = [createMockOrden(), createMockOrden({ id: 'order-2' })]; + mockRepository.findAndCount.mockResolvedValue([mockOrdenes, 2]); + + const result = await service.findAll(mockTenantId, { limit: 50, offset: 0 }); + + expect(mockRepository.findAndCount).toHaveBeenCalled(); + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.page).toBe(1); + expect(result.pageSize).toBe(50); + }); + + it('should filter by estado', async () => { + const mockOrdenes = [createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA })]; + mockRepository.findAndCount.mockResolvedValue([mockOrdenes, 1]); + + const result = await service.findAll(mockTenantId, { + estado: EstadoOrdenTransporte.CONFIRMADA, + }); + + expect(mockRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.arrayContaining([ + expect.objectContaining({ + estado: EstadoOrdenTransporte.CONFIRMADA, + }), + ]), + }) + ); + expect(result.data[0].estado).toBe(EstadoOrdenTransporte.CONFIRMADA); + }); + + it('should filter by clienteId', async () => { + const mockOrdenes = [createMockOrden()]; + mockRepository.findAndCount.mockResolvedValue([mockOrdenes, 1]); + + await service.findAll(mockTenantId, { clienteId: mockClienteId }); + + expect(mockRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.arrayContaining([ + expect.objectContaining({ + clienteId: mockClienteId, + }), + ]), + }) + ); + }); + + it('should filter by search term across multiple fields', async () => { + mockRepository.findAndCount.mockResolvedValue([[], 0]); + + await service.findAll(mockTenantId, { search: 'TEST' }); + + // Should create multiple where clauses for search + expect(mockRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.arrayContaining([ + expect.objectContaining({ codigo: expect.anything() }), + expect.objectContaining({ shipperNombre: expect.anything() }), + ]), + }) + ); + }); + + it('should filter by date range', async () => { + const fechaDesde = new Date('2026-02-01'); + const fechaHasta = new Date('2026-02-28'); + mockRepository.findAndCount.mockResolvedValue([[], 0]); + + await service.findAll(mockTenantId, { fechaDesde, fechaHasta }); + + expect(mockRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should support ordering by different fields', async () => { + mockRepository.findAndCount.mockResolvedValue([[], 0]); + + await service.findAll(mockTenantId, { + orderBy: 'fechaRecoleccion', + orderDir: 'ASC', + }); + + expect(mockRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + order: { fechaRecoleccionProgramada: 'ASC' }, + }) + ); + }); + }); + + describe('findOne() / findById()', () => { + it('should return order with relations when found', async () => { + const mockOrden = createMockOrden(); + mockRepository.findOne.mockResolvedValue(mockOrden); + + const result = await service.findById(mockTenantId, mockOrderId); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockOrderId, tenantId: mockTenantId }, + }); + expect(result).toBeDefined(); + expect(result?.id).toBe(mockOrderId); + }); + + it('should return null when order not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + const result = await service.findById(mockTenantId, 'non-existent-id'); + + expect(result).toBeNull(); + }); + + it('should enforce tenant isolation', async () => { + const mockOrden = createMockOrden(); + mockRepository.findOne.mockResolvedValue(mockOrden); + + await service.findById(mockTenantId, mockOrderId); + + expect(mockRepository.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + tenantId: mockTenantId, + }), + }) + ); + }); + }); + + describe('update()', () => { + it('should update order fields when in allowed state', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.BORRADOR }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockResolvedValue({ ...mockOrden, shipperNombre: 'Updated Shipper' }); + + const updateDto: UpdateOrdenTransporteDto = { + shipperNombre: 'Updated Shipper', + }; + + const result = await service.update(mockTenantId, mockOrderId, updateDto, mockUserId); + + expect(mockRepository.save).toHaveBeenCalled(); + expect(result?.shipperNombre).toBe('Updated Shipper'); + }); + + it('should reject updates in final states (ENTREGADA)', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.ENTREGADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + const updateDto: UpdateOrdenTransporteDto = { shipperNombre: 'New Name' }; + + await expect( + service.update(mockTenantId, mockOrderId, updateDto, mockUserId) + ).rejects.toThrow(/No se puede modificar una OT en estado ENTREGADA/); + }); + + it('should reject updates in final states (FACTURADA)', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.FACTURADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + const updateDto: UpdateOrdenTransporteDto = { shipperNombre: 'New Name' }; + + await expect( + service.update(mockTenantId, mockOrderId, updateDto, mockUserId) + ).rejects.toThrow(/No se puede modificar una OT en estado FACTURADA/); + }); + + it('should reject updates in final states (CANCELADA)', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.CANCELADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + const updateDto: UpdateOrdenTransporteDto = { shipperNombre: 'New Name' }; + + await expect( + service.update(mockTenantId, mockOrderId, updateDto, mockUserId) + ).rejects.toThrow(/No se puede modificar una OT en estado CANCELADA/); + }); + + it('should recalculate totals when tarifa fields change', async () => { + const mockOrden = createMockOrden({ + estado: EstadoOrdenTransporte.CONFIRMADA, + tarifaBase: 10000, + recargos: 0, + descuentos: 0, + subtotal: 10000, + iva: 1600, + total: 11600, + }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const updateDto: UpdateOrdenTransporteDto = { + tarifaBase: 20000, + recargos: 3000, + descuentos: 1000, + }; + + const result = await service.update(mockTenantId, mockOrderId, updateDto, mockUserId); + + // New subtotal = 20000 + 3000 - 1000 = 22000 + // New IVA = 22000 * 0.16 = 3520 + // New total = 22000 + 3520 = 25520 + expect(result?.subtotal).toBe(22000); + expect(result?.iva).toBe(3520); + expect(result?.total).toBe(25520); + }); + + it('should return null when order not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + const result = await service.update( + mockTenantId, + 'non-existent-id', + { shipperNombre: 'Test' }, + mockUserId + ); + + expect(result).toBeNull(); + }); + }); + }); + + // =========================================================================== + // BUSINESS LOGIC + // =========================================================================== + + describe('Business Logic', () => { + describe('asignarUnidad()', () => { + const mockAsignarDto: AsignarUnidadDto = { + unidadId: mockUnidadId, + operadorId: mockOperadorId, + remolqueId: '550e8400-e29b-41d4-a716-446655440032', + fechaSalidaProgramada: new Date('2026-02-10T06:00:00Z'), + fechaLlegadaProgramada: new Date('2026-02-11T20:00:00Z'), + }; + + it('should assign unit and operator when order is CONFIRMADA', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + // Mock unidad validation - available + mockManager.query + .mockResolvedValueOnce([{ id: mockUnidadId, estado: 'DISPONIBLE', activo: true }]) // unidad check + .mockResolvedValueOnce([]) // no conflicting trips + .mockResolvedValueOnce([{ // operador check + id: mockOperadorId, + activo: true, + estado: 'DISPONIBLE', + licencia_vigencia: '2027-01-01', + certificado_fisico_vigencia: '2027-01-01', + antidoping_vigencia: '2027-01-01', + }]) + .mockResolvedValueOnce([{ count: '0' }]) // viaje count + .mockResolvedValueOnce([{ id: mockViajeId }]); // viaje insert + + const result = await service.asignarUnidad(mockOrderId, mockAsignarDto, mockServiceContext); + + expect(result).toBeDefined(); + expect(result?.orden.estado).toBe(EstadoOrdenTransporte.ASIGNADA); + expect(result?.viajeId).toBe(mockViajeId); + }); + + it('should assign unit when order is PENDIENTE', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.PENDIENTE }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + mockManager.query + .mockResolvedValueOnce([{ id: mockUnidadId, estado: 'DISPONIBLE', activo: true }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ + id: mockOperadorId, + activo: true, + estado: 'DISPONIBLE', + licencia_vigencia: '2027-01-01', + certificado_fisico_vigencia: '2027-01-01', + antidoping_vigencia: '2027-01-01', + }]) + .mockResolvedValueOnce([{ count: '0' }]) + .mockResolvedValueOnce([{ id: mockViajeId }]); + + const result = await service.asignarUnidad(mockOrderId, mockAsignarDto, mockServiceContext); + + expect(result).toBeDefined(); + expect(result?.orden.estado).toBe(EstadoOrdenTransporte.ASIGNADA); + }); + + it('should reject assignment when order not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + service.asignarUnidad(mockOrderId, mockAsignarDto, mockServiceContext) + ).rejects.toThrow('Orden de transporte no encontrada'); + }); + + it('should reject assignment when order is in invalid state (EN_TRANSITO)', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.EN_TRANSITO }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + await expect( + service.asignarUnidad(mockOrderId, mockAsignarDto, mockServiceContext) + ).rejects.toThrow(/No se puede asignar unidad a una OT en estado EN_TRANSITO/); + }); + + it('should reject assignment when order already has a viaje assigned', async () => { + const mockOrden = createMockOrden({ + estado: EstadoOrdenTransporte.CONFIRMADA, + viajeId: 'existing-viaje-id', + }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + await expect( + service.asignarUnidad(mockOrderId, mockAsignarDto, mockServiceContext) + ).rejects.toThrow(/La orden ya tiene un viaje asignado/); + }); + + it('should reject assignment to unavailable unit (EN_VIAJE)', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query.mockResolvedValueOnce([{ + id: mockUnidadId, + estado: 'EN_VIAJE', + activo: true, + }]); + + await expect( + service.asignarUnidad(mockOrderId, mockAsignarDto, mockServiceContext) + ).rejects.toThrow(/Unidad.*no disponible.*Estado actual: EN_VIAJE/); + }); + + it('should reject assignment to inactive unit', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query.mockResolvedValueOnce([{ + id: mockUnidadId, + estado: 'DISPONIBLE', + activo: false, + }]); + + await expect( + service.asignarUnidad(mockOrderId, mockAsignarDto, mockServiceContext) + ).rejects.toThrow(/Unidad.*no disponible.*Unidad inactiva/); + }); + + it('should reject assignment to non-existent unit', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query.mockResolvedValueOnce([]); // No unit found + + await expect( + service.asignarUnidad(mockOrderId, mockAsignarDto, mockServiceContext) + ).rejects.toThrow(/Unidad.*no disponible.*Unidad no encontrada/); + }); + + it('should reject assignment when unit has conflicting trips', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query + .mockResolvedValueOnce([{ id: mockUnidadId, estado: 'DISPONIBLE', activo: true }]) + .mockResolvedValueOnce([{ id: 'conflict-viaje', codigo: 'VJ-202602-000001' }]); // Conflicting trip + + await expect( + service.asignarUnidad(mockOrderId, mockAsignarDto, mockServiceContext) + ).rejects.toThrow(/Unidad.*no disponible.*Conflicto con viaje/); + }); + + it('should reject assignment when operator has expired documents', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query + .mockResolvedValueOnce([{ id: mockUnidadId, estado: 'DISPONIBLE', activo: true }]) + .mockResolvedValueOnce([]) // No conflicting trips + .mockResolvedValueOnce([{ + id: mockOperadorId, + activo: true, + estado: 'DISPONIBLE', + licencia_vigencia: '2025-01-01', // Expired + certificado_fisico_vigencia: '2027-01-01', + antidoping_vigencia: '2027-01-01', + }]); + + await expect( + service.asignarUnidad(mockOrderId, mockAsignarDto, mockServiceContext) + ).rejects.toThrow(/Operador.*tiene documentos vencidos.*Licencia de conducir/); + }); + + it('should reject assignment when operator not found', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query + .mockResolvedValueOnce([{ id: mockUnidadId, estado: 'DISPONIBLE', activo: true }]) + .mockResolvedValueOnce([]) // No conflicting trips + .mockResolvedValueOnce([]); // Operator not found + + await expect( + service.asignarUnidad(mockOrderId, mockAsignarDto, mockServiceContext) + ).rejects.toThrow(/documentos vencidos.*Operador no encontrado/); + }); + }); + + describe('calcularTarifa()', () => { + it('should calculate tarifa using specific tarifa from database', async () => { + const mockOrden = createMockOrden({ + origenLatitud: 19.3984, + origenLongitud: -99.1576, + destinoLatitud: 20.6597, + destinoLongitud: -103.3496, + pesoKg: 15000, + }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + // Mock tarifa lookup + mockManager.query + .mockResolvedValueOnce([]) // No lane match + .mockResolvedValueOnce([{ + id: mockTarifaId, + codigo: 'TAR-001', + tipoTarifa: 'FIJO', + tarifaBase: 20000, + moneda: 'MXN', + }]); + + const result = await service.calcularTarifa(mockOrderId, mockServiceContext); + + expect(result).toBeDefined(); + expect(result?.tarifaId).toBe(mockTarifaId); + expect(result?.tarifaBase).toBe(20000); + expect(result?.moneda).toBe('MXN'); + expect(result?.ivaRate).toBe(0.16); + }); + + it('should calculate tarifa by kilometer when tipoTarifa is POR_KM', async () => { + const mockOrden = createMockOrden({ + origenLatitud: 19.3984, + origenLongitud: -99.1576, + destinoLatitud: 20.6597, + destinoLongitud: -103.3496, + }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query + .mockResolvedValueOnce([]) // No lane + .mockResolvedValueOnce([{ + id: mockTarifaId, + codigo: 'TAR-KM', + tipoTarifa: 'POR_KM', + tarifaBase: 5000, + tarifaKm: 25, + moneda: 'MXN', + }]); + + const result = await service.calcularTarifa(mockOrderId, mockServiceContext); + + expect(result).toBeDefined(); + expect(result?.tarifaBase).toBe(5000); + expect(result?.montoVariable).toBeGreaterThan(0); // Based on distance + expect(result?.distanciaEstimadaKm).toBeDefined(); + }); + + it('should calculate tarifa by weight when tipoTarifa is POR_TONELADA', async () => { + const mockOrden = createMockOrden({ + pesoKg: 20000, // 20 tons + origenLatitud: 19.3984, + origenLongitud: -99.1576, + destinoLatitud: 20.6597, + destinoLongitud: -103.3496, + }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ + id: mockTarifaId, + codigo: 'TAR-TON', + tipoTarifa: 'POR_TONELADA', + tarifaBase: 3000, + tarifaTonelada: 500, + moneda: 'MXN', + }]); + + const result = await service.calcularTarifa(mockOrderId, mockServiceContext); + + expect(result).toBeDefined(); + // montoVariable = 20 tons * 500 = 10000 + expect(result?.montoVariable).toBe(10000); + }); + + it('should use default tarifa when no specific tarifa found', async () => { + const mockOrden = createMockOrden({ + origenLatitud: 19.3984, + origenLongitud: -99.1576, + destinoLatitud: 20.6597, + destinoLongitud: -103.3496, + }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query + .mockResolvedValueOnce([]) // No lane + .mockResolvedValueOnce([]); // No tarifa + + const result = await service.calcularTarifa(mockOrderId, mockServiceContext); + + expect(result).toBeDefined(); + expect(result?.tarifaId).toBeUndefined(); + expect(result?.tarifaBase).toBe(15000); // Default base + expect(result?.montoVariable).toBeGreaterThan(0); // Default $25/km + }); + + it('should throw error when order not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + service.calcularTarifa('non-existent-id', mockServiceContext) + ).rejects.toThrow('Orden de transporte no encontrada'); + }); + + it('should apply minimum tarifa when calculated is below minimum', async () => { + const mockOrden = createMockOrden({ + pesoKg: 100, // Very light cargo + origenLatitud: 19.3984, + origenLongitud: -99.1576, + destinoLatitud: 19.4000, // Very close + destinoLongitud: -99.1600, + }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ + id: mockTarifaId, + codigo: 'TAR-MIN', + tipoTarifa: 'POR_KM', + tarifaBase: 1000, + tarifaKm: 10, + minimoFacturar: 15000, + moneda: 'MXN', + }]); + + const result = await service.calcularTarifa(mockOrderId, mockServiceContext); + + expect(result).toBeDefined(); + expect(result?.minimoAplicado).toBe(true); + expect(result?.tarifaBase).toBe(15000); + }); + + it('should add fuel surcharge for refrigerated cargo', async () => { + const mockOrden = createMockOrden({ + requiereTemperatura: true, + origenLatitud: 19.3984, + origenLongitud: -99.1576, + destinoLatitud: 20.6597, + destinoLongitud: -103.3496, + }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ + id: mockTarifaId, + tipoTarifa: 'FIJO', + tarifaBase: 20000, + moneda: 'MXN', + }]); + + const result = await service.calcularTarifa(mockOrderId, mockServiceContext); + + expect(result).toBeDefined(); + expect(result?.recargoCombustible).toBeGreaterThan(0); + }); + + it('should add surcharge for hazardous cargo (PELIGROSA)', async () => { + const mockOrden = createMockOrden({ + tipoCarga: TipoCarga.PELIGROSA, + origenLatitud: 19.3984, + origenLongitud: -99.1576, + destinoLatitud: 20.6597, + destinoLongitud: -103.3496, + }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + mockManager.query + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ + id: mockTarifaId, + tipoTarifa: 'FIJO', + tarifaBase: 20000, + moneda: 'MXN', + }]); + + const result = await service.calcularTarifa(mockOrderId, mockServiceContext); + + expect(result).toBeDefined(); + expect(result?.recargoManiobras).toBeGreaterThanOrEqual(3000); // Fixed surcharge for hazmat + }); + }); + + describe('cambiarEstado()', () => { + it('should transition from BORRADOR to CONFIRMADA', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.BORRADOR }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const result = await service.confirm(mockTenantId, mockOrderId, mockUserId); + + expect(result?.estado).toBe(EstadoOrdenTransporte.CONFIRMADA); + expect(mockRepository.save).toHaveBeenCalled(); + }); + + it('should transition from CONFIRMADA to ASIGNADA via assignToViaje', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const result = await service.assignToViaje( + mockTenantId, + mockOrderId, + mockViajeId, + mockUserId + ); + + expect(result?.estado).toBe(EstadoOrdenTransporte.ASIGNADA); + expect(result?.viajeId).toBe(mockViajeId); + }); + + it('should transition from ASIGNADA to EN_PROCESO', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.ASIGNADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const result = await service.startProcess(mockTenantId, mockOrderId, mockUserId); + + expect(result?.estado).toBe(EstadoOrdenTransporte.EN_PROCESO); + }); + + it('should transition from EN_PROCESO to EN_TRANSITO', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.EN_PROCESO }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const result = await service.startTransit(mockTenantId, mockOrderId, mockUserId); + + expect(result?.estado).toBe(EstadoOrdenTransporte.EN_TRANSITO); + }); + + it('should transition from EN_TRANSITO to ENTREGADA', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.EN_TRANSITO }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const result = await service.deliver(mockTenantId, mockOrderId, mockUserId); + + expect(result?.estado).toBe(EstadoOrdenTransporte.ENTREGADA); + }); + + it('should reject invalid state transition (BORRADOR to EN_TRANSITO)', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.BORRADOR }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + await expect( + service.startTransit(mockTenantId, mockOrderId, mockUserId) + ).rejects.toThrow(/Transicion de estado invalida/); + }); + + it('should reject transition from final state (CANCELADA)', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.CANCELADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + await expect( + service.confirm(mockTenantId, mockOrderId, mockUserId) + ).rejects.toThrow(/Transicion de estado invalida/); + }); + + it('should reject transition from final state (FACTURADA)', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.FACTURADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + await expect( + service.deliver(mockTenantId, mockOrderId, mockUserId) + ).rejects.toThrow(/Transicion de estado invalida/); + }); + }); + + describe('cancel()', () => { + it('should cancel order in BORRADOR state', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.BORRADOR }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const result = await service.cancel( + mockTenantId, + mockOrderId, + 'Cliente solicito cancelacion', + mockUserId + ); + + expect(result?.estado).toBe(EstadoOrdenTransporte.CANCELADA); + expect(result?.observaciones).toContain('CANCELADA'); + expect(result?.observaciones).toContain('Cliente solicito cancelacion'); + }); + + it('should cancel order in CONFIRMADA state', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const result = await service.cancel( + mockTenantId, + mockOrderId, + 'Error en datos', + mockUserId + ); + + expect(result?.estado).toBe(EstadoOrdenTransporte.CANCELADA); + }); + + it('should reject cancellation of order EN_TRANSITO', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.EN_TRANSITO }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + await expect( + service.cancel(mockTenantId, mockOrderId, 'Test', mockUserId) + ).rejects.toThrow(/No se puede cancelar una OT en estado EN_TRANSITO/); + }); + + it('should reject cancellation of already delivered order', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.ENTREGADA }); + mockRepository.findOne.mockResolvedValue(mockOrden); + + await expect( + service.cancel(mockTenantId, mockOrderId, 'Test', mockUserId) + ).rejects.toThrow(/No se puede cancelar una OT en estado ENTREGADA/); + }); + + it('should unassign viaje when cancelling assigned order', async () => { + const mockOrden = createMockOrden({ + estado: EstadoOrdenTransporte.ASIGNADA, + viajeId: mockViajeId, + }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const result = await service.cancel( + mockTenantId, + mockOrderId, + 'Cambio de planes', + mockUserId + ); + + expect(result?.estado).toBe(EstadoOrdenTransporte.CANCELADA); + expect(result?.viajeId).toBeUndefined(); + }); + }); + }); + + // =========================================================================== + // VALIDATION + // =========================================================================== + + describe('Validation', () => { + describe('Required fields on create', () => { + it('should require clienteId', async () => { + mockRepository.count.mockResolvedValue(0); + mockRepository.create.mockImplementation((dto: any) => dto as OrdenTransporte); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const dto = { ...mockCreateDto }; + delete (dto as any).clienteId; + + // The service creates the entity - in a real scenario TypeORM would validate + // Here we verify the field is passed through + await service.create(mockTenantId, dto as CreateOrdenTransporteDto, mockUserId); + + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + clienteId: undefined, + }) + ); + }); + + it('should require shipperId and shipperNombre', async () => { + mockRepository.count.mockResolvedValue(0); + mockRepository.create.mockImplementation((dto: any) => dto as OrdenTransporte); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + await service.create(mockTenantId, mockCreateDto, mockUserId); + + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + shipperId: mockCreateDto.shipperId, + shipperNombre: mockCreateDto.shipperNombre, + }) + ); + }); + + it('should require origen and destino direcciones', async () => { + mockRepository.count.mockResolvedValue(0); + mockRepository.create.mockImplementation((dto: any) => dto as OrdenTransporte); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + await service.create(mockTenantId, mockCreateDto, mockUserId); + + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + origenDireccion: mockCreateDto.origenDireccion, + destinoDireccion: mockCreateDto.destinoDireccion, + }) + ); + }); + }); + + describe('State transition validation', () => { + it('should validate all allowed transitions from BORRADOR', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.BORRADOR }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + // Valid transitions from BORRADOR + const validStates = [ + EstadoOrdenTransporte.PENDIENTE, + EstadoOrdenTransporte.SOLICITADA, + EstadoOrdenTransporte.CONFIRMADA, + EstadoOrdenTransporte.CANCELADA, + ]; + + for (const estado of validStates) { + mockRepository.findOne.mockResolvedValue(createMockOrden({ estado: EstadoOrdenTransporte.BORRADOR })); + const result = await service.cambiarEstado(mockTenantId, mockOrderId, estado, mockUserId); + expect(result?.estado).toBe(estado); + } + }); + }); + + describe('Tenant isolation', () => { + it('should always include tenantId in queries', async () => { + const mockOrden = createMockOrden(); + mockRepository.findOne.mockResolvedValue(mockOrden); + + await service.findById(mockTenantId, mockOrderId); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: expect.objectContaining({ + tenantId: mockTenantId, + }), + }); + }); + + it('should create orders with correct tenantId', async () => { + const mockOrden = createMockOrden(); + mockRepository.count.mockResolvedValue(0); + mockRepository.create.mockReturnValue(mockOrden); + mockRepository.save.mockResolvedValue(mockOrden); + + await service.create(mockTenantId, mockCreateDto, mockUserId); + + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: mockTenantId, + }) + ); + }); + }); + }); + + // =========================================================================== + // ADDITIONAL QUERY METHODS + // =========================================================================== + + describe('Additional Query Methods', () => { + describe('findByStatus()', () => { + it('should return orders by specific status', async () => { + const mockOrdenes = [ + createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA }), + createMockOrden({ id: 'order-2', estado: EstadoOrdenTransporte.CONFIRMADA }), + ]; + mockRepository.find.mockResolvedValue(mockOrdenes); + + const result = await service.findByStatus( + mockTenantId, + EstadoOrdenTransporte.CONFIRMADA, + 100 + ); + + expect(mockRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + estado: EstadoOrdenTransporte.CONFIRMADA, + }), + }) + ); + expect(result).toHaveLength(2); + }); + }); + + describe('findUnassigned()', () => { + it('should return orders without viaje assignment', async () => { + const mockOrdenes = [createMockOrden({ estado: EstadoOrdenTransporte.CONFIRMADA })]; + mockRepository.find.mockResolvedValue(mockOrdenes); + + const result = await service.findUnassigned(mockTenantId, 100); + + expect(mockRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + viajeId: expect.anything(), // IsNull() + }), + }) + ); + expect(result).toBeDefined(); + }); + }); + + describe('findByViaje()', () => { + it('should return orders assigned to a specific viaje', async () => { + const mockOrdenes = [ + createMockOrden({ viajeId: mockViajeId }), + createMockOrden({ id: 'order-2', viajeId: mockViajeId }), + ]; + mockRepository.find.mockResolvedValue(mockOrdenes); + + const result = await service.findByViaje(mockTenantId, mockViajeId); + + expect(mockRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + viajeId: mockViajeId, + }), + }) + ); + expect(result).toHaveLength(2); + }); + }); + + describe('getStatistics()', () => { + it('should return statistics for date range', async () => { + const mockOrdenes = [ + createMockOrden({ estado: EstadoOrdenTransporte.ENTREGADA, total: 30000 }), + createMockOrden({ + id: 'order-2', + estado: EstadoOrdenTransporte.CONFIRMADA, + total: 25000, + }), + ]; + mockRepository.find.mockResolvedValue(mockOrdenes); + + const result = await service.getStatistics(mockTenantId, { + from: new Date('2026-02-01'), + to: new Date('2026-02-28'), + }); + + expect(result).toBeDefined(); + expect(result.total).toBe(2); + expect(result.porEstado).toBeDefined(); + expect(result.porModalidad).toBeDefined(); + expect(result.totalIngresos).toBeGreaterThan(0); + }); + }); + }); + + // =========================================================================== + // BACKWARD COMPATIBILITY ALIASES + // =========================================================================== + + describe('Backward Compatibility Aliases', () => { + it('findOne() should call findById()', async () => { + const mockOrden = createMockOrden(); + mockRepository.findOne.mockResolvedValue(mockOrden); + + const result = await service.findOne(mockTenantId, mockOrderId); + + expect(result).toBeDefined(); + expect(result?.id).toBe(mockOrderId); + }); + + it('confirmar() should call confirm()', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.BORRADOR }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const result = await service.confirmar(mockTenantId, mockOrderId, mockUserId); + + expect(result?.estado).toBe(EstadoOrdenTransporte.CONFIRMADA); + }); + + it('cancelar() should call cancel()', async () => { + const mockOrden = createMockOrden({ estado: EstadoOrdenTransporte.BORRADOR }); + mockRepository.findOne.mockResolvedValue(mockOrden); + mockRepository.save.mockImplementation(async (orden: any) => orden as OrdenTransporte); + + const result = await service.cancelar( + mockTenantId, + mockOrderId, + 'Motivo test', + mockUserId + ); + + expect(result?.estado).toBe(EstadoOrdenTransporte.CANCELADA); + }); + }); +});