erp-transportistas-backend-v2/src/modules/carta-porte/services/ubicacion-carta-porte.service.ts
Adrian Flores Cortes 2134ff98e5 [SPRINT-5] feat: Implement carta-porte, tracking, GPS and fleet services
Carta Porte Module:
- mercancia.service.ts: Cargo management for CFDI Carta Porte 3.1
- ubicacion-carta-porte.service.ts: Origin/destination locations
- figura-transporte.service.ts: Transportation figures (operators, owners)
- inspeccion-pre-viaje.service.ts: Pre-trip inspections per NOM-087

Gestion Flota Module:
- documento-flota.service.ts: Fleet document management with expiration alerts
- asignacion.service.ts: Unit-operator assignments with availability check

Tracking Module:
- evento-tracking.service.ts: Real-time tracking events and ETA calculation

GPS Module:
- evento-geocerca.service.ts: Geofence events (entry/exit/dwell)

Also includes backward compatibility fixes for ordenes-transporte module.

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

527 lines
14 KiB
TypeScript

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<CreateUbicacionDto> {}
/**
* 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<UbicacionCartaPorte>;
private cartaPorteRepository: Repository<CartaPorte>;
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<CartaPorte> {
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<number> {
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<UbicacionCartaPorte> {
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<UbicacionCartaPorte[]> {
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<UbicacionCartaPorte | null> {
return this.ubicacionRepository.findOne({
where: { id, tenantId },
});
}
/**
* Actualiza una ubicacion existente
*/
async update(
tenantId: string,
id: string,
data: UpdateUbicacionDto
): Promise<UbicacionCartaPorte | null> {
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<boolean> {
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<UbicacionCartaPorte[]> {
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<UbicacionCartaPorte>[] = [];
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<ValidacionSecuencia> {
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<UbicacionCartaPorte | null> {
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<UbicacionCartaPorte | null> {
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<number> {
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<void> {
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<void> {
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<void> {
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]);
}
}
}
}