import { Repository, DataSource } from 'typeorm'; import { UbicacionCartaPorte, TipoUbicacionCartaPorte } from '../entities'; import { CartaPorte, EstadoCartaPorte } from '../entities/carta-porte.entity'; /** * DTO para crear ubicacion */ export interface CreateUbicacionDto { tipoUbicacion: string; idUbicacion?: string; rfcRemitenteDestinatario?: string; nombreRemitenteDestinatario?: string; pais?: string; estado?: string; municipio?: string; localidad?: string; codigoPostal: string; colonia?: string; calle?: string; numeroExterior?: string; numeroInterior?: string; referencia?: string; fechaHoraSalidaLlegada?: Date; distanciaRecorrida?: number; secuencia?: number; } /** * DTO para actualizar ubicacion */ export interface UpdateUbicacionDto extends Partial {} /** * Resultado de validacion de secuencia */ export interface ValidacionSecuencia { valid: boolean; tieneOrigen: boolean; tieneDestino: boolean; secuenciaCorrecta: boolean; distanciaTotalKm: number; errors: string[]; } /** * Servicio para gestion de ubicaciones de Carta Porte * CFDI 3.1 Compliance */ export class UbicacionCartaPorteService { private ubicacionRepository: Repository; private cartaPorteRepository: Repository; constructor(private readonly dataSource: DataSource) { this.ubicacionRepository = dataSource.getRepository(UbicacionCartaPorte); this.cartaPorteRepository = dataSource.getRepository(CartaPorte); } /** * Verifica que la Carta Porte exista y pertenezca al tenant */ private async getCartaPorteOrFail( cartaPorteId: string, tenantId: string ): Promise { const cartaPorte = await this.cartaPorteRepository.findOne({ where: { id: cartaPorteId, tenantId }, }); if (!cartaPorte) { throw new Error('Carta Porte no encontrada'); } return cartaPorte; } /** * Verifica que la Carta Porte este en estado editable */ private assertEditable(cartaPorte: CartaPorte): void { if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) { throw new Error( `No se pueden modificar ubicaciones en estado ${cartaPorte.estado}` ); } } /** * Obtiene el siguiente numero de secuencia */ private async getNextSecuencia(cartaPorteId: string): Promise { const result = await this.ubicacionRepository .createQueryBuilder('u') .select('COALESCE(MAX(u.secuencia), 0)', 'maxSecuencia') .where('u.cartaPorteId = :cartaPorteId', { cartaPorteId }) .getRawOne(); return (result?.maxSecuencia || 0) + 1; } /** * Crea una nueva ubicacion para una Carta Porte */ async create( tenantId: string, cartaPorteId: string, data: CreateUbicacionDto ): Promise { const cartaPorte = await this.getCartaPorteOrFail(cartaPorteId, tenantId); this.assertEditable(cartaPorte); // Validar datos requeridos por SAT this.validateUbicacionData(data); // Validar reglas de negocio await this.validateBusinessRules(cartaPorteId, data); const secuencia = data.secuencia ?? (await this.getNextSecuencia(cartaPorteId)); const ubicacion = this.ubicacionRepository.create({ tenantId, cartaPorteId, tipoUbicacion: data.tipoUbicacion, idUbicacion: data.idUbicacion || null, rfcRemitenteDestinatario: data.rfcRemitenteDestinatario || null, nombreRemitenteDestinatario: data.nombreRemitenteDestinatario || null, pais: data.pais || 'MEX', estado: data.estado || null, municipio: data.municipio || null, localidad: data.localidad || null, codigoPostal: data.codigoPostal, colonia: data.colonia || null, calle: data.calle || null, numeroExterior: data.numeroExterior || null, numeroInterior: data.numeroInterior || null, referencia: data.referencia || null, fechaHoraSalidaLlegada: data.fechaHoraSalidaLlegada || null, distanciaRecorrida: data.distanciaRecorrida || null, secuencia, }); return this.ubicacionRepository.save(ubicacion); } /** * Obtiene todas las ubicaciones de una Carta Porte ordenadas por secuencia */ async findByCartaParte( tenantId: string, cartaPorteId: string ): Promise { await this.getCartaPorteOrFail(cartaPorteId, tenantId); return this.ubicacionRepository.find({ where: { cartaPorteId, tenantId }, order: { secuencia: 'ASC' }, }); } /** * Obtiene una ubicacion por ID */ async findById( tenantId: string, id: string ): Promise { return this.ubicacionRepository.findOne({ where: { id, tenantId }, }); } /** * Actualiza una ubicacion existente */ async update( tenantId: string, id: string, data: UpdateUbicacionDto ): Promise { const ubicacion = await this.findById(tenantId, id); if (!ubicacion) return null; const cartaPorte = await this.getCartaPorteOrFail( ubicacion.cartaPorteId, tenantId ); this.assertEditable(cartaPorte); // Validar datos si se actualizan campos requeridos if (data.tipoUbicacion || data.codigoPostal) { this.validateUbicacionData({ tipoUbicacion: data.tipoUbicacion ?? ubicacion.tipoUbicacion, codigoPostal: data.codigoPostal ?? ubicacion.codigoPostal, } as CreateUbicacionDto); } Object.assign(ubicacion, data); return this.ubicacionRepository.save(ubicacion); } /** * Elimina una ubicacion */ async delete(tenantId: string, id: string): Promise { const ubicacion = await this.findById(tenantId, id); if (!ubicacion) return false; const cartaPorte = await this.getCartaPorteOrFail( ubicacion.cartaPorteId, tenantId ); this.assertEditable(cartaPorte); const cartaPorteId = ubicacion.cartaPorteId; const result = await this.ubicacionRepository.delete(id); if ((result.affected ?? 0) > 0) { // Reordenar secuencias await this.reorderSecuencias(cartaPorteId); return true; } return false; } /** * Reordena las ubicaciones segun un nuevo orden */ async reorder( tenantId: string, cartaPorteId: string, newOrder: string[] ): Promise { const cartaPorte = await this.getCartaPorteOrFail(cartaPorteId, tenantId); this.assertEditable(cartaPorte); const ubicaciones = await this.findByCartaParte(tenantId, cartaPorteId); // Validar que todos los IDs existan const existingIds = new Set(ubicaciones.map(u => u.id)); for (const id of newOrder) { if (!existingIds.has(id)) { throw new Error(`Ubicacion con ID ${id} no encontrada`); } } // Validar que todos los IDs esten presentes if (newOrder.length !== ubicaciones.length) { throw new Error('El nuevo orden debe contener todas las ubicaciones'); } // Actualizar secuencias const updates: Promise[] = []; for (let i = 0; i < newOrder.length; i++) { const ubicacion = ubicaciones.find(u => u.id === newOrder[i]); if (ubicacion && ubicacion.secuencia !== i + 1) { ubicacion.secuencia = i + 1; updates.push(this.ubicacionRepository.save(ubicacion)); } } await Promise.all(updates); // Validar que el nuevo orden tenga sentido (origen primero, destino ultimo) const reordered = await this.findByCartaParte(tenantId, cartaPorteId); await this.validateSecuenciaAfterReorder(reordered); return reordered; } /** * Valida la integridad de la secuencia de ubicaciones */ async validateSecuencia( tenantId: string, cartaPorteId: string ): Promise { await this.getCartaPorteOrFail(cartaPorteId, tenantId); const ubicaciones = await this.findByCartaParte(tenantId, cartaPorteId); const errors: string[] = []; if (ubicaciones.length === 0) { return { valid: false, tieneOrigen: false, tieneDestino: false, secuenciaCorrecta: false, distanciaTotalKm: 0, errors: ['No hay ubicaciones registradas'], }; } // Verificar origen y destino const tieneOrigen = ubicaciones.some( u => u.tipoUbicacion === TipoUbicacionCartaPorte.ORIGEN ); const tieneDestino = ubicaciones.some( u => u.tipoUbicacion === TipoUbicacionCartaPorte.DESTINO ); if (!tieneOrigen) { errors.push('Se requiere al menos una ubicacion de origen'); } if (!tieneDestino) { errors.push('Se requiere al menos una ubicacion de destino'); } // Verificar que origen sea primero y destino sea ultimo const primera = ubicaciones[0]; const ultima = ubicaciones[ubicaciones.length - 1]; let secuenciaCorrecta = true; if (primera.tipoUbicacion !== TipoUbicacionCartaPorte.ORIGEN) { errors.push('La primera ubicacion debe ser de tipo Origen'); secuenciaCorrecta = false; } if (ultima.tipoUbicacion !== TipoUbicacionCartaPorte.DESTINO) { errors.push('La ultima ubicacion debe ser de tipo Destino'); secuenciaCorrecta = false; } // Verificar secuencia de numeros for (let i = 0; i < ubicaciones.length; i++) { if (ubicaciones[i].secuencia !== i + 1) { errors.push(`Secuencia incorrecta en ubicacion ${i + 1}`); secuenciaCorrecta = false; } } // Verificar fechas en orden cronologico for (let i = 1; i < ubicaciones.length; i++) { const fechaAnterior = ubicaciones[i - 1].fechaHoraSalidaLlegada; const fechaActual = ubicaciones[i].fechaHoraSalidaLlegada; if (fechaAnterior && fechaActual && fechaActual < fechaAnterior) { errors.push( `Fecha de ubicacion ${i + 1} es anterior a ubicacion ${i}` ); secuenciaCorrecta = false; } } // Calcular distancia total const distanciaTotalKm = ubicaciones.reduce( (sum, u) => sum + (u.distanciaRecorrida ? Number(u.distanciaRecorrida) : 0), 0 ); // Validar que haya al menos 2 ubicaciones (SAT requirement) if (ubicaciones.length < 2) { errors.push('Se requieren al menos 2 ubicaciones (origen y destino)'); } return { valid: errors.length === 0, tieneOrigen, tieneDestino, secuenciaCorrecta, distanciaTotalKm, errors, }; } /** * Obtiene la ubicacion de origen */ async getOrigen( tenantId: string, cartaPorteId: string ): Promise { await this.getCartaPorteOrFail(cartaPorteId, tenantId); return this.ubicacionRepository.findOne({ where: { cartaPorteId, tenantId, tipoUbicacion: TipoUbicacionCartaPorte.ORIGEN, }, order: { secuencia: 'ASC' }, }); } /** * Obtiene la ubicacion de destino final */ async getDestinoFinal( tenantId: string, cartaPorteId: string ): Promise { await this.getCartaPorteOrFail(cartaPorteId, tenantId); return this.ubicacionRepository.findOne({ where: { cartaPorteId, tenantId, tipoUbicacion: TipoUbicacionCartaPorte.DESTINO, }, order: { secuencia: 'DESC' }, }); } /** * Obtiene la distancia total del recorrido */ async getDistanciaTotal( tenantId: string, cartaPorteId: string ): Promise { await this.getCartaPorteOrFail(cartaPorteId, tenantId); const result = await this.ubicacionRepository .createQueryBuilder('u') .select('COALESCE(SUM(u.distanciaRecorrida), 0)', 'total') .where('u.cartaPorteId = :cartaPorteId', { cartaPorteId }) .andWhere('u.tenantId = :tenantId', { tenantId }) .getRawOne(); return Number(result?.total || 0); } /** * Valida datos de ubicacion segun requerimientos SAT */ private validateUbicacionData(data: CreateUbicacionDto): void { const errors: string[] = []; if (!data.tipoUbicacion) { errors.push('tipoUbicacion es requerido'); } if ( data.tipoUbicacion && !Object.values(TipoUbicacionCartaPorte).includes( data.tipoUbicacion as TipoUbicacionCartaPorte ) ) { errors.push( `tipoUbicacion debe ser: ${Object.values(TipoUbicacionCartaPorte).join(', ')}` ); } if (!data.codigoPostal) { errors.push('codigoPostal es requerido'); } if (data.codigoPostal && !/^\d{5}$/.test(data.codigoPostal)) { errors.push('codigoPostal debe tener 5 digitos'); } if (errors.length > 0) { throw new Error(`Datos de ubicacion invalidos: ${errors.join(', ')}`); } } /** * Valida reglas de negocio para ubicaciones */ private async validateBusinessRules( cartaPorteId: string, data: CreateUbicacionDto ): Promise { const existingUbicaciones = await this.ubicacionRepository.find({ where: { cartaPorteId }, order: { secuencia: 'ASC' }, }); // Si es la primera ubicacion, debe ser origen if ( existingUbicaciones.length === 0 && data.tipoUbicacion !== TipoUbicacionCartaPorte.ORIGEN ) { throw new Error('La primera ubicacion debe ser de tipo Origen'); } } /** * Valida secuencia despues de reordenar */ private async validateSecuenciaAfterReorder( ubicaciones: UbicacionCartaPorte[] ): Promise { if (ubicaciones.length < 2) return; const primera = ubicaciones[0]; const ultima = ubicaciones[ubicaciones.length - 1]; const warnings: string[] = []; if (primera.tipoUbicacion !== TipoUbicacionCartaPorte.ORIGEN) { warnings.push( 'Advertencia: La primera ubicacion no es de tipo Origen' ); } if (ultima.tipoUbicacion !== TipoUbicacionCartaPorte.DESTINO) { warnings.push( 'Advertencia: La ultima ubicacion no es de tipo Destino' ); } // Log warnings but don't throw - let user decide if (warnings.length > 0) { console.warn('Ubicaciones reordenadas con advertencias:', warnings); } } /** * Reordena las secuencias despues de eliminar */ private async reorderSecuencias(cartaPorteId: string): Promise { const ubicaciones = await this.ubicacionRepository.find({ where: { cartaPorteId }, order: { secuencia: 'ASC' }, }); for (let i = 0; i < ubicaciones.length; i++) { if (ubicaciones[i].secuencia !== i + 1) { ubicaciones[i].secuencia = i + 1; await this.ubicacionRepository.save(ubicaciones[i]); } } } }