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>
527 lines
14 KiB
TypeScript
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]);
|
|
}
|
|
}
|
|
}
|
|
}
|