[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>
This commit is contained in:
parent
48bb0c8d58
commit
2134ff98e5
461
src/modules/carta-porte/services/figura-transporte.service.ts
Normal file
461
src/modules/carta-porte/services/figura-transporte.service.ts
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { FiguraTransporte, TipoFigura, ParteTransporte } from '../entities';
|
||||||
|
import { CartaPorte, EstadoCartaPorte } from '../entities/carta-porte.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear figura de transporte
|
||||||
|
*/
|
||||||
|
export interface CreateFiguraDto {
|
||||||
|
tipoFigura: string;
|
||||||
|
rfcFigura?: string;
|
||||||
|
nombreFigura?: string;
|
||||||
|
numLicencia?: string;
|
||||||
|
pais?: string;
|
||||||
|
estado?: string;
|
||||||
|
codigoPostal?: string;
|
||||||
|
calle?: string;
|
||||||
|
partesTransporte?: ParteTransporte[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar figura de transporte
|
||||||
|
*/
|
||||||
|
export interface UpdateFiguraDto extends Partial<CreateFiguraDto> {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado de validacion de figuras requeridas
|
||||||
|
*/
|
||||||
|
export interface ValidacionFigurasRequeridas {
|
||||||
|
valid: boolean;
|
||||||
|
tieneOperador: boolean;
|
||||||
|
tienePropietario: boolean;
|
||||||
|
operadores: FiguraTransporte[];
|
||||||
|
totalFiguras: number;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio para gestion de figuras de transporte de Carta Porte
|
||||||
|
* CFDI 3.1 Compliance
|
||||||
|
*/
|
||||||
|
export class FiguraTransporteService {
|
||||||
|
private figuraRepository: Repository<FiguraTransporte>;
|
||||||
|
private cartaPorteRepository: Repository<CartaPorte>;
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {
|
||||||
|
this.figuraRepository = dataSource.getRepository(FiguraTransporte);
|
||||||
|
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 figuras de transporte en estado ${cartaPorte.estado}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una nueva figura de transporte
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string,
|
||||||
|
data: CreateFiguraDto
|
||||||
|
): Promise<FiguraTransporte> {
|
||||||
|
const cartaPorte = await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
this.assertEditable(cartaPorte);
|
||||||
|
|
||||||
|
// Validar datos segun tipo de figura
|
||||||
|
this.validateFiguraData(data);
|
||||||
|
|
||||||
|
const figura = this.figuraRepository.create({
|
||||||
|
tenantId,
|
||||||
|
cartaPorteId,
|
||||||
|
tipoFigura: data.tipoFigura,
|
||||||
|
rfcFigura: data.rfcFigura || null,
|
||||||
|
nombreFigura: data.nombreFigura || null,
|
||||||
|
numLicencia: data.numLicencia || null,
|
||||||
|
pais: data.pais || null,
|
||||||
|
estado: data.estado || null,
|
||||||
|
codigoPostal: data.codigoPostal || null,
|
||||||
|
calle: data.calle || null,
|
||||||
|
partesTransporte: data.partesTransporte || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.figuraRepository.save(figura);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todas las figuras de una Carta Porte
|
||||||
|
*/
|
||||||
|
async findByCartaParte(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string
|
||||||
|
): Promise<FiguraTransporte[]> {
|
||||||
|
await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
|
||||||
|
return this.figuraRepository.find({
|
||||||
|
where: { cartaPorteId, tenantId },
|
||||||
|
order: { tipoFigura: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una figura por ID
|
||||||
|
*/
|
||||||
|
async findById(
|
||||||
|
tenantId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<FiguraTransporte | null> {
|
||||||
|
return this.figuraRepository.findOne({
|
||||||
|
where: { id, tenantId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza una figura existente
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
data: UpdateFiguraDto
|
||||||
|
): Promise<FiguraTransporte | null> {
|
||||||
|
const figura = await this.findById(tenantId, id);
|
||||||
|
if (!figura) return null;
|
||||||
|
|
||||||
|
const cartaPorte = await this.getCartaPorteOrFail(
|
||||||
|
figura.cartaPorteId,
|
||||||
|
tenantId
|
||||||
|
);
|
||||||
|
this.assertEditable(cartaPorte);
|
||||||
|
|
||||||
|
// Validar datos si se actualiza tipo de figura
|
||||||
|
if (data.tipoFigura) {
|
||||||
|
this.validateFiguraData({
|
||||||
|
tipoFigura: data.tipoFigura,
|
||||||
|
rfcFigura: data.rfcFigura ?? figura.rfcFigura ?? undefined,
|
||||||
|
nombreFigura: data.nombreFigura ?? figura.nombreFigura ?? undefined,
|
||||||
|
numLicencia: data.numLicencia ?? figura.numLicencia ?? undefined,
|
||||||
|
} as CreateFiguraDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(figura, data);
|
||||||
|
return this.figuraRepository.save(figura);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una figura de transporte
|
||||||
|
*/
|
||||||
|
async delete(tenantId: string, id: string): Promise<boolean> {
|
||||||
|
const figura = await this.findById(tenantId, id);
|
||||||
|
if (!figura) return false;
|
||||||
|
|
||||||
|
const cartaPorte = await this.getCartaPorteOrFail(
|
||||||
|
figura.cartaPorteId,
|
||||||
|
tenantId
|
||||||
|
);
|
||||||
|
this.assertEditable(cartaPorte);
|
||||||
|
|
||||||
|
const result = await this.figuraRepository.delete(id);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida que las figuras requeridas esten presentes
|
||||||
|
*/
|
||||||
|
async validateFigurasRequeridas(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string
|
||||||
|
): Promise<ValidacionFigurasRequeridas> {
|
||||||
|
await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
const figuras = await this.findByCartaParte(tenantId, cartaPorteId);
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
const operadores = figuras.filter(f => f.tipoFigura === TipoFigura.OPERADOR);
|
||||||
|
const propietarios = figuras.filter(f => f.tipoFigura === TipoFigura.PROPIETARIO);
|
||||||
|
const arrendadores = figuras.filter(f => f.tipoFigura === TipoFigura.ARRENDADOR);
|
||||||
|
|
||||||
|
const tieneOperador = operadores.length > 0;
|
||||||
|
const tienePropietario = propietarios.length > 0;
|
||||||
|
|
||||||
|
// Validacion SAT: Se requiere al menos un operador
|
||||||
|
if (!tieneOperador) {
|
||||||
|
errors.push('Se requiere al menos un operador (figura tipo 01)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar datos de operadores
|
||||||
|
for (const op of operadores) {
|
||||||
|
if (!op.rfcFigura) {
|
||||||
|
errors.push(`Operador "${op.nombreFigura || 'sin nombre'}" requiere RFC`);
|
||||||
|
}
|
||||||
|
if (!op.numLicencia) {
|
||||||
|
errors.push(`Operador "${op.nombreFigura || 'sin nombre'}" requiere numero de licencia`);
|
||||||
|
}
|
||||||
|
if (!op.nombreFigura) {
|
||||||
|
errors.push(`Operador con RFC ${op.rfcFigura || 'sin RFC'} requiere nombre`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar datos de propietarios
|
||||||
|
for (const prop of propietarios) {
|
||||||
|
if (!prop.rfcFigura) {
|
||||||
|
errors.push(`Propietario "${prop.nombreFigura || 'sin nombre'}" requiere RFC`);
|
||||||
|
}
|
||||||
|
if (!prop.nombreFigura) {
|
||||||
|
errors.push(`Propietario con RFC ${prop.rfcFigura || 'sin RFC'} requiere nombre`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar datos de arrendadores
|
||||||
|
for (const arr of arrendadores) {
|
||||||
|
if (!arr.rfcFigura) {
|
||||||
|
errors.push(`Arrendador "${arr.nombreFigura || 'sin nombre'}" requiere RFC`);
|
||||||
|
}
|
||||||
|
if (!arr.nombreFigura) {
|
||||||
|
errors.push(`Arrendador con RFC ${arr.rfcFigura || 'sin RFC'} requiere nombre`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warnings opcionales
|
||||||
|
if (operadores.length > 2) {
|
||||||
|
warnings.push('Se registraron mas de 2 operadores');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tienePropietario && arrendadores.length === 0) {
|
||||||
|
warnings.push('No se registro propietario ni arrendador de la mercancia');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
tieneOperador,
|
||||||
|
tienePropietario,
|
||||||
|
operadores,
|
||||||
|
totalFiguras: figuras.length,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene solo los operadores de una Carta Porte
|
||||||
|
*/
|
||||||
|
async getOperadores(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string
|
||||||
|
): Promise<FiguraTransporte[]> {
|
||||||
|
await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
|
||||||
|
return this.figuraRepository.find({
|
||||||
|
where: {
|
||||||
|
cartaPorteId,
|
||||||
|
tenantId,
|
||||||
|
tipoFigura: TipoFigura.OPERADOR,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene propietarios de mercancia
|
||||||
|
*/
|
||||||
|
async getPropietarios(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string
|
||||||
|
): Promise<FiguraTransporte[]> {
|
||||||
|
await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
|
||||||
|
return this.figuraRepository.find({
|
||||||
|
where: {
|
||||||
|
cartaPorteId,
|
||||||
|
tenantId,
|
||||||
|
tipoFigura: TipoFigura.PROPIETARIO,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene arrendadores
|
||||||
|
*/
|
||||||
|
async getArrendadores(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string
|
||||||
|
): Promise<FiguraTransporte[]> {
|
||||||
|
await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
|
||||||
|
return this.figuraRepository.find({
|
||||||
|
where: {
|
||||||
|
cartaPorteId,
|
||||||
|
tenantId,
|
||||||
|
tipoFigura: TipoFigura.ARRENDADOR,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega un operador con datos minimos requeridos
|
||||||
|
*/
|
||||||
|
async addOperador(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string,
|
||||||
|
data: {
|
||||||
|
rfcFigura: string;
|
||||||
|
nombreFigura: string;
|
||||||
|
numLicencia: string;
|
||||||
|
pais?: string;
|
||||||
|
estado?: string;
|
||||||
|
codigoPostal?: string;
|
||||||
|
}
|
||||||
|
): Promise<FiguraTransporte> {
|
||||||
|
return this.create(tenantId, cartaPorteId, {
|
||||||
|
tipoFigura: TipoFigura.OPERADOR,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega un propietario de mercancia
|
||||||
|
*/
|
||||||
|
async addPropietario(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string,
|
||||||
|
data: {
|
||||||
|
rfcFigura: string;
|
||||||
|
nombreFigura: string;
|
||||||
|
pais?: string;
|
||||||
|
estado?: string;
|
||||||
|
codigoPostal?: string;
|
||||||
|
calle?: string;
|
||||||
|
partesTransporte?: ParteTransporte[];
|
||||||
|
}
|
||||||
|
): Promise<FiguraTransporte> {
|
||||||
|
return this.create(tenantId, cartaPorteId, {
|
||||||
|
tipoFigura: TipoFigura.PROPIETARIO,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega un arrendador
|
||||||
|
*/
|
||||||
|
async addArrendador(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string,
|
||||||
|
data: {
|
||||||
|
rfcFigura: string;
|
||||||
|
nombreFigura: string;
|
||||||
|
pais?: string;
|
||||||
|
estado?: string;
|
||||||
|
codigoPostal?: string;
|
||||||
|
calle?: string;
|
||||||
|
partesTransporte?: ParteTransporte[];
|
||||||
|
}
|
||||||
|
): Promise<FiguraTransporte> {
|
||||||
|
return this.create(tenantId, cartaPorteId, {
|
||||||
|
tipoFigura: TipoFigura.ARRENDADOR,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene resumen de figuras por tipo
|
||||||
|
*/
|
||||||
|
async getResumenPorTipo(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string
|
||||||
|
): Promise<{
|
||||||
|
operadores: number;
|
||||||
|
propietarios: number;
|
||||||
|
arrendadores: number;
|
||||||
|
notificados: number;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
|
||||||
|
const figuras = await this.findByCartaParte(tenantId, cartaPorteId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
operadores: figuras.filter(f => f.tipoFigura === TipoFigura.OPERADOR).length,
|
||||||
|
propietarios: figuras.filter(f => f.tipoFigura === TipoFigura.PROPIETARIO).length,
|
||||||
|
arrendadores: figuras.filter(f => f.tipoFigura === TipoFigura.ARRENDADOR).length,
|
||||||
|
notificados: figuras.filter(f => f.tipoFigura === TipoFigura.NOTIFICADO).length,
|
||||||
|
total: figuras.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida datos de figura segun tipo
|
||||||
|
*/
|
||||||
|
private validateFiguraData(data: CreateFiguraDto): void {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!data.tipoFigura) {
|
||||||
|
errors.push('tipoFigura es requerido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiposValidos = Object.values(TipoFigura);
|
||||||
|
if (data.tipoFigura && !tiposValidos.includes(data.tipoFigura as TipoFigura)) {
|
||||||
|
errors.push(`tipoFigura debe ser uno de: ${tiposValidos.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validaciones especificas por tipo
|
||||||
|
if (data.tipoFigura === TipoFigura.OPERADOR) {
|
||||||
|
if (!data.rfcFigura) {
|
||||||
|
errors.push('RFC es requerido para operador');
|
||||||
|
}
|
||||||
|
if (!data.numLicencia) {
|
||||||
|
errors.push('Numero de licencia es requerido para operador');
|
||||||
|
}
|
||||||
|
if (!data.nombreFigura) {
|
||||||
|
errors.push('Nombre es requerido para operador');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.tipoFigura === TipoFigura.PROPIETARIO ||
|
||||||
|
data.tipoFigura === TipoFigura.ARRENDADOR
|
||||||
|
) {
|
||||||
|
if (!data.rfcFigura) {
|
||||||
|
errors.push(`RFC es requerido para ${data.tipoFigura === TipoFigura.PROPIETARIO ? 'propietario' : 'arrendador'}`);
|
||||||
|
}
|
||||||
|
if (!data.nombreFigura) {
|
||||||
|
errors.push(`Nombre es requerido para ${data.tipoFigura === TipoFigura.PROPIETARIO ? 'propietario' : 'arrendador'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar formato RFC (opcional pero si se proporciona debe ser valido)
|
||||||
|
if (data.rfcFigura) {
|
||||||
|
const rfcRegex = /^[A-Z&]{3,4}\d{6}[A-Z0-9]{3}$/;
|
||||||
|
if (!rfcRegex.test(data.rfcFigura.toUpperCase())) {
|
||||||
|
errors.push('Formato de RFC invalido');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Datos de figura invalidos: ${errors.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,8 +2,22 @@
|
|||||||
* Carta Porte Services
|
* Carta Porte Services
|
||||||
* CFDI con Complemento Carta Porte 3.1
|
* CFDI con Complemento Carta Porte 3.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Main Carta Porte Service
|
||||||
export * from './carta-porte.service';
|
export * from './carta-porte.service';
|
||||||
|
|
||||||
|
// Mercancia (Cargo/Merchandise) Service
|
||||||
|
export * from './mercancia.service';
|
||||||
|
|
||||||
|
// Ubicacion (Locations) Service
|
||||||
|
export * from './ubicacion-carta-porte.service';
|
||||||
|
|
||||||
|
// Figura Transporte (Transportation Figures) Service
|
||||||
|
export * from './figura-transporte.service';
|
||||||
|
|
||||||
|
// Inspeccion Pre-Viaje (Pre-trip Inspection) Service
|
||||||
|
export * from './inspeccion-pre-viaje.service';
|
||||||
|
|
||||||
// TODO: Implement additional services
|
// TODO: Implement additional services
|
||||||
// - carta-porte-validator.service.ts (validacion SAT detallada)
|
// - carta-porte-validator.service.ts (validacion SAT detallada)
|
||||||
// - pac-integration.service.ts (integracion con PAC)
|
// - pac-integration.service.ts (integracion con PAC)
|
||||||
|
|||||||
614
src/modules/carta-porte/services/inspeccion-pre-viaje.service.ts
Normal file
614
src/modules/carta-porte/services/inspeccion-pre-viaje.service.ts
Normal file
@ -0,0 +1,614 @@
|
|||||||
|
import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
|
||||||
|
import { InspeccionPreViaje, ChecklistItem, FotoInspeccion } from '../entities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear inspeccion pre-viaje
|
||||||
|
*/
|
||||||
|
export interface CreateInspeccionDto {
|
||||||
|
viajeId: string;
|
||||||
|
remolqueId?: string;
|
||||||
|
fechaInspeccion?: Date;
|
||||||
|
checklistItems?: ChecklistItem[];
|
||||||
|
fotos?: FotoInspeccion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar inspeccion
|
||||||
|
*/
|
||||||
|
export interface UpdateInspeccionDto {
|
||||||
|
checklistItems?: ChecklistItem[];
|
||||||
|
fotos?: FotoInspeccion[];
|
||||||
|
firmaOperador?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rango de fechas para busquedas
|
||||||
|
*/
|
||||||
|
export interface DateRange {
|
||||||
|
desde: Date;
|
||||||
|
hasta: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumen de defectos
|
||||||
|
*/
|
||||||
|
export interface ResumenDefectos {
|
||||||
|
totalItems: number;
|
||||||
|
itemsOk: number;
|
||||||
|
defectosMenores: number;
|
||||||
|
defectosCriticos: number;
|
||||||
|
noAplica: number;
|
||||||
|
porcentajeAprobacion: number;
|
||||||
|
defectosDetallados: {
|
||||||
|
item: string;
|
||||||
|
categoria: string;
|
||||||
|
estado: string;
|
||||||
|
observacion?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado de verificacion de despacho
|
||||||
|
*/
|
||||||
|
export interface ResultadoDespacho {
|
||||||
|
puedeDespachar: boolean;
|
||||||
|
unidadId: string;
|
||||||
|
ultimaInspeccion: InspeccionPreViaje | null;
|
||||||
|
horasDesdeUltimaInspeccion: number | null;
|
||||||
|
defectosCriticosPendientes: number;
|
||||||
|
requiereNuevaInspeccion: boolean;
|
||||||
|
motivos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorias de checklist predefinidas
|
||||||
|
*/
|
||||||
|
export const CATEGORIAS_CHECKLIST = {
|
||||||
|
MOTOR: 'Motor',
|
||||||
|
FRENOS: 'Frenos',
|
||||||
|
LLANTAS: 'Llantas',
|
||||||
|
LUCES: 'Luces',
|
||||||
|
SUSPENSION: 'Suspension',
|
||||||
|
CABINA: 'Cabina',
|
||||||
|
CARROCERIA: 'Carroceria',
|
||||||
|
DOCUMENTOS: 'Documentos',
|
||||||
|
SEGURIDAD: 'Seguridad',
|
||||||
|
REMOLQUE: 'Remolque',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio para gestion de inspecciones pre-viaje
|
||||||
|
* NOM-087-SCT-2-2017 Compliance
|
||||||
|
*/
|
||||||
|
export class InspeccionPreViajeService {
|
||||||
|
private inspeccionRepository: Repository<InspeccionPreViaje>;
|
||||||
|
|
||||||
|
// Horas maximas desde ultima inspeccion para permitir despacho
|
||||||
|
private readonly HORAS_VALIDEZ_INSPECCION = 24;
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {
|
||||||
|
this.inspeccionRepository = dataSource.getRepository(InspeccionPreViaje);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una nueva inspeccion pre-viaje
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string,
|
||||||
|
operadorId: string,
|
||||||
|
data: CreateInspeccionDto
|
||||||
|
): Promise<InspeccionPreViaje> {
|
||||||
|
// Validar que no exista inspeccion en progreso para esta unidad
|
||||||
|
const inspeccionEnProgreso = await this.findInspeccionEnProgreso(
|
||||||
|
tenantId,
|
||||||
|
unidadId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inspeccionEnProgreso) {
|
||||||
|
throw new Error(
|
||||||
|
`Ya existe una inspeccion en progreso para la unidad. ID: ${inspeccionEnProgreso.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checklistItems = data.checklistItems || this.getChecklistDefault();
|
||||||
|
|
||||||
|
const inspeccion = this.inspeccionRepository.create({
|
||||||
|
tenantId,
|
||||||
|
viajeId: data.viajeId,
|
||||||
|
unidadId,
|
||||||
|
remolqueId: data.remolqueId || null,
|
||||||
|
operadorId,
|
||||||
|
fechaInspeccion: data.fechaInspeccion || new Date(),
|
||||||
|
aprobada: false,
|
||||||
|
checklistItems,
|
||||||
|
defectosEncontrados: null,
|
||||||
|
defectosCriticos: 0,
|
||||||
|
defectosMenores: 0,
|
||||||
|
firmaOperador: null,
|
||||||
|
firmaFecha: null,
|
||||||
|
fotos: data.fotos || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.inspeccionRepository.save(inspeccion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una inspeccion por ID
|
||||||
|
*/
|
||||||
|
async findById(
|
||||||
|
tenantId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<InspeccionPreViaje | null> {
|
||||||
|
return this.inspeccionRepository.findOne({
|
||||||
|
where: { id, tenantId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene inspecciones por unidad en un rango de fechas
|
||||||
|
*/
|
||||||
|
async findByUnidad(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string,
|
||||||
|
dateRange?: DateRange
|
||||||
|
): Promise<InspeccionPreViaje[]> {
|
||||||
|
const where: any = { tenantId, unidadId };
|
||||||
|
|
||||||
|
if (dateRange) {
|
||||||
|
where.fechaInspeccion = Between(dateRange.desde, dateRange.hasta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.inspeccionRepository.find({
|
||||||
|
where,
|
||||||
|
order: { fechaInspeccion: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene inspecciones por operador en un rango de fechas
|
||||||
|
*/
|
||||||
|
async findByOperador(
|
||||||
|
tenantId: string,
|
||||||
|
operadorId: string,
|
||||||
|
dateRange?: DateRange
|
||||||
|
): Promise<InspeccionPreViaje[]> {
|
||||||
|
const where: any = { tenantId, operadorId };
|
||||||
|
|
||||||
|
if (dateRange) {
|
||||||
|
where.fechaInspeccion = Between(dateRange.desde, dateRange.hasta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.inspeccionRepository.find({
|
||||||
|
where,
|
||||||
|
order: { fechaInspeccion: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene inspecciones por viaje
|
||||||
|
*/
|
||||||
|
async findByViaje(
|
||||||
|
tenantId: string,
|
||||||
|
viajeId: string
|
||||||
|
): Promise<InspeccionPreViaje[]> {
|
||||||
|
return this.inspeccionRepository.find({
|
||||||
|
where: { tenantId, viajeId },
|
||||||
|
order: { fechaInspeccion: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega o actualiza un item del checklist
|
||||||
|
*/
|
||||||
|
async addChecklistItem(
|
||||||
|
tenantId: string,
|
||||||
|
inspeccionId: string,
|
||||||
|
item: ChecklistItem
|
||||||
|
): Promise<InspeccionPreViaje | null> {
|
||||||
|
const inspeccion = await this.findById(tenantId, inspeccionId);
|
||||||
|
if (!inspeccion) return null;
|
||||||
|
|
||||||
|
// No permitir modificar inspecciones ya completadas
|
||||||
|
if (inspeccion.aprobada || inspeccion.firmaOperador) {
|
||||||
|
throw new Error('No se puede modificar una inspeccion completada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar item
|
||||||
|
this.validateChecklistItem(item);
|
||||||
|
|
||||||
|
// Buscar si el item ya existe
|
||||||
|
const existingIndex = inspeccion.checklistItems.findIndex(
|
||||||
|
i => i.item === item.item && i.categoria === item.categoria
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Actualizar item existente
|
||||||
|
inspeccion.checklistItems[existingIndex] = item;
|
||||||
|
} else {
|
||||||
|
// Agregar nuevo item
|
||||||
|
inspeccion.checklistItems.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalcular conteos de defectos
|
||||||
|
this.recalcularDefectos(inspeccion);
|
||||||
|
|
||||||
|
return this.inspeccionRepository.save(inspeccion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza multiples items del checklist
|
||||||
|
*/
|
||||||
|
async updateChecklistItems(
|
||||||
|
tenantId: string,
|
||||||
|
inspeccionId: string,
|
||||||
|
items: ChecklistItem[]
|
||||||
|
): Promise<InspeccionPreViaje | null> {
|
||||||
|
const inspeccion = await this.findById(tenantId, inspeccionId);
|
||||||
|
if (!inspeccion) return null;
|
||||||
|
|
||||||
|
if (inspeccion.aprobada || inspeccion.firmaOperador) {
|
||||||
|
throw new Error('No se puede modificar una inspeccion completada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar todos los items
|
||||||
|
for (const item of items) {
|
||||||
|
this.validateChecklistItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
inspeccion.checklistItems = items;
|
||||||
|
this.recalcularDefectos(inspeccion);
|
||||||
|
|
||||||
|
return this.inspeccionRepository.save(inspeccion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completa una inspeccion y la marca como aprobada o rechazada
|
||||||
|
*/
|
||||||
|
async completeInspeccion(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
firmaOperador?: string
|
||||||
|
): Promise<InspeccionPreViaje | null> {
|
||||||
|
const inspeccion = await this.findById(tenantId, id);
|
||||||
|
if (!inspeccion) return null;
|
||||||
|
|
||||||
|
if (inspeccion.aprobada || inspeccion.firmaOperador) {
|
||||||
|
throw new Error('La inspeccion ya fue completada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalcular defectos finales
|
||||||
|
this.recalcularDefectos(inspeccion);
|
||||||
|
|
||||||
|
// Determinar si aprobada (sin defectos criticos)
|
||||||
|
inspeccion.aprobada = inspeccion.defectosCriticos === 0;
|
||||||
|
|
||||||
|
// Registrar firma y fecha
|
||||||
|
if (firmaOperador) {
|
||||||
|
inspeccion.firmaOperador = firmaOperador;
|
||||||
|
inspeccion.firmaFecha = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar lista de defectos encontrados
|
||||||
|
const defectos = inspeccion.checklistItems
|
||||||
|
.filter(i => i.estado === 'DEFECTO_MENOR' || i.estado === 'DEFECTO_CRITICO')
|
||||||
|
.map(i => `[${i.estado}] ${i.categoria}: ${i.item}${i.observacion ? ` - ${i.observacion}` : ''}`);
|
||||||
|
|
||||||
|
inspeccion.defectosEncontrados = defectos.length > 0 ? defectos : null;
|
||||||
|
|
||||||
|
return this.inspeccionRepository.save(inspeccion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los defectos encontrados en una inspeccion
|
||||||
|
*/
|
||||||
|
async getDefectos(
|
||||||
|
tenantId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<ResumenDefectos | null> {
|
||||||
|
const inspeccion = await this.findById(tenantId, id);
|
||||||
|
if (!inspeccion) return null;
|
||||||
|
|
||||||
|
const items = inspeccion.checklistItems;
|
||||||
|
const itemsOk = items.filter(i => i.estado === 'OK').length;
|
||||||
|
const defectosMenores = items.filter(i => i.estado === 'DEFECTO_MENOR').length;
|
||||||
|
const defectosCriticos = items.filter(i => i.estado === 'DEFECTO_CRITICO').length;
|
||||||
|
const noAplica = items.filter(i => i.estado === 'NO_APLICA').length;
|
||||||
|
|
||||||
|
const itemsEvaluados = items.length - noAplica;
|
||||||
|
const porcentajeAprobacion = itemsEvaluados > 0
|
||||||
|
? (itemsOk / itemsEvaluados) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const defectosDetallados = items
|
||||||
|
.filter(i => i.estado === 'DEFECTO_MENOR' || i.estado === 'DEFECTO_CRITICO')
|
||||||
|
.map(i => ({
|
||||||
|
item: i.item,
|
||||||
|
categoria: i.categoria,
|
||||||
|
estado: i.estado,
|
||||||
|
observacion: i.observacion,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalItems: items.length,
|
||||||
|
itemsOk,
|
||||||
|
defectosMenores,
|
||||||
|
defectosCriticos,
|
||||||
|
noAplica,
|
||||||
|
porcentajeAprobacion,
|
||||||
|
defectosDetallados,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si una unidad puede ser despachada
|
||||||
|
*/
|
||||||
|
async canDespachar(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string
|
||||||
|
): Promise<ResultadoDespacho> {
|
||||||
|
const motivos: string[] = [];
|
||||||
|
|
||||||
|
// Obtener ultima inspeccion de la unidad
|
||||||
|
const inspecciones = await this.findByUnidad(tenantId, unidadId);
|
||||||
|
const ultimaInspeccion = inspecciones.length > 0 ? inspecciones[0] : null;
|
||||||
|
|
||||||
|
// Calcular horas desde ultima inspeccion
|
||||||
|
let horasDesdeUltimaInspeccion: number | null = null;
|
||||||
|
let requiereNuevaInspeccion = true;
|
||||||
|
|
||||||
|
if (ultimaInspeccion) {
|
||||||
|
const ahora = new Date();
|
||||||
|
const fechaInspeccion = new Date(ultimaInspeccion.fechaInspeccion);
|
||||||
|
horasDesdeUltimaInspeccion =
|
||||||
|
(ahora.getTime() - fechaInspeccion.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
requiereNuevaInspeccion = horasDesdeUltimaInspeccion > this.HORAS_VALIDEZ_INSPECCION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar si hay defectos criticos pendientes
|
||||||
|
let defectosCriticosPendientes = 0;
|
||||||
|
|
||||||
|
if (ultimaInspeccion) {
|
||||||
|
defectosCriticosPendientes = ultimaInspeccion.defectosCriticos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar si puede despachar
|
||||||
|
let puedeDespachar = true;
|
||||||
|
|
||||||
|
if (!ultimaInspeccion) {
|
||||||
|
puedeDespachar = false;
|
||||||
|
motivos.push('No existe inspeccion pre-viaje para esta unidad');
|
||||||
|
} else {
|
||||||
|
if (!ultimaInspeccion.aprobada) {
|
||||||
|
puedeDespachar = false;
|
||||||
|
motivos.push('La ultima inspeccion no fue aprobada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defectosCriticosPendientes > 0) {
|
||||||
|
puedeDespachar = false;
|
||||||
|
motivos.push(`Existen ${defectosCriticosPendientes} defectos criticos sin resolver`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiereNuevaInspeccion) {
|
||||||
|
puedeDespachar = false;
|
||||||
|
motivos.push(
|
||||||
|
`Han pasado mas de ${this.HORAS_VALIDEZ_INSPECCION} horas desde la ultima inspeccion`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ultimaInspeccion.firmaOperador) {
|
||||||
|
puedeDespachar = false;
|
||||||
|
motivos.push('La inspeccion no cuenta con firma del operador');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
puedeDespachar,
|
||||||
|
unidadId,
|
||||||
|
ultimaInspeccion,
|
||||||
|
horasDesdeUltimaInspeccion,
|
||||||
|
defectosCriticosPendientes,
|
||||||
|
requiereNuevaInspeccion,
|
||||||
|
motivos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la ultima inspeccion aprobada de una unidad
|
||||||
|
*/
|
||||||
|
async getUltimaInspeccionAprobada(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string
|
||||||
|
): Promise<InspeccionPreViaje | null> {
|
||||||
|
return this.inspeccionRepository.findOne({
|
||||||
|
where: { tenantId, unidadId, aprobada: true },
|
||||||
|
order: { fechaInspeccion: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega fotos a una inspeccion
|
||||||
|
*/
|
||||||
|
async addFotos(
|
||||||
|
tenantId: string,
|
||||||
|
inspeccionId: string,
|
||||||
|
fotos: FotoInspeccion[]
|
||||||
|
): Promise<InspeccionPreViaje | null> {
|
||||||
|
const inspeccion = await this.findById(tenantId, inspeccionId);
|
||||||
|
if (!inspeccion) return null;
|
||||||
|
|
||||||
|
const fotosActuales = inspeccion.fotos || [];
|
||||||
|
inspeccion.fotos = [...fotosActuales, ...fotos];
|
||||||
|
|
||||||
|
return this.inspeccionRepository.save(inspeccion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadisticas de inspecciones por periodo
|
||||||
|
*/
|
||||||
|
async getEstadisticas(
|
||||||
|
tenantId: string,
|
||||||
|
dateRange: DateRange
|
||||||
|
): Promise<{
|
||||||
|
totalInspecciones: number;
|
||||||
|
aprobadas: number;
|
||||||
|
rechazadas: number;
|
||||||
|
porcentajeAprobacion: number;
|
||||||
|
defectosMasComunes: { item: string; count: number }[];
|
||||||
|
}> {
|
||||||
|
const inspecciones = await this.inspeccionRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
fechaInspeccion: Between(dateRange.desde, dateRange.hasta),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const aprobadas = inspecciones.filter(i => i.aprobada).length;
|
||||||
|
const rechazadas = inspecciones.length - aprobadas;
|
||||||
|
|
||||||
|
// Contar defectos mas comunes
|
||||||
|
const defectosCount: Record<string, number> = {};
|
||||||
|
for (const insp of inspecciones) {
|
||||||
|
for (const item of insp.checklistItems) {
|
||||||
|
if (item.estado === 'DEFECTO_MENOR' || item.estado === 'DEFECTO_CRITICO') {
|
||||||
|
const key = `${item.categoria}: ${item.item}`;
|
||||||
|
defectosCount[key] = (defectosCount[key] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defectosMasComunes = Object.entries(defectosCount)
|
||||||
|
.map(([item, count]) => ({ item, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalInspecciones: inspecciones.length,
|
||||||
|
aprobadas,
|
||||||
|
rechazadas,
|
||||||
|
porcentajeAprobacion: inspecciones.length > 0
|
||||||
|
? (aprobadas / inspecciones.length) * 100
|
||||||
|
: 0,
|
||||||
|
defectosMasComunes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca inspeccion en progreso (sin completar) para una unidad
|
||||||
|
*/
|
||||||
|
private async findInspeccionEnProgreso(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string
|
||||||
|
): Promise<InspeccionPreViaje | null> {
|
||||||
|
// Buscar inspecciones de las ultimas 24 horas sin firma
|
||||||
|
const hace24Horas = new Date();
|
||||||
|
hace24Horas.setHours(hace24Horas.getHours() - 24);
|
||||||
|
|
||||||
|
return this.inspeccionRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
unidadId,
|
||||||
|
firmaOperador: undefined,
|
||||||
|
fechaInspeccion: MoreThanOrEqual(hace24Horas),
|
||||||
|
},
|
||||||
|
order: { fechaInspeccion: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida un item del checklist
|
||||||
|
*/
|
||||||
|
private validateChecklistItem(item: ChecklistItem): void {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!item.item || item.item.trim().length === 0) {
|
||||||
|
errors.push('item es requerido');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.categoria || item.categoria.trim().length === 0) {
|
||||||
|
errors.push('categoria es requerida');
|
||||||
|
}
|
||||||
|
|
||||||
|
const estadosValidos = ['OK', 'DEFECTO_MENOR', 'DEFECTO_CRITICO', 'NO_APLICA'];
|
||||||
|
if (!estadosValidos.includes(item.estado)) {
|
||||||
|
errors.push(`estado debe ser uno de: ${estadosValidos.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Item de checklist invalido: ${errors.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalcula contadores de defectos
|
||||||
|
*/
|
||||||
|
private recalcularDefectos(inspeccion: InspeccionPreViaje): void {
|
||||||
|
const items = inspeccion.checklistItems;
|
||||||
|
|
||||||
|
inspeccion.defectosCriticos = items.filter(
|
||||||
|
i => i.estado === 'DEFECTO_CRITICO'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
inspeccion.defectosMenores = items.filter(
|
||||||
|
i => i.estado === 'DEFECTO_MENOR'
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene checklist por defecto segun NOM-087
|
||||||
|
*/
|
||||||
|
private getChecklistDefault(): ChecklistItem[] {
|
||||||
|
return [
|
||||||
|
// Motor
|
||||||
|
{ item: 'Nivel de aceite de motor', categoria: CATEGORIAS_CHECKLIST.MOTOR, estado: 'OK' },
|
||||||
|
{ item: 'Nivel de refrigerante', categoria: CATEGORIAS_CHECKLIST.MOTOR, estado: 'OK' },
|
||||||
|
{ item: 'Fugas visibles', categoria: CATEGORIAS_CHECKLIST.MOTOR, estado: 'OK' },
|
||||||
|
{ item: 'Bandas y mangueras', categoria: CATEGORIAS_CHECKLIST.MOTOR, estado: 'OK' },
|
||||||
|
|
||||||
|
// Frenos
|
||||||
|
{ item: 'Presion de aire en sistema', categoria: CATEGORIAS_CHECKLIST.FRENOS, estado: 'OK' },
|
||||||
|
{ item: 'Funcionamiento freno de servicio', categoria: CATEGORIAS_CHECKLIST.FRENOS, estado: 'OK' },
|
||||||
|
{ item: 'Funcionamiento freno de estacionamiento', categoria: CATEGORIAS_CHECKLIST.FRENOS, estado: 'OK' },
|
||||||
|
{ item: 'Ajuste de frenos', categoria: CATEGORIAS_CHECKLIST.FRENOS, estado: 'OK' },
|
||||||
|
|
||||||
|
// Llantas
|
||||||
|
{ item: 'Presion de inflado', categoria: CATEGORIAS_CHECKLIST.LLANTAS, estado: 'OK' },
|
||||||
|
{ item: 'Profundidad de dibujo', categoria: CATEGORIAS_CHECKLIST.LLANTAS, estado: 'OK' },
|
||||||
|
{ item: 'Danos visibles', categoria: CATEGORIAS_CHECKLIST.LLANTAS, estado: 'OK' },
|
||||||
|
{ item: 'Birlos y tuercas', categoria: CATEGORIAS_CHECKLIST.LLANTAS, estado: 'OK' },
|
||||||
|
|
||||||
|
// Luces
|
||||||
|
{ item: 'Luces frontales', categoria: CATEGORIAS_CHECKLIST.LUCES, estado: 'OK' },
|
||||||
|
{ item: 'Luces traseras', categoria: CATEGORIAS_CHECKLIST.LUCES, estado: 'OK' },
|
||||||
|
{ item: 'Direccionales', categoria: CATEGORIAS_CHECKLIST.LUCES, estado: 'OK' },
|
||||||
|
{ item: 'Luces de freno', categoria: CATEGORIAS_CHECKLIST.LUCES, estado: 'OK' },
|
||||||
|
{ item: 'Luces de galibo', categoria: CATEGORIAS_CHECKLIST.LUCES, estado: 'OK' },
|
||||||
|
|
||||||
|
// Suspension
|
||||||
|
{ item: 'Amortiguadores', categoria: CATEGORIAS_CHECKLIST.SUSPENSION, estado: 'OK' },
|
||||||
|
{ item: 'Muelles/bolsas de aire', categoria: CATEGORIAS_CHECKLIST.SUSPENSION, estado: 'OK' },
|
||||||
|
{ item: 'Quinta rueda', categoria: CATEGORIAS_CHECKLIST.SUSPENSION, estado: 'OK' },
|
||||||
|
|
||||||
|
// Cabina
|
||||||
|
{ item: 'Espejos retrovisores', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' },
|
||||||
|
{ item: 'Parabrisas', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' },
|
||||||
|
{ item: 'Limpiadores', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' },
|
||||||
|
{ item: 'Cinturones de seguridad', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' },
|
||||||
|
{ item: 'Claxon', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' },
|
||||||
|
{ item: 'Instrumentos tablero', categoria: CATEGORIAS_CHECKLIST.CABINA, estado: 'OK' },
|
||||||
|
|
||||||
|
// Seguridad
|
||||||
|
{ item: 'Extintor', categoria: CATEGORIAS_CHECKLIST.SEGURIDAD, estado: 'OK' },
|
||||||
|
{ item: 'Triangulos reflectantes', categoria: CATEGORIAS_CHECKLIST.SEGURIDAD, estado: 'OK' },
|
||||||
|
{ item: 'Botiquin', categoria: CATEGORIAS_CHECKLIST.SEGURIDAD, estado: 'OK' },
|
||||||
|
{ item: 'Calzas', categoria: CATEGORIAS_CHECKLIST.SEGURIDAD, estado: 'OK' },
|
||||||
|
|
||||||
|
// Documentos
|
||||||
|
{ item: 'Licencia vigente', categoria: CATEGORIAS_CHECKLIST.DOCUMENTOS, estado: 'OK' },
|
||||||
|
{ item: 'Tarjeta de circulacion', categoria: CATEGORIAS_CHECKLIST.DOCUMENTOS, estado: 'OK' },
|
||||||
|
{ item: 'Poliza de seguro', categoria: CATEGORIAS_CHECKLIST.DOCUMENTOS, estado: 'OK' },
|
||||||
|
{ item: 'Permiso SCT', categoria: CATEGORIAS_CHECKLIST.DOCUMENTOS, estado: 'OK' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
521
src/modules/carta-porte/services/mercancia.service.ts
Normal file
521
src/modules/carta-porte/services/mercancia.service.ts
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
import { Repository, FindOptionsWhere, DataSource } from 'typeorm';
|
||||||
|
import { MercanciaCartaPorte } from '../entities';
|
||||||
|
import { CartaPorte, EstadoCartaPorte } from '../entities/carta-porte.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear mercancia
|
||||||
|
*/
|
||||||
|
export interface CreateMercanciaDto {
|
||||||
|
bienesTransp: string;
|
||||||
|
descripcion: string;
|
||||||
|
cantidad: number;
|
||||||
|
claveUnidad: string;
|
||||||
|
unidad?: string;
|
||||||
|
pesoEnKg: number;
|
||||||
|
largoCm?: number;
|
||||||
|
anchoCm?: number;
|
||||||
|
altoCm?: number;
|
||||||
|
valorMercancia?: number;
|
||||||
|
moneda?: string;
|
||||||
|
materialPeligroso?: boolean;
|
||||||
|
cveMaterialPeligroso?: string;
|
||||||
|
tipoEmbalaje?: string;
|
||||||
|
descripcionEmbalaje?: string;
|
||||||
|
fraccionArancelaria?: string;
|
||||||
|
uuidComercioExt?: string;
|
||||||
|
pedimentos?: string[];
|
||||||
|
guias?: string[];
|
||||||
|
secuencia?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar mercancia
|
||||||
|
*/
|
||||||
|
export interface UpdateMercanciaDto extends Partial<CreateMercanciaDto> {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado de validacion de pesos
|
||||||
|
*/
|
||||||
|
export interface ValidacionPesos {
|
||||||
|
valid: boolean;
|
||||||
|
pesoTotalMercancias: number;
|
||||||
|
pesoBrutoDeclarado: number | null;
|
||||||
|
diferencia: number;
|
||||||
|
porcentajeDiferencia: number;
|
||||||
|
toleranciaPorcentaje: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumen de valor de mercancias
|
||||||
|
*/
|
||||||
|
export interface ValorMercanciaResumen {
|
||||||
|
valorTotal: number;
|
||||||
|
moneda: string;
|
||||||
|
cantidadMercancias: number;
|
||||||
|
valorPromedioPorMercancia: number;
|
||||||
|
mercanciaMayorValor: {
|
||||||
|
id: string;
|
||||||
|
descripcion: string;
|
||||||
|
valor: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio para gestion de mercancias de Carta Porte
|
||||||
|
* CFDI 3.1 Compliance
|
||||||
|
*/
|
||||||
|
export class MercanciaService {
|
||||||
|
private mercanciaRepository: Repository<MercanciaCartaPorte>;
|
||||||
|
private cartaPorteRepository: Repository<CartaPorte>;
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {
|
||||||
|
this.mercanciaRepository = dataSource.getRepository(MercanciaCartaPorte);
|
||||||
|
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 mercancias en estado ${cartaPorte.estado}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el siguiente numero de secuencia para mercancias
|
||||||
|
*/
|
||||||
|
private async getNextSecuencia(cartaPorteId: string): Promise<number> {
|
||||||
|
const result = await this.mercanciaRepository
|
||||||
|
.createQueryBuilder('m')
|
||||||
|
.select('COALESCE(MAX(m.secuencia), 0)', 'maxSecuencia')
|
||||||
|
.where('m.cartaPorteId = :cartaPorteId', { cartaPorteId })
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
return (result?.maxSecuencia || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una nueva mercancia para una Carta Porte
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string,
|
||||||
|
data: CreateMercanciaDto
|
||||||
|
): Promise<MercanciaCartaPorte> {
|
||||||
|
const cartaPorte = await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
this.assertEditable(cartaPorte);
|
||||||
|
|
||||||
|
// Validar datos requeridos por SAT
|
||||||
|
this.validateMercanciaData(data);
|
||||||
|
|
||||||
|
const secuencia = data.secuencia ?? (await this.getNextSecuencia(cartaPorteId));
|
||||||
|
|
||||||
|
const mercancia = this.mercanciaRepository.create({
|
||||||
|
tenantId,
|
||||||
|
cartaPorteId,
|
||||||
|
bienesTransp: data.bienesTransp,
|
||||||
|
descripcion: data.descripcion,
|
||||||
|
cantidad: data.cantidad,
|
||||||
|
claveUnidad: data.claveUnidad,
|
||||||
|
unidad: data.unidad || null,
|
||||||
|
pesoEnKg: data.pesoEnKg,
|
||||||
|
largoCm: data.largoCm || null,
|
||||||
|
anchoCm: data.anchoCm || null,
|
||||||
|
altoCm: data.altoCm || null,
|
||||||
|
valorMercancia: data.valorMercancia || null,
|
||||||
|
moneda: data.moneda || 'MXN',
|
||||||
|
materialPeligroso: data.materialPeligroso || false,
|
||||||
|
cveMaterialPeligroso: data.cveMaterialPeligroso || null,
|
||||||
|
tipoEmbalaje: data.tipoEmbalaje || null,
|
||||||
|
descripcionEmbalaje: data.descripcionEmbalaje || null,
|
||||||
|
fraccionArancelaria: data.fraccionArancelaria || null,
|
||||||
|
uuidComercioExt: data.uuidComercioExt || null,
|
||||||
|
pedimentos: data.pedimentos || null,
|
||||||
|
guias: data.guias || null,
|
||||||
|
secuencia,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.mercanciaRepository.save(mercancia);
|
||||||
|
|
||||||
|
// Actualizar contador en Carta Porte
|
||||||
|
await this.updateCartaPorteTotals(cartaPorteId, tenantId);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todas las mercancias de una Carta Porte
|
||||||
|
*/
|
||||||
|
async findByCartaParte(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string
|
||||||
|
): Promise<MercanciaCartaPorte[]> {
|
||||||
|
await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
|
||||||
|
return this.mercanciaRepository.find({
|
||||||
|
where: { cartaPorteId, tenantId },
|
||||||
|
order: { secuencia: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene una mercancia por ID
|
||||||
|
*/
|
||||||
|
async findById(
|
||||||
|
tenantId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<MercanciaCartaPorte | null> {
|
||||||
|
return this.mercanciaRepository.findOne({
|
||||||
|
where: { id, tenantId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza una mercancia existente
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
data: UpdateMercanciaDto
|
||||||
|
): Promise<MercanciaCartaPorte | null> {
|
||||||
|
const mercancia = await this.findById(tenantId, id);
|
||||||
|
if (!mercancia) return null;
|
||||||
|
|
||||||
|
const cartaPorte = await this.getCartaPorteOrFail(
|
||||||
|
mercancia.cartaPorteId,
|
||||||
|
tenantId
|
||||||
|
);
|
||||||
|
this.assertEditable(cartaPorte);
|
||||||
|
|
||||||
|
// Validar datos si se actualizan campos requeridos
|
||||||
|
if (data.bienesTransp || data.descripcion || data.cantidad || data.claveUnidad || data.pesoEnKg) {
|
||||||
|
this.validateMercanciaData({
|
||||||
|
bienesTransp: data.bienesTransp ?? mercancia.bienesTransp,
|
||||||
|
descripcion: data.descripcion ?? mercancia.descripcion,
|
||||||
|
cantidad: data.cantidad ?? mercancia.cantidad,
|
||||||
|
claveUnidad: data.claveUnidad ?? mercancia.claveUnidad,
|
||||||
|
pesoEnKg: data.pesoEnKg ?? mercancia.pesoEnKg,
|
||||||
|
} as CreateMercanciaDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar material peligroso
|
||||||
|
if (data.materialPeligroso === true && !data.cveMaterialPeligroso && !mercancia.cveMaterialPeligroso) {
|
||||||
|
throw new Error('Se requiere clave de material peligroso cuando materialPeligroso es true');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(mercancia, data);
|
||||||
|
const updated = await this.mercanciaRepository.save(mercancia);
|
||||||
|
|
||||||
|
// Actualizar totales
|
||||||
|
await this.updateCartaPorteTotals(mercancia.cartaPorteId, tenantId);
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una mercancia
|
||||||
|
*/
|
||||||
|
async delete(tenantId: string, id: string): Promise<boolean> {
|
||||||
|
const mercancia = await this.findById(tenantId, id);
|
||||||
|
if (!mercancia) return false;
|
||||||
|
|
||||||
|
const cartaPorte = await this.getCartaPorteOrFail(
|
||||||
|
mercancia.cartaPorteId,
|
||||||
|
tenantId
|
||||||
|
);
|
||||||
|
this.assertEditable(cartaPorte);
|
||||||
|
|
||||||
|
const cartaPorteId = mercancia.cartaPorteId;
|
||||||
|
const result = await this.mercanciaRepository.delete(id);
|
||||||
|
|
||||||
|
if ((result.affected ?? 0) > 0) {
|
||||||
|
// Reordenar secuencias
|
||||||
|
await this.reorderSecuencias(cartaPorteId);
|
||||||
|
// Actualizar totales
|
||||||
|
await this.updateCartaPorteTotals(cartaPorteId, tenantId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida que los pesos totales de mercancias coincidan con el declarado
|
||||||
|
*/
|
||||||
|
async validatePesosTotales(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string
|
||||||
|
): Promise<ValidacionPesos> {
|
||||||
|
const cartaPorte = await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
const mercancias = await this.findByCartaParte(tenantId, cartaPorteId);
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const toleranciaPorcentaje = 2; // 2% de tolerancia
|
||||||
|
|
||||||
|
if (mercancias.length === 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
pesoTotalMercancias: 0,
|
||||||
|
pesoBrutoDeclarado: cartaPorte.pesoBrutoTotal,
|
||||||
|
diferencia: 0,
|
||||||
|
porcentajeDiferencia: 0,
|
||||||
|
toleranciaPorcentaje,
|
||||||
|
errors: ['No hay mercancias registradas'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pesoTotalMercancias = mercancias.reduce(
|
||||||
|
(sum, m) => sum + Number(m.pesoEnKg) * Number(m.cantidad),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const pesoBrutoDeclarado = cartaPorte.pesoBrutoTotal
|
||||||
|
? Number(cartaPorte.pesoBrutoTotal)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (pesoBrutoDeclarado === null) {
|
||||||
|
errors.push('No se ha declarado peso bruto total en la Carta Porte');
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
pesoTotalMercancias,
|
||||||
|
pesoBrutoDeclarado: null,
|
||||||
|
diferencia: 0,
|
||||||
|
porcentajeDiferencia: 0,
|
||||||
|
toleranciaPorcentaje,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const diferencia = Math.abs(pesoTotalMercancias - pesoBrutoDeclarado);
|
||||||
|
const porcentajeDiferencia =
|
||||||
|
pesoBrutoDeclarado > 0 ? (diferencia / pesoBrutoDeclarado) * 100 : 0;
|
||||||
|
|
||||||
|
if (porcentajeDiferencia > toleranciaPorcentaje) {
|
||||||
|
errors.push(
|
||||||
|
`Diferencia de peso (${porcentajeDiferencia.toFixed(2)}%) excede tolerancia de ${toleranciaPorcentaje}%`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar pesos individuales
|
||||||
|
for (const m of mercancias) {
|
||||||
|
if (Number(m.pesoEnKg) <= 0) {
|
||||||
|
errors.push(`Mercancia "${m.descripcion}" tiene peso invalido`);
|
||||||
|
}
|
||||||
|
if (Number(m.cantidad) <= 0) {
|
||||||
|
errors.push(`Mercancia "${m.descripcion}" tiene cantidad invalida`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
pesoTotalMercancias,
|
||||||
|
pesoBrutoDeclarado,
|
||||||
|
diferencia,
|
||||||
|
porcentajeDiferencia,
|
||||||
|
toleranciaPorcentaje,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el valor total de las mercancias
|
||||||
|
*/
|
||||||
|
async calculateValorMercancia(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string
|
||||||
|
): Promise<ValorMercanciaResumen> {
|
||||||
|
await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
const mercancias = await this.findByCartaParte(tenantId, cartaPorteId);
|
||||||
|
|
||||||
|
if (mercancias.length === 0) {
|
||||||
|
return {
|
||||||
|
valorTotal: 0,
|
||||||
|
moneda: 'MXN',
|
||||||
|
cantidadMercancias: 0,
|
||||||
|
valorPromedioPorMercancia: 0,
|
||||||
|
mercanciaMayorValor: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asumimos misma moneda (SAT requiere MXN para valores)
|
||||||
|
const moneda = mercancias[0]?.moneda || 'MXN';
|
||||||
|
|
||||||
|
let valorTotal = 0;
|
||||||
|
let mercanciaMayorValor: { id: string; descripcion: string; valor: number } | null = null;
|
||||||
|
|
||||||
|
for (const m of mercancias) {
|
||||||
|
const valorItem = m.valorMercancia ? Number(m.valorMercancia) * Number(m.cantidad) : 0;
|
||||||
|
valorTotal += valorItem;
|
||||||
|
|
||||||
|
if (!mercanciaMayorValor || valorItem > mercanciaMayorValor.valor) {
|
||||||
|
mercanciaMayorValor = {
|
||||||
|
id: m.id,
|
||||||
|
descripcion: m.descripcion,
|
||||||
|
valor: valorItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valorTotal,
|
||||||
|
moneda,
|
||||||
|
cantidadMercancias: mercancias.length,
|
||||||
|
valorPromedioPorMercancia: mercancias.length > 0 ? valorTotal / mercancias.length : 0,
|
||||||
|
mercanciaMayorValor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene solo las mercancias peligrosas de una Carta Porte
|
||||||
|
*/
|
||||||
|
async getMercanciasPeligrosas(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string
|
||||||
|
): Promise<MercanciaCartaPorte[]> {
|
||||||
|
await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
|
||||||
|
return this.mercanciaRepository.find({
|
||||||
|
where: {
|
||||||
|
cartaPorteId,
|
||||||
|
tenantId,
|
||||||
|
materialPeligroso: true,
|
||||||
|
},
|
||||||
|
order: { secuencia: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadisticas de mercancias peligrosas
|
||||||
|
*/
|
||||||
|
async getEstadisticasMercanciasPeligrosas(
|
||||||
|
tenantId: string,
|
||||||
|
cartaPorteId: string
|
||||||
|
): Promise<{
|
||||||
|
totalMercancias: number;
|
||||||
|
mercanciasPeligrosas: number;
|
||||||
|
porcentajePeligrosas: number;
|
||||||
|
clavesMaterialPeligroso: string[];
|
||||||
|
}> {
|
||||||
|
await this.getCartaPorteOrFail(cartaPorteId, tenantId);
|
||||||
|
const todas = await this.findByCartaParte(tenantId, cartaPorteId);
|
||||||
|
const peligrosas = todas.filter(m => m.materialPeligroso);
|
||||||
|
|
||||||
|
const clavesMaterialPeligroso = Array.from(
|
||||||
|
new Set(
|
||||||
|
peligrosas
|
||||||
|
.map(m => m.cveMaterialPeligroso)
|
||||||
|
.filter((c): c is string => c !== null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMercancias: todas.length,
|
||||||
|
mercanciasPeligrosas: peligrosas.length,
|
||||||
|
porcentajePeligrosas: todas.length > 0 ? (peligrosas.length / todas.length) * 100 : 0,
|
||||||
|
clavesMaterialPeligroso,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida datos de mercancia segun requerimientos SAT
|
||||||
|
*/
|
||||||
|
private validateMercanciaData(data: CreateMercanciaDto): void {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!data.bienesTransp || data.bienesTransp.length === 0) {
|
||||||
|
errors.push('bienesTransp es requerido');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.descripcion || data.descripcion.length === 0) {
|
||||||
|
errors.push('descripcion es requerida');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.cantidad || data.cantidad <= 0) {
|
||||||
|
errors.push('cantidad debe ser mayor a 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.claveUnidad || data.claveUnidad.length === 0) {
|
||||||
|
errors.push('claveUnidad es requerida');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.pesoEnKg || data.pesoEnKg <= 0) {
|
||||||
|
errors.push('pesoEnKg debe ser mayor a 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validaciones material peligroso
|
||||||
|
if (data.materialPeligroso === true) {
|
||||||
|
if (!data.cveMaterialPeligroso) {
|
||||||
|
errors.push('cveMaterialPeligroso es requerido para material peligroso');
|
||||||
|
}
|
||||||
|
if (!data.tipoEmbalaje) {
|
||||||
|
errors.push('tipoEmbalaje es requerido para material peligroso');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Datos de mercancia invalidos: ${errors.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reordena las secuencias despues de eliminar
|
||||||
|
*/
|
||||||
|
private async reorderSecuencias(cartaPorteId: string): Promise<void> {
|
||||||
|
const mercancias = await this.mercanciaRepository.find({
|
||||||
|
where: { cartaPorteId },
|
||||||
|
order: { secuencia: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < mercancias.length; i++) {
|
||||||
|
if (mercancias[i].secuencia !== i + 1) {
|
||||||
|
mercancias[i].secuencia = i + 1;
|
||||||
|
await this.mercanciaRepository.save(mercancias[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza totales en la Carta Porte
|
||||||
|
*/
|
||||||
|
private async updateCartaPorteTotals(
|
||||||
|
cartaPorteId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const mercancias = await this.mercanciaRepository.find({
|
||||||
|
where: { cartaPorteId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const numTotalMercancias = mercancias.length;
|
||||||
|
const pesoBrutoTotal = mercancias.reduce(
|
||||||
|
(sum, m) => sum + Number(m.pesoEnKg) * Number(m.cantidad),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.cartaPorteRepository.update(
|
||||||
|
{ id: cartaPorteId, tenantId },
|
||||||
|
{ numTotalMercancias, pesoBrutoTotal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,526 @@
|
|||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
455
src/modules/gestion-flota/services/asignacion.service.ts
Normal file
455
src/modules/gestion-flota/services/asignacion.service.ts
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
import { Repository, IsNull, Not } from 'typeorm';
|
||||||
|
import { Asignacion } from '../entities/asignacion.entity';
|
||||||
|
import { Unidad, EstadoUnidad } from '../entities/unidad.entity';
|
||||||
|
import { Operador, EstadoOperador } from '../entities/operador.entity';
|
||||||
|
import { DocumentoFlota, TipoDocumento, TipoEntidadDocumento } from '../entities/documento-flota.entity';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CreateAsignacionDto {
|
||||||
|
remolqueId?: string;
|
||||||
|
motivo?: string;
|
||||||
|
fechaInicio?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AsignacionHistorial {
|
||||||
|
asignacion: Asignacion;
|
||||||
|
unidad: Unidad;
|
||||||
|
operador: Operador;
|
||||||
|
duracionDias: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisponibilidadResult {
|
||||||
|
unidadDisponible: boolean;
|
||||||
|
operadorDisponible: boolean;
|
||||||
|
unidadMotivo?: string;
|
||||||
|
operadorMotivo?: string;
|
||||||
|
documentosVencidosUnidad: string[];
|
||||||
|
documentosVencidosOperador: string[];
|
||||||
|
puedeAsignar: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Documentos criticos que bloquean asignacion
|
||||||
|
const DOCUMENTOS_CRITICOS_UNIDAD: TipoDocumento[] = [
|
||||||
|
TipoDocumento.TARJETA_CIRCULACION,
|
||||||
|
TipoDocumento.POLIZA_SEGURO,
|
||||||
|
TipoDocumento.VERIFICACION,
|
||||||
|
TipoDocumento.PERMISO_SCT,
|
||||||
|
];
|
||||||
|
|
||||||
|
const DOCUMENTOS_CRITICOS_OPERADOR: TipoDocumento[] = [
|
||||||
|
TipoDocumento.LICENCIA,
|
||||||
|
TipoDocumento.CERTIFICADO_FISICO,
|
||||||
|
TipoDocumento.ANTIDOPING,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class AsignacionService {
|
||||||
|
constructor(
|
||||||
|
private readonly asignacionRepository: Repository<Asignacion>,
|
||||||
|
private readonly unidadRepository: Repository<Unidad>,
|
||||||
|
private readonly operadorRepository: Repository<Operador>,
|
||||||
|
private readonly documentoRepository: Repository<DocumentoFlota>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// CRUD OPERATIONS
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear asignacion de unidad a operador
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string,
|
||||||
|
operadorId: string,
|
||||||
|
dto: CreateAsignacionDto,
|
||||||
|
createdById: string
|
||||||
|
): Promise<Asignacion> {
|
||||||
|
// Validar disponibilidad de ambos
|
||||||
|
const disponibilidad = await this.validateDisponibilidad(tenantId, unidadId, operadorId);
|
||||||
|
|
||||||
|
if (!disponibilidad.puedeAsignar) {
|
||||||
|
const motivos: string[] = [];
|
||||||
|
if (!disponibilidad.unidadDisponible) {
|
||||||
|
motivos.push(`Unidad: ${disponibilidad.unidadMotivo}`);
|
||||||
|
}
|
||||||
|
if (!disponibilidad.operadorDisponible) {
|
||||||
|
motivos.push(`Operador: ${disponibilidad.operadorMotivo}`);
|
||||||
|
}
|
||||||
|
if (disponibilidad.documentosVencidosUnidad.length > 0) {
|
||||||
|
motivos.push(`Documentos vencidos de unidad: ${disponibilidad.documentosVencidosUnidad.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (disponibilidad.documentosVencidosOperador.length > 0) {
|
||||||
|
motivos.push(`Documentos vencidos de operador: ${disponibilidad.documentosVencidosOperador.join(', ')}`);
|
||||||
|
}
|
||||||
|
throw new Error(`No se puede crear la asignacion: ${motivos.join('; ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que no haya asignacion activa para la unidad
|
||||||
|
const asignacionExistenteUnidad = await this.asignacionRepository.findOne({
|
||||||
|
where: { tenantId, unidadId, activa: true },
|
||||||
|
});
|
||||||
|
if (asignacionExistenteUnidad) {
|
||||||
|
throw new Error('La unidad ya tiene un operador asignado. Termine la asignacion actual primero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el operador no tenga asignacion activa
|
||||||
|
const asignacionExistenteOperador = await this.asignacionRepository.findOne({
|
||||||
|
where: { tenantId, operadorId, activa: true },
|
||||||
|
});
|
||||||
|
if (asignacionExistenteOperador) {
|
||||||
|
throw new Error('El operador ya tiene una unidad asignada. Termine la asignacion actual primero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener unidad y operador
|
||||||
|
const unidad = await this.unidadRepository.findOne({ where: { tenantId, id: unidadId } });
|
||||||
|
const operador = await this.operadorRepository.findOne({ where: { tenantId, id: operadorId } });
|
||||||
|
|
||||||
|
if (!unidad) throw new Error(`Unidad con ID ${unidadId} no encontrada`);
|
||||||
|
if (!operador) throw new Error(`Operador con ID ${operadorId} no encontrado`);
|
||||||
|
|
||||||
|
// Crear asignacion
|
||||||
|
const asignacion = this.asignacionRepository.create({
|
||||||
|
tenantId,
|
||||||
|
unidadId,
|
||||||
|
operadorId,
|
||||||
|
remolqueId: dto.remolqueId,
|
||||||
|
fechaInicio: dto.fechaInicio || new Date(),
|
||||||
|
activa: true,
|
||||||
|
motivo: dto.motivo,
|
||||||
|
createdById,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedAsignacion = await this.asignacionRepository.save(asignacion);
|
||||||
|
|
||||||
|
// Actualizar unidad asignada en operador
|
||||||
|
operador.unidadAsignadaId = unidadId;
|
||||||
|
await this.operadorRepository.save(operador);
|
||||||
|
|
||||||
|
return savedAsignacion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar asignacion por ID
|
||||||
|
*/
|
||||||
|
async findById(tenantId: string, id: string): Promise<Asignacion | null> {
|
||||||
|
return this.asignacionRepository.findOne({
|
||||||
|
where: { tenantId, id },
|
||||||
|
relations: ['unidad', 'operador'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar asignacion por ID o lanzar error
|
||||||
|
*/
|
||||||
|
async findByIdOrFail(tenantId: string, id: string): Promise<Asignacion> {
|
||||||
|
const asignacion = await this.findById(tenantId, id);
|
||||||
|
if (!asignacion) {
|
||||||
|
throw new Error(`Asignacion con ID ${id} no encontrada`);
|
||||||
|
}
|
||||||
|
return asignacion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar historial de asignaciones de una unidad
|
||||||
|
*/
|
||||||
|
async findByUnidad(tenantId: string, unidadId: string): Promise<Asignacion[]> {
|
||||||
|
return this.asignacionRepository.find({
|
||||||
|
where: { tenantId, unidadId },
|
||||||
|
relations: ['operador'],
|
||||||
|
order: { fechaInicio: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar historial de asignaciones de un operador
|
||||||
|
*/
|
||||||
|
async findByOperador(tenantId: string, operadorId: string): Promise<Asignacion[]> {
|
||||||
|
return this.asignacionRepository.find({
|
||||||
|
where: { tenantId, operadorId },
|
||||||
|
relations: ['unidad'],
|
||||||
|
order: { fechaInicio: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener todas las asignaciones activas
|
||||||
|
*/
|
||||||
|
async findActivas(tenantId: string): Promise<Asignacion[]> {
|
||||||
|
return this.asignacionRepository.find({
|
||||||
|
where: { tenantId, activa: true },
|
||||||
|
relations: ['unidad', 'operador'],
|
||||||
|
order: { fechaInicio: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener asignacion actual de una unidad
|
||||||
|
*/
|
||||||
|
async getAsignacionActual(tenantId: string, unidadId: string): Promise<Asignacion | null> {
|
||||||
|
return this.asignacionRepository.findOne({
|
||||||
|
where: { tenantId, unidadId, activa: true },
|
||||||
|
relations: ['operador'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminar asignacion
|
||||||
|
*/
|
||||||
|
async terminar(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
motivo: string,
|
||||||
|
terminadoPorId: string
|
||||||
|
): Promise<Asignacion> {
|
||||||
|
const asignacion = await this.findByIdOrFail(tenantId, id);
|
||||||
|
|
||||||
|
if (!asignacion.activa) {
|
||||||
|
throw new Error('La asignacion ya esta terminada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que la unidad no este en viaje
|
||||||
|
const unidad = await this.unidadRepository.findOne({
|
||||||
|
where: { tenantId, id: asignacion.unidadId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unidad && (unidad.estado === EstadoUnidad.EN_VIAJE || unidad.estado === EstadoUnidad.EN_RUTA)) {
|
||||||
|
throw new Error('No se puede terminar la asignacion de una unidad en viaje');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminar asignacion
|
||||||
|
asignacion.activa = false;
|
||||||
|
asignacion.fechaFin = new Date();
|
||||||
|
asignacion.motivo = motivo;
|
||||||
|
|
||||||
|
await this.asignacionRepository.save(asignacion);
|
||||||
|
|
||||||
|
// Actualizar operador
|
||||||
|
const operador = await this.operadorRepository.findOne({
|
||||||
|
where: { tenantId, id: asignacion.operadorId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (operador) {
|
||||||
|
operador.unidadAsignadaId = undefined as unknown as string;
|
||||||
|
await this.operadorRepository.save(operador);
|
||||||
|
}
|
||||||
|
|
||||||
|
return asignacion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transferir unidad a nuevo operador
|
||||||
|
*/
|
||||||
|
async transferir(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string,
|
||||||
|
nuevoOperadorId: string,
|
||||||
|
motivo: string,
|
||||||
|
transferidoPorId: string
|
||||||
|
): Promise<Asignacion> {
|
||||||
|
// Verificar que la unidad existe
|
||||||
|
const unidad = await this.unidadRepository.findOne({
|
||||||
|
where: { tenantId, id: unidadId },
|
||||||
|
});
|
||||||
|
if (!unidad) {
|
||||||
|
throw new Error(`Unidad con ID ${unidadId} no encontrada`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que la unidad no este en viaje
|
||||||
|
if (unidad.estado === EstadoUnidad.EN_VIAJE || unidad.estado === EstadoUnidad.EN_RUTA) {
|
||||||
|
throw new Error('No se puede transferir una unidad en viaje');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminar asignacion actual si existe
|
||||||
|
const asignacionActual = await this.getAsignacionActual(tenantId, unidadId);
|
||||||
|
if (asignacionActual) {
|
||||||
|
await this.terminar(tenantId, asignacionActual.id, `Transferencia: ${motivo}`, transferidoPorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear nueva asignacion
|
||||||
|
return this.create(
|
||||||
|
tenantId,
|
||||||
|
unidadId,
|
||||||
|
nuevoOperadorId,
|
||||||
|
{ motivo: `Transferencia: ${motivo}` },
|
||||||
|
transferidoPorId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener historial completo de asignaciones de una unidad
|
||||||
|
*/
|
||||||
|
async getHistorial(tenantId: string, unidadId: string): Promise<AsignacionHistorial[]> {
|
||||||
|
const asignaciones = await this.asignacionRepository.find({
|
||||||
|
where: { tenantId, unidadId },
|
||||||
|
relations: ['unidad', 'operador'],
|
||||||
|
order: { fechaInicio: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const historial: AsignacionHistorial[] = [];
|
||||||
|
|
||||||
|
for (const asignacion of asignaciones) {
|
||||||
|
const fechaFin = asignacion.fechaFin || new Date();
|
||||||
|
const duracionMs = fechaFin.getTime() - asignacion.fechaInicio.getTime();
|
||||||
|
const duracionDias = Math.ceil(duracionMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
historial.push({
|
||||||
|
asignacion,
|
||||||
|
unidad: asignacion.unidad,
|
||||||
|
operador: asignacion.operador,
|
||||||
|
duracionDias,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return historial;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar disponibilidad de unidad y operador para asignacion
|
||||||
|
*/
|
||||||
|
async validateDisponibilidad(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string,
|
||||||
|
operadorId: string
|
||||||
|
): Promise<DisponibilidadResult> {
|
||||||
|
const result: DisponibilidadResult = {
|
||||||
|
unidadDisponible: true,
|
||||||
|
operadorDisponible: true,
|
||||||
|
documentosVencidosUnidad: [],
|
||||||
|
documentosVencidosOperador: [],
|
||||||
|
puedeAsignar: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hoy = new Date();
|
||||||
|
hoy.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Verificar unidad
|
||||||
|
const unidad = await this.unidadRepository.findOne({
|
||||||
|
where: { tenantId, id: unidadId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unidad) {
|
||||||
|
result.unidadDisponible = false;
|
||||||
|
result.unidadMotivo = 'Unidad no encontrada';
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
} else if (!unidad.activo) {
|
||||||
|
result.unidadDisponible = false;
|
||||||
|
result.unidadMotivo = 'Unidad no activa';
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
} else if (unidad.estado !== EstadoUnidad.DISPONIBLE) {
|
||||||
|
result.unidadDisponible = false;
|
||||||
|
result.unidadMotivo = `Unidad en estado ${unidad.estado}`;
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar operador
|
||||||
|
const operador = await this.operadorRepository.findOne({
|
||||||
|
where: { tenantId, id: operadorId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!operador) {
|
||||||
|
result.operadorDisponible = false;
|
||||||
|
result.operadorMotivo = 'Operador no encontrado';
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
} else if (!operador.activo) {
|
||||||
|
result.operadorDisponible = false;
|
||||||
|
result.operadorMotivo = 'Operador no activo';
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
} else if (
|
||||||
|
operador.estado !== EstadoOperador.DISPONIBLE &&
|
||||||
|
operador.estado !== EstadoOperador.ACTIVO
|
||||||
|
) {
|
||||||
|
result.operadorDisponible = false;
|
||||||
|
result.operadorMotivo = `Operador en estado ${operador.estado}`;
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar documentos criticos de la unidad
|
||||||
|
if (unidad) {
|
||||||
|
const docsUnidad = await this.documentoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadId: unidadId,
|
||||||
|
entidadTipo: TipoEntidadDocumento.UNIDAD,
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tipoDoc of DOCUMENTOS_CRITICOS_UNIDAD) {
|
||||||
|
const doc = docsUnidad.find(d => d.tipoDocumento === tipoDoc);
|
||||||
|
if (doc && doc.fechaVencimiento && doc.fechaVencimiento < hoy) {
|
||||||
|
result.documentosVencidosUnidad.push(tipoDoc);
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar fechas en la entidad unidad
|
||||||
|
if (unidad.fechaPolizaVencimiento && unidad.fechaPolizaVencimiento < hoy) {
|
||||||
|
if (!result.documentosVencidosUnidad.includes(TipoDocumento.POLIZA_SEGURO)) {
|
||||||
|
result.documentosVencidosUnidad.push(TipoDocumento.POLIZA_SEGURO);
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (unidad.fechaVerificacionProxima && unidad.fechaVerificacionProxima < hoy) {
|
||||||
|
if (!result.documentosVencidosUnidad.includes(TipoDocumento.VERIFICACION)) {
|
||||||
|
result.documentosVencidosUnidad.push(TipoDocumento.VERIFICACION);
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (unidad.fechaPermisoVencimiento && unidad.fechaPermisoVencimiento < hoy) {
|
||||||
|
if (!result.documentosVencidosUnidad.includes(TipoDocumento.PERMISO_SCT)) {
|
||||||
|
result.documentosVencidosUnidad.push(TipoDocumento.PERMISO_SCT);
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar documentos criticos del operador
|
||||||
|
if (operador) {
|
||||||
|
const docsOperador = await this.documentoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadId: operadorId,
|
||||||
|
entidadTipo: TipoEntidadDocumento.OPERADOR,
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tipoDoc of DOCUMENTOS_CRITICOS_OPERADOR) {
|
||||||
|
const doc = docsOperador.find(d => d.tipoDocumento === tipoDoc);
|
||||||
|
if (doc && doc.fechaVencimiento && doc.fechaVencimiento < hoy) {
|
||||||
|
result.documentosVencidosOperador.push(tipoDoc);
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar fechas en la entidad operador
|
||||||
|
if (operador.licenciaVigencia && operador.licenciaVigencia < hoy) {
|
||||||
|
if (!result.documentosVencidosOperador.includes(TipoDocumento.LICENCIA)) {
|
||||||
|
result.documentosVencidosOperador.push(TipoDocumento.LICENCIA);
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (operador.certificadoFisicoVigencia && operador.certificadoFisicoVigencia < hoy) {
|
||||||
|
if (!result.documentosVencidosOperador.includes(TipoDocumento.CERTIFICADO_FISICO)) {
|
||||||
|
result.documentosVencidosOperador.push(TipoDocumento.CERTIFICADO_FISICO);
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (operador.antidopingVigencia && operador.antidopingVigencia < hoy) {
|
||||||
|
if (!result.documentosVencidosOperador.includes(TipoDocumento.ANTIDOPING)) {
|
||||||
|
result.documentosVencidosOperador.push(TipoDocumento.ANTIDOPING);
|
||||||
|
result.puedeAsignar = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
730
src/modules/gestion-flota/services/documento-flota.service.ts
Normal file
730
src/modules/gestion-flota/services/documento-flota.service.ts
Normal file
@ -0,0 +1,730 @@
|
|||||||
|
import { Repository, Between, LessThan, LessThanOrEqual, MoreThan, In } from 'typeorm';
|
||||||
|
import {
|
||||||
|
DocumentoFlota,
|
||||||
|
TipoDocumento,
|
||||||
|
TipoEntidadDocumento,
|
||||||
|
} from '../entities/documento-flota.entity';
|
||||||
|
import { Unidad, EstadoUnidad } from '../entities/unidad.entity';
|
||||||
|
import { Operador, EstadoOperador } from '../entities/operador.entity';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CreateDocumentoFlotaDto {
|
||||||
|
entidadTipo: TipoEntidadDocumento;
|
||||||
|
entidadId: string;
|
||||||
|
tipoDocumento: TipoDocumento;
|
||||||
|
nombre: string;
|
||||||
|
numeroDocumento?: string;
|
||||||
|
descripcion?: string;
|
||||||
|
fechaEmision?: Date;
|
||||||
|
fechaVencimiento?: Date;
|
||||||
|
diasAlertaVencimiento?: number;
|
||||||
|
archivoUrl?: string;
|
||||||
|
archivoNombre?: string;
|
||||||
|
archivoTipo?: string;
|
||||||
|
archivoTamanoBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDocumentoFlotaDto {
|
||||||
|
nombre?: string;
|
||||||
|
numeroDocumento?: string;
|
||||||
|
descripcion?: string;
|
||||||
|
fechaEmision?: Date;
|
||||||
|
fechaVencimiento?: Date;
|
||||||
|
diasAlertaVencimiento?: number;
|
||||||
|
archivoUrl?: string;
|
||||||
|
archivoNombre?: string;
|
||||||
|
archivoTipo?: string;
|
||||||
|
archivoTamanoBytes?: number;
|
||||||
|
verificado?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentoVencimiento {
|
||||||
|
documento: DocumentoFlota;
|
||||||
|
entidadTipo: TipoEntidadDocumento;
|
||||||
|
entidadId: string;
|
||||||
|
entidadNombre: string;
|
||||||
|
diasParaVencer: number;
|
||||||
|
esCritico: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumenVencimientos {
|
||||||
|
totalDocumentos: number;
|
||||||
|
vencidos: number;
|
||||||
|
porVencer7Dias: number;
|
||||||
|
porVencer15Dias: number;
|
||||||
|
porVencer30Dias: number;
|
||||||
|
porCategoria: {
|
||||||
|
unidades: { vencidos: number; porVencer: number };
|
||||||
|
remolques: { vencidos: number; porVencer: number };
|
||||||
|
operadores: { vencidos: number; porVencer: number };
|
||||||
|
};
|
||||||
|
documentosCriticosVencidos: DocumentoVencimiento[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntidadBloqueada {
|
||||||
|
entidadTipo: TipoEntidadDocumento;
|
||||||
|
entidadId: string;
|
||||||
|
entidadNombre: string;
|
||||||
|
documentosVencidos: DocumentoFlota[];
|
||||||
|
motivoBloqueo: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Documentos criticos que bloquean operacion
|
||||||
|
const DOCUMENTOS_CRITICOS_UNIDAD: TipoDocumento[] = [
|
||||||
|
TipoDocumento.TARJETA_CIRCULACION,
|
||||||
|
TipoDocumento.POLIZA_SEGURO,
|
||||||
|
TipoDocumento.VERIFICACION,
|
||||||
|
TipoDocumento.PERMISO_SCT,
|
||||||
|
];
|
||||||
|
|
||||||
|
const DOCUMENTOS_CRITICOS_OPERADOR: TipoDocumento[] = [
|
||||||
|
TipoDocumento.LICENCIA,
|
||||||
|
TipoDocumento.CERTIFICADO_FISICO,
|
||||||
|
TipoDocumento.ANTIDOPING,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class DocumentoFlotaService {
|
||||||
|
constructor(
|
||||||
|
private readonly documentoRepository: Repository<DocumentoFlota>,
|
||||||
|
private readonly unidadRepository: Repository<Unidad>,
|
||||||
|
private readonly operadorRepository: Repository<Operador>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// CRUD OPERATIONS
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear documento de flota (soporta UNIDAD, REMOLQUE, OPERADOR)
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
tenantId: string,
|
||||||
|
dto: CreateDocumentoFlotaDto,
|
||||||
|
createdById: string
|
||||||
|
): Promise<DocumentoFlota> {
|
||||||
|
// Validar que la entidad existe
|
||||||
|
await this.validateEntidadExists(tenantId, dto.entidadTipo, dto.entidadId);
|
||||||
|
|
||||||
|
// Verificar si ya existe un documento del mismo tipo para la entidad
|
||||||
|
const existingDoc = await this.documentoRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: dto.entidadTipo,
|
||||||
|
entidadId: dto.entidadId,
|
||||||
|
tipoDocumento: dto.tipoDocumento,
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingDoc) {
|
||||||
|
throw new Error(
|
||||||
|
`Ya existe un documento de tipo ${dto.tipoDocumento} para esta entidad. ` +
|
||||||
|
`Use el metodo renovar() para actualizarlo.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documento = this.documentoRepository.create({
|
||||||
|
...dto,
|
||||||
|
tenantId,
|
||||||
|
diasAlertaVencimiento: dto.diasAlertaVencimiento || 30,
|
||||||
|
activo: true,
|
||||||
|
createdById,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.documentoRepository.save(documento);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar documento por ID
|
||||||
|
*/
|
||||||
|
async findById(tenantId: string, id: string): Promise<DocumentoFlota | null> {
|
||||||
|
return this.documentoRepository.findOne({
|
||||||
|
where: { tenantId, id, activo: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar documento por ID o lanzar error
|
||||||
|
*/
|
||||||
|
async findByIdOrFail(tenantId: string, id: string): Promise<DocumentoFlota> {
|
||||||
|
const documento = await this.findById(tenantId, id);
|
||||||
|
if (!documento) {
|
||||||
|
throw new Error(`Documento con ID ${id} no encontrado`);
|
||||||
|
}
|
||||||
|
return documento;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar documentos de una unidad
|
||||||
|
*/
|
||||||
|
async findByUnidad(tenantId: string, unidadId: string): Promise<DocumentoFlota[]> {
|
||||||
|
// Verificar que la unidad existe
|
||||||
|
const unidad = await this.unidadRepository.findOne({
|
||||||
|
where: { tenantId, id: unidadId },
|
||||||
|
});
|
||||||
|
if (!unidad) {
|
||||||
|
throw new Error(`Unidad con ID ${unidadId} no encontrada`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.documentoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: In([TipoEntidadDocumento.UNIDAD, TipoEntidadDocumento.REMOLQUE]),
|
||||||
|
entidadId: unidadId,
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
order: { fechaVencimiento: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar documentos de un operador
|
||||||
|
*/
|
||||||
|
async findByOperador(tenantId: string, operadorId: string): Promise<DocumentoFlota[]> {
|
||||||
|
// Verificar que el operador existe
|
||||||
|
const operador = await this.operadorRepository.findOne({
|
||||||
|
where: { tenantId, id: operadorId },
|
||||||
|
});
|
||||||
|
if (!operador) {
|
||||||
|
throw new Error(`Operador con ID ${operadorId} no encontrado`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.documentoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: TipoEntidadDocumento.OPERADOR,
|
||||||
|
entidadId: operadorId,
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
order: { fechaVencimiento: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar documentos por tipo
|
||||||
|
*/
|
||||||
|
async findByTipo(tenantId: string, tipo: TipoDocumento): Promise<DocumentoFlota[]> {
|
||||||
|
return this.documentoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
tipoDocumento: tipo,
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
order: { fechaVencimiento: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener documentos vencidos
|
||||||
|
*/
|
||||||
|
async findVencidos(tenantId: string): Promise<DocumentoFlota[]> {
|
||||||
|
const hoy = new Date();
|
||||||
|
hoy.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return this.documentoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
fechaVencimiento: LessThan(hoy),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
order: { fechaVencimiento: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener documentos por vencer en X dias
|
||||||
|
*/
|
||||||
|
async findPorVencer(tenantId: string, dias: number): Promise<DocumentoFlota[]> {
|
||||||
|
const hoy = new Date();
|
||||||
|
hoy.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const fechaLimite = new Date(hoy);
|
||||||
|
fechaLimite.setDate(fechaLimite.getDate() + dias);
|
||||||
|
|
||||||
|
return this.documentoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
fechaVencimiento: Between(hoy, fechaLimite),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
order: { fechaVencimiento: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar documento
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
dto: UpdateDocumentoFlotaDto,
|
||||||
|
updatedById: string
|
||||||
|
): Promise<DocumentoFlota> {
|
||||||
|
const documento = await this.findByIdOrFail(tenantId, id);
|
||||||
|
|
||||||
|
// Si se marca como verificado, registrar quien y cuando
|
||||||
|
if (dto.verificado && !documento.verificado) {
|
||||||
|
documento.verificado = true;
|
||||||
|
documento.verificadoPor = updatedById;
|
||||||
|
documento.verificadoFecha = new Date();
|
||||||
|
} else if (dto.verificado !== undefined) {
|
||||||
|
documento.verificado = dto.verificado;
|
||||||
|
if (!dto.verificado) {
|
||||||
|
documento.verificadoPor = undefined as unknown as string;
|
||||||
|
documento.verificadoFecha = undefined as unknown as Date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar otros campos
|
||||||
|
if (dto.nombre !== undefined) documento.nombre = dto.nombre;
|
||||||
|
if (dto.numeroDocumento !== undefined) documento.numeroDocumento = dto.numeroDocumento;
|
||||||
|
if (dto.descripcion !== undefined) documento.descripcion = dto.descripcion;
|
||||||
|
if (dto.fechaEmision !== undefined) documento.fechaEmision = dto.fechaEmision;
|
||||||
|
if (dto.fechaVencimiento !== undefined) documento.fechaVencimiento = dto.fechaVencimiento;
|
||||||
|
if (dto.diasAlertaVencimiento !== undefined) documento.diasAlertaVencimiento = dto.diasAlertaVencimiento;
|
||||||
|
if (dto.archivoUrl !== undefined) documento.archivoUrl = dto.archivoUrl;
|
||||||
|
if (dto.archivoNombre !== undefined) documento.archivoNombre = dto.archivoNombre;
|
||||||
|
if (dto.archivoTipo !== undefined) documento.archivoTipo = dto.archivoTipo;
|
||||||
|
if (dto.archivoTamanoBytes !== undefined) documento.archivoTamanoBytes = dto.archivoTamanoBytes;
|
||||||
|
|
||||||
|
return this.documentoRepository.save(documento);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renovar documento (actualiza fecha de vencimiento)
|
||||||
|
*/
|
||||||
|
async renovar(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
nuevaFechaVencimiento: Date,
|
||||||
|
updatedById: string
|
||||||
|
): Promise<DocumentoFlota> {
|
||||||
|
const documento = await this.findByIdOrFail(tenantId, id);
|
||||||
|
|
||||||
|
// Validar que la nueva fecha sea futura
|
||||||
|
const hoy = new Date();
|
||||||
|
if (nuevaFechaVencimiento <= hoy) {
|
||||||
|
throw new Error('La nueva fecha de vencimiento debe ser una fecha futura');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guardar la fecha anterior para historial (podria extenderse a tabla de historial)
|
||||||
|
const fechaAnterior = documento.fechaVencimiento;
|
||||||
|
|
||||||
|
documento.fechaVencimiento = nuevaFechaVencimiento;
|
||||||
|
documento.fechaEmision = new Date(); // Fecha de renovacion
|
||||||
|
documento.verificado = false; // Requiere nueva verificacion
|
||||||
|
documento.verificadoPor = undefined as unknown as string;
|
||||||
|
documento.verificadoFecha = undefined as unknown as Date;
|
||||||
|
|
||||||
|
// Agregar nota en descripcion sobre renovacion
|
||||||
|
const notaRenovacion = `\n[Renovado el ${new Date().toISOString().split('T')[0]} - Vigencia anterior: ${fechaAnterior?.toISOString().split('T')[0] || 'N/A'}]`;
|
||||||
|
documento.descripcion = (documento.descripcion || '') + notaRenovacion;
|
||||||
|
|
||||||
|
return this.documentoRepository.save(documento);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar documento (soft delete)
|
||||||
|
*/
|
||||||
|
async delete(tenantId: string, id: string): Promise<void> {
|
||||||
|
const documento = await this.findByIdOrFail(tenantId, id);
|
||||||
|
documento.activo = false;
|
||||||
|
await this.documentoRepository.save(documento);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// REPORTS & ALERTS
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener resumen de vencimientos por categoria
|
||||||
|
*/
|
||||||
|
async getResumenVencimientos(tenantId: string): Promise<ResumenVencimientos> {
|
||||||
|
const hoy = new Date();
|
||||||
|
hoy.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const fecha7Dias = new Date(hoy);
|
||||||
|
fecha7Dias.setDate(fecha7Dias.getDate() + 7);
|
||||||
|
|
||||||
|
const fecha15Dias = new Date(hoy);
|
||||||
|
fecha15Dias.setDate(fecha15Dias.getDate() + 15);
|
||||||
|
|
||||||
|
const fecha30Dias = new Date(hoy);
|
||||||
|
fecha30Dias.setDate(fecha30Dias.getDate() + 30);
|
||||||
|
|
||||||
|
// Total documentos activos
|
||||||
|
const totalDocumentos = await this.documentoRepository.count({
|
||||||
|
where: { tenantId, activo: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vencidos
|
||||||
|
const vencidos = await this.documentoRepository.count({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
fechaVencimiento: LessThan(hoy),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Por vencer en 7 dias
|
||||||
|
const porVencer7Dias = await this.documentoRepository.count({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
fechaVencimiento: Between(hoy, fecha7Dias),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Por vencer en 15 dias
|
||||||
|
const porVencer15Dias = await this.documentoRepository.count({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
fechaVencimiento: Between(hoy, fecha15Dias),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Por vencer en 30 dias
|
||||||
|
const porVencer30Dias = await this.documentoRepository.count({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
fechaVencimiento: Between(hoy, fecha30Dias),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Por categoria - Unidades
|
||||||
|
const unidadesVencidos = await this.documentoRepository.count({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: TipoEntidadDocumento.UNIDAD,
|
||||||
|
fechaVencimiento: LessThan(hoy),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const unidadesPorVencer = await this.documentoRepository.count({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: TipoEntidadDocumento.UNIDAD,
|
||||||
|
fechaVencimiento: Between(hoy, fecha30Dias),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Por categoria - Remolques
|
||||||
|
const remolquesVencidos = await this.documentoRepository.count({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: TipoEntidadDocumento.REMOLQUE,
|
||||||
|
fechaVencimiento: LessThan(hoy),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const remolquesPorVencer = await this.documentoRepository.count({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: TipoEntidadDocumento.REMOLQUE,
|
||||||
|
fechaVencimiento: Between(hoy, fecha30Dias),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Por categoria - Operadores
|
||||||
|
const operadoresVencidos = await this.documentoRepository.count({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: TipoEntidadDocumento.OPERADOR,
|
||||||
|
fechaVencimiento: LessThan(hoy),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const operadoresPorVencer = await this.documentoRepository.count({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: TipoEntidadDocumento.OPERADOR,
|
||||||
|
fechaVencimiento: Between(hoy, fecha30Dias),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Documentos criticos vencidos
|
||||||
|
const documentosCriticosVencidos = await this.getDocumentosCriticosVencidos(tenantId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalDocumentos,
|
||||||
|
vencidos,
|
||||||
|
porVencer7Dias,
|
||||||
|
porVencer15Dias,
|
||||||
|
porVencer30Dias,
|
||||||
|
porCategoria: {
|
||||||
|
unidades: { vencidos: unidadesVencidos, porVencer: unidadesPorVencer },
|
||||||
|
remolques: { vencidos: remolquesVencidos, porVencer: remolquesPorVencer },
|
||||||
|
operadores: { vencidos: operadoresVencidos, porVencer: operadoresPorVencer },
|
||||||
|
},
|
||||||
|
documentosCriticosVencidos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener unidades/operadores con documentos criticos vencidos (bloqueo operativo)
|
||||||
|
*/
|
||||||
|
async bloquearPorDocumentosVencidos(tenantId: string): Promise<EntidadBloqueada[]> {
|
||||||
|
const hoy = new Date();
|
||||||
|
hoy.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const entidadesBloqueadas: EntidadBloqueada[] = [];
|
||||||
|
|
||||||
|
// Obtener documentos criticos vencidos de unidades
|
||||||
|
const docsUnidades = await this.documentoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: In([TipoEntidadDocumento.UNIDAD, TipoEntidadDocumento.REMOLQUE]),
|
||||||
|
tipoDocumento: In(DOCUMENTOS_CRITICOS_UNIDAD),
|
||||||
|
fechaVencimiento: LessThan(hoy),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por entidad
|
||||||
|
const unidadesMap = new Map<string, DocumentoFlota[]>();
|
||||||
|
for (const doc of docsUnidades) {
|
||||||
|
const docs = unidadesMap.get(doc.entidadId) || [];
|
||||||
|
docs.push(doc);
|
||||||
|
unidadesMap.set(doc.entidadId, docs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar unidades
|
||||||
|
for (const [entidadId, docs] of unidadesMap) {
|
||||||
|
const unidad = await this.unidadRepository.findOne({
|
||||||
|
where: { tenantId, id: entidadId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unidad && unidad.activo) {
|
||||||
|
entidadesBloqueadas.push({
|
||||||
|
entidadTipo: docs[0].entidadTipo,
|
||||||
|
entidadId,
|
||||||
|
entidadNombre: `${unidad.numeroEconomico} - ${unidad.marca || ''} ${unidad.modelo || ''}`.trim(),
|
||||||
|
documentosVencidos: docs,
|
||||||
|
motivoBloqueo: docs.map(d => `${d.tipoDocumento}: vencido`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener documentos criticos vencidos de operadores
|
||||||
|
const docsOperadores = await this.documentoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: TipoEntidadDocumento.OPERADOR,
|
||||||
|
tipoDocumento: In(DOCUMENTOS_CRITICOS_OPERADOR),
|
||||||
|
fechaVencimiento: LessThan(hoy),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por operador
|
||||||
|
const operadoresMap = new Map<string, DocumentoFlota[]>();
|
||||||
|
for (const doc of docsOperadores) {
|
||||||
|
const docs = operadoresMap.get(doc.entidadId) || [];
|
||||||
|
docs.push(doc);
|
||||||
|
operadoresMap.set(doc.entidadId, docs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar operadores
|
||||||
|
for (const [entidadId, docs] of operadoresMap) {
|
||||||
|
const operador = await this.operadorRepository.findOne({
|
||||||
|
where: { tenantId, id: entidadId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (operador && operador.activo) {
|
||||||
|
entidadesBloqueadas.push({
|
||||||
|
entidadTipo: TipoEntidadDocumento.OPERADOR,
|
||||||
|
entidadId,
|
||||||
|
entidadNombre: `${operador.numeroEmpleado} - ${operador.nombre} ${operador.apellidoPaterno}`,
|
||||||
|
documentosVencidos: docs,
|
||||||
|
motivoBloqueo: docs.map(d => `${d.tipoDocumento}: vencido`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entidadesBloqueadas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enviar alertas de vencimiento (retorna lista de alertas a enviar)
|
||||||
|
*/
|
||||||
|
async sendAlertasVencimiento(
|
||||||
|
tenantId: string,
|
||||||
|
diasAnticipacion: number = 30
|
||||||
|
): Promise<DocumentoVencimiento[]> {
|
||||||
|
const hoy = new Date();
|
||||||
|
hoy.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const fechaLimite = new Date(hoy);
|
||||||
|
fechaLimite.setDate(fechaLimite.getDate() + diasAnticipacion);
|
||||||
|
|
||||||
|
// Obtener documentos que vencen dentro del rango
|
||||||
|
const documentos = await this.documentoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
fechaVencimiento: Between(hoy, fechaLimite),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
order: { fechaVencimiento: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const alertas: DocumentoVencimiento[] = [];
|
||||||
|
|
||||||
|
for (const doc of documentos) {
|
||||||
|
const diasParaVencer = Math.ceil(
|
||||||
|
(doc.fechaVencimiento.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Solo alertar si esta dentro del periodo de alerta configurado del documento
|
||||||
|
if (diasParaVencer <= doc.diasAlertaVencimiento) {
|
||||||
|
const entidadNombre = await this.getEntidadNombre(tenantId, doc.entidadTipo, doc.entidadId);
|
||||||
|
const esCritico = this.esDocumentoCritico(doc.entidadTipo, doc.tipoDocumento);
|
||||||
|
|
||||||
|
alertas.push({
|
||||||
|
documento: doc,
|
||||||
|
entidadTipo: doc.entidadTipo,
|
||||||
|
entidadId: doc.entidadId,
|
||||||
|
entidadNombre,
|
||||||
|
diasParaVencer,
|
||||||
|
esCritico,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar: criticos primero, luego por dias para vencer
|
||||||
|
alertas.sort((a, b) => {
|
||||||
|
if (a.esCritico !== b.esCritico) {
|
||||||
|
return a.esCritico ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.diasParaVencer - b.diasParaVencer;
|
||||||
|
});
|
||||||
|
|
||||||
|
return alertas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// HELPER METHODS
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar que la entidad existe
|
||||||
|
*/
|
||||||
|
private async validateEntidadExists(
|
||||||
|
tenantId: string,
|
||||||
|
entidadTipo: TipoEntidadDocumento,
|
||||||
|
entidadId: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (entidadTipo === TipoEntidadDocumento.UNIDAD || entidadTipo === TipoEntidadDocumento.REMOLQUE) {
|
||||||
|
const unidad = await this.unidadRepository.findOne({
|
||||||
|
where: { tenantId, id: entidadId },
|
||||||
|
});
|
||||||
|
if (!unidad) {
|
||||||
|
throw new Error(`Unidad/Remolque con ID ${entidadId} no encontrada`);
|
||||||
|
}
|
||||||
|
} else if (entidadTipo === TipoEntidadDocumento.OPERADOR) {
|
||||||
|
const operador = await this.operadorRepository.findOne({
|
||||||
|
where: { tenantId, id: entidadId },
|
||||||
|
});
|
||||||
|
if (!operador) {
|
||||||
|
throw new Error(`Operador con ID ${entidadId} no encontrado`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener nombre de la entidad para mostrar
|
||||||
|
*/
|
||||||
|
private async getEntidadNombre(
|
||||||
|
tenantId: string,
|
||||||
|
entidadTipo: TipoEntidadDocumento,
|
||||||
|
entidadId: string
|
||||||
|
): Promise<string> {
|
||||||
|
if (entidadTipo === TipoEntidadDocumento.UNIDAD || entidadTipo === TipoEntidadDocumento.REMOLQUE) {
|
||||||
|
const unidad = await this.unidadRepository.findOne({
|
||||||
|
where: { tenantId, id: entidadId },
|
||||||
|
});
|
||||||
|
if (unidad) {
|
||||||
|
return `${unidad.numeroEconomico} - ${unidad.marca || ''} ${unidad.modelo || ''}`.trim();
|
||||||
|
}
|
||||||
|
} else if (entidadTipo === TipoEntidadDocumento.OPERADOR) {
|
||||||
|
const operador = await this.operadorRepository.findOne({
|
||||||
|
where: { tenantId, id: entidadId },
|
||||||
|
});
|
||||||
|
if (operador) {
|
||||||
|
return `${operador.numeroEmpleado} - ${operador.nombre} ${operador.apellidoPaterno}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Entidad no encontrada';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determinar si un documento es critico
|
||||||
|
*/
|
||||||
|
private esDocumentoCritico(entidadTipo: TipoEntidadDocumento, tipoDocumento: TipoDocumento): boolean {
|
||||||
|
if (entidadTipo === TipoEntidadDocumento.UNIDAD || entidadTipo === TipoEntidadDocumento.REMOLQUE) {
|
||||||
|
return DOCUMENTOS_CRITICOS_UNIDAD.includes(tipoDocumento);
|
||||||
|
} else if (entidadTipo === TipoEntidadDocumento.OPERADOR) {
|
||||||
|
return DOCUMENTOS_CRITICOS_OPERADOR.includes(tipoDocumento);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener documentos criticos vencidos con detalles
|
||||||
|
*/
|
||||||
|
private async getDocumentosCriticosVencidos(tenantId: string): Promise<DocumentoVencimiento[]> {
|
||||||
|
const hoy = new Date();
|
||||||
|
hoy.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Documentos criticos vencidos
|
||||||
|
const documentos = await this.documentoRepository.find({
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: In([TipoEntidadDocumento.UNIDAD, TipoEntidadDocumento.REMOLQUE]),
|
||||||
|
tipoDocumento: In(DOCUMENTOS_CRITICOS_UNIDAD),
|
||||||
|
fechaVencimiento: LessThan(hoy),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenantId,
|
||||||
|
entidadTipo: TipoEntidadDocumento.OPERADOR,
|
||||||
|
tipoDocumento: In(DOCUMENTOS_CRITICOS_OPERADOR),
|
||||||
|
fechaVencimiento: LessThan(hoy),
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: DocumentoVencimiento[] = [];
|
||||||
|
|
||||||
|
for (const doc of documentos) {
|
||||||
|
const diasParaVencer = Math.ceil(
|
||||||
|
(doc.fechaVencimiento.getTime() - hoy.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
const entidadNombre = await this.getEntidadNombre(tenantId, doc.entidadTipo, doc.entidadId);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
documento: doc,
|
||||||
|
entidadTipo: doc.entidadTipo,
|
||||||
|
entidadId: doc.entidadId,
|
||||||
|
entidadNombre,
|
||||||
|
diasParaVencer, // Sera negativo si ya vencio
|
||||||
|
esCritico: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,3 +10,5 @@ export { OperadoresService, OperadorSearchParams } from './operadores.service';
|
|||||||
// Enhanced services (full implementation with DTOs)
|
// Enhanced services (full implementation with DTOs)
|
||||||
export * from './unidad.service';
|
export * from './unidad.service';
|
||||||
export * from './operador.service';
|
export * from './operador.service';
|
||||||
|
export * from './documento-flota.service';
|
||||||
|
export * from './asignacion.service';
|
||||||
|
|||||||
488
src/modules/gps/services/evento-geocerca.service.ts
Normal file
488
src/modules/gps/services/evento-geocerca.service.ts
Normal file
@ -0,0 +1,488 @@
|
|||||||
|
/**
|
||||||
|
* EventoGeocerca Service
|
||||||
|
* ERP Transportistas
|
||||||
|
*
|
||||||
|
* Business logic for geofence events (entry/exit/permanence).
|
||||||
|
* Adapted from erp-mecanicas-diesel MMD-014 GPS Integration
|
||||||
|
* Module: MAI-006 Tracking
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource, Between, IsNull, Not } from 'typeorm';
|
||||||
|
import { EventoGeocerca, TipoEventoGeocerca } from '../entities/evento-geocerca.entity';
|
||||||
|
import { DispositivoGps } from '../entities/dispositivo-gps.entity';
|
||||||
|
import { PosicionGps } from '../entities/posicion-gps.entity';
|
||||||
|
|
||||||
|
// ==================== DTOs ====================
|
||||||
|
|
||||||
|
export interface CreateEventoGeocercaDto {
|
||||||
|
geocercaId: string;
|
||||||
|
dispositivoId: string;
|
||||||
|
unidadId: string;
|
||||||
|
tipoEvento: TipoEventoGeocerca;
|
||||||
|
posicionId?: string;
|
||||||
|
latitud: number;
|
||||||
|
longitud: number;
|
||||||
|
tiempoEvento: Date;
|
||||||
|
viajeId?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
fechaInicio: Date;
|
||||||
|
fechaFin: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventoGeocercaFilters {
|
||||||
|
geocercaId?: string;
|
||||||
|
dispositivoId?: string;
|
||||||
|
unidadId?: string;
|
||||||
|
tipoEvento?: TipoEventoGeocerca;
|
||||||
|
viajeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TiempoEnGeocerca {
|
||||||
|
geocercaId: string;
|
||||||
|
unidadId: string;
|
||||||
|
tiempoTotalMinutos: number;
|
||||||
|
visitasTotales: number;
|
||||||
|
promedioMinutosPorVisita: number;
|
||||||
|
ultimaEntrada?: Date;
|
||||||
|
ultimaSalida?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnidadEnGeocerca {
|
||||||
|
unidadId: string;
|
||||||
|
dispositivoId: string;
|
||||||
|
horaEntrada: Date;
|
||||||
|
tiempoTranscurridoMinutos: number;
|
||||||
|
latitud: number;
|
||||||
|
longitud: number;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeocercaEstadisticas {
|
||||||
|
totalEventos: number;
|
||||||
|
totalEntradas: number;
|
||||||
|
totalSalidas: number;
|
||||||
|
totalPermanencias: number;
|
||||||
|
unidadesUnicas: number;
|
||||||
|
tiempoPromedioMinutos: number;
|
||||||
|
alertasGeneradas: number;
|
||||||
|
periodoInicio: Date;
|
||||||
|
periodoFin: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Service ====================
|
||||||
|
|
||||||
|
export class EventoGeocercaService {
|
||||||
|
private eventoGeocercaRepository: Repository<EventoGeocerca>;
|
||||||
|
private dispositivoRepository: Repository<DispositivoGps>;
|
||||||
|
private posicionRepository: Repository<PosicionGps>;
|
||||||
|
|
||||||
|
constructor(dataSource: DataSource) {
|
||||||
|
this.eventoGeocercaRepository = dataSource.getRepository(EventoGeocerca);
|
||||||
|
this.dispositivoRepository = dataSource.getRepository(DispositivoGps);
|
||||||
|
this.posicionRepository = dataSource.getRepository(PosicionGps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a new geofence event (entry/exit)
|
||||||
|
*/
|
||||||
|
async create(tenantId: string, data: CreateEventoGeocercaDto): Promise<EventoGeocerca> {
|
||||||
|
// Validate device exists
|
||||||
|
const dispositivo = await this.dispositivoRepository.findOne({
|
||||||
|
where: { id: data.dispositivoId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dispositivo) {
|
||||||
|
throw new Error(`Dispositivo ${data.dispositivoId} no encontrado`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const evento = this.eventoGeocercaRepository.create({
|
||||||
|
tenantId,
|
||||||
|
geocercaId: data.geocercaId,
|
||||||
|
dispositivoId: data.dispositivoId,
|
||||||
|
unidadId: data.unidadId,
|
||||||
|
tipoEvento: data.tipoEvento,
|
||||||
|
posicionId: data.posicionId,
|
||||||
|
latitud: data.latitud,
|
||||||
|
longitud: data.longitud,
|
||||||
|
tiempoEvento: data.tiempoEvento,
|
||||||
|
viajeId: data.viajeId,
|
||||||
|
metadata: data.metadata || {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.eventoGeocercaRepository.save(evento);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find events for a specific geofence in date range
|
||||||
|
*/
|
||||||
|
async findByGeocerca(
|
||||||
|
tenantId: string,
|
||||||
|
geocercaId: string,
|
||||||
|
dateRange: DateRange
|
||||||
|
): Promise<EventoGeocerca[]> {
|
||||||
|
return this.eventoGeocercaRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
geocercaId,
|
||||||
|
tiempoEvento: Between(dateRange.fechaInicio, dateRange.fechaFin),
|
||||||
|
},
|
||||||
|
order: { tiempoEvento: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find events for a specific vehicle/unit in date range
|
||||||
|
*/
|
||||||
|
async findByUnidad(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string,
|
||||||
|
dateRange: DateRange
|
||||||
|
): Promise<EventoGeocerca[]> {
|
||||||
|
return this.eventoGeocercaRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
unidadId,
|
||||||
|
tiempoEvento: Between(dateRange.fechaInicio, dateRange.fechaFin),
|
||||||
|
},
|
||||||
|
relations: ['dispositivo'],
|
||||||
|
order: { tiempoEvento: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find recent geofence events across fleet
|
||||||
|
*/
|
||||||
|
async findRecientes(tenantId: string, limit: number = 50): Promise<EventoGeocerca[]> {
|
||||||
|
return this.eventoGeocercaRepository.find({
|
||||||
|
where: { tenantId },
|
||||||
|
relations: ['dispositivo'],
|
||||||
|
order: { tiempoEvento: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get entry events only for a geofence
|
||||||
|
*/
|
||||||
|
async getEntradas(
|
||||||
|
tenantId: string,
|
||||||
|
geocercaId: string,
|
||||||
|
dateRange: DateRange
|
||||||
|
): Promise<EventoGeocerca[]> {
|
||||||
|
return this.eventoGeocercaRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
geocercaId,
|
||||||
|
tipoEvento: TipoEventoGeocerca.ENTRADA,
|
||||||
|
tiempoEvento: Between(dateRange.fechaInicio, dateRange.fechaFin),
|
||||||
|
},
|
||||||
|
order: { tiempoEvento: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get exit events only for a geofence
|
||||||
|
*/
|
||||||
|
async getSalidas(
|
||||||
|
tenantId: string,
|
||||||
|
geocercaId: string,
|
||||||
|
dateRange: DateRange
|
||||||
|
): Promise<EventoGeocerca[]> {
|
||||||
|
return this.eventoGeocercaRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
geocercaId,
|
||||||
|
tipoEvento: TipoEventoGeocerca.SALIDA,
|
||||||
|
tiempoEvento: Between(dateRange.fechaInicio, dateRange.fechaFin),
|
||||||
|
},
|
||||||
|
order: { tiempoEvento: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate time spent in a geofence by a unit
|
||||||
|
*/
|
||||||
|
async getTiempoEnGeocerca(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string,
|
||||||
|
geocercaId: string
|
||||||
|
): Promise<TiempoEnGeocerca> {
|
||||||
|
// Get all entry/exit events for this unit in this geofence
|
||||||
|
const eventos = await this.eventoGeocercaRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
unidadId,
|
||||||
|
geocercaId,
|
||||||
|
tipoEvento: Not(TipoEventoGeocerca.PERMANENCIA),
|
||||||
|
},
|
||||||
|
order: { tiempoEvento: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let tiempoTotalMinutos = 0;
|
||||||
|
let visitasTotales = 0;
|
||||||
|
let ultimaEntrada: Date | undefined;
|
||||||
|
let ultimaSalida: Date | undefined;
|
||||||
|
let entradaActual: Date | null = null;
|
||||||
|
|
||||||
|
for (const evento of eventos) {
|
||||||
|
if (evento.tipoEvento === TipoEventoGeocerca.ENTRADA) {
|
||||||
|
entradaActual = evento.tiempoEvento;
|
||||||
|
ultimaEntrada = evento.tiempoEvento;
|
||||||
|
} else if (evento.tipoEvento === TipoEventoGeocerca.SALIDA && entradaActual) {
|
||||||
|
const duracion = (evento.tiempoEvento.getTime() - entradaActual.getTime()) / (1000 * 60);
|
||||||
|
tiempoTotalMinutos += duracion;
|
||||||
|
visitasTotales++;
|
||||||
|
ultimaSalida = evento.tiempoEvento;
|
||||||
|
entradaActual = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still inside (entry without exit), count time until now
|
||||||
|
if (entradaActual) {
|
||||||
|
const now = new Date();
|
||||||
|
const duracion = (now.getTime() - entradaActual.getTime()) / (1000 * 60);
|
||||||
|
tiempoTotalMinutos += duracion;
|
||||||
|
visitasTotales++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promedioMinutosPorVisita = visitasTotales > 0
|
||||||
|
? tiempoTotalMinutos / visitasTotales
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
geocercaId,
|
||||||
|
unidadId,
|
||||||
|
tiempoTotalMinutos: Math.round(tiempoTotalMinutos * 100) / 100,
|
||||||
|
visitasTotales,
|
||||||
|
promedioMinutosPorVisita: Math.round(promedioMinutosPorVisita * 100) / 100,
|
||||||
|
ultimaEntrada,
|
||||||
|
ultimaSalida,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get units currently inside a geofence
|
||||||
|
*/
|
||||||
|
async getUnidadesEnGeocerca(
|
||||||
|
tenantId: string,
|
||||||
|
geocercaId: string
|
||||||
|
): Promise<UnidadEnGeocerca[]> {
|
||||||
|
// Find the latest event for each unit in this geofence
|
||||||
|
const subQuery = this.eventoGeocercaRepository
|
||||||
|
.createQueryBuilder('sub')
|
||||||
|
.select('sub.unidad_id', 'unidad_id')
|
||||||
|
.addSelect('MAX(sub.tiempo_evento)', 'max_tiempo')
|
||||||
|
.where('sub.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('sub.geocerca_id = :geocercaId', { geocercaId })
|
||||||
|
.groupBy('sub.unidad_id');
|
||||||
|
|
||||||
|
const latestEvents = await this.eventoGeocercaRepository
|
||||||
|
.createQueryBuilder('evento')
|
||||||
|
.innerJoin(
|
||||||
|
`(${subQuery.getQuery()})`,
|
||||||
|
'latest',
|
||||||
|
'evento.unidad_id = latest.unidad_id AND evento.tiempo_evento = latest.max_tiempo'
|
||||||
|
)
|
||||||
|
.setParameters({ tenantId, geocercaId })
|
||||||
|
.where('evento.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('evento.geocerca_id = :geocercaId', { geocercaId })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Filter only those where last event was ENTRADA
|
||||||
|
const unidadesDentro: UnidadEnGeocerca[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const evento of latestEvents) {
|
||||||
|
if (evento.tipoEvento === TipoEventoGeocerca.ENTRADA) {
|
||||||
|
const tiempoTranscurridoMinutos =
|
||||||
|
(now.getTime() - evento.tiempoEvento.getTime()) / (1000 * 60);
|
||||||
|
|
||||||
|
unidadesDentro.push({
|
||||||
|
unidadId: evento.unidadId,
|
||||||
|
dispositivoId: evento.dispositivoId,
|
||||||
|
horaEntrada: evento.tiempoEvento,
|
||||||
|
tiempoTranscurridoMinutos: Math.round(tiempoTranscurridoMinutos * 100) / 100,
|
||||||
|
latitud: Number(evento.latitud),
|
||||||
|
longitud: Number(evento.longitud),
|
||||||
|
metadata: evento.metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unidadesDentro;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an alert for a geofence event
|
||||||
|
*/
|
||||||
|
async triggerAlerta(tenantId: string, eventoId: string): Promise<EventoGeocerca | null> {
|
||||||
|
const evento = await this.eventoGeocercaRepository.findOne({
|
||||||
|
where: { id: eventoId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!evento) return null;
|
||||||
|
|
||||||
|
// Mark the event as having triggered an alert
|
||||||
|
evento.metadata = {
|
||||||
|
...evento.metadata,
|
||||||
|
alertaTriggered: true,
|
||||||
|
alertaTriggeredAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.eventoGeocercaRepository.save(evento);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics for a geofence
|
||||||
|
*/
|
||||||
|
async getEstadisticas(
|
||||||
|
tenantId: string,
|
||||||
|
geocercaId: string,
|
||||||
|
dateRange: DateRange
|
||||||
|
): Promise<GeocercaEstadisticas> {
|
||||||
|
const eventos = await this.eventoGeocercaRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
geocercaId,
|
||||||
|
tiempoEvento: Between(dateRange.fechaInicio, dateRange.fechaFin),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalEntradas = 0;
|
||||||
|
let totalSalidas = 0;
|
||||||
|
let totalPermanencias = 0;
|
||||||
|
let alertasGeneradas = 0;
|
||||||
|
const unidadesSet = new Set<string>();
|
||||||
|
const tiemposVisita: number[] = [];
|
||||||
|
let entradaActualPorUnidad: Record<string, Date> = {};
|
||||||
|
|
||||||
|
// Sort events by time for proper entry/exit pairing
|
||||||
|
const eventosOrdenados = [...eventos].sort(
|
||||||
|
(a, b) => a.tiempoEvento.getTime() - b.tiempoEvento.getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const evento of eventosOrdenados) {
|
||||||
|
unidadesSet.add(evento.unidadId);
|
||||||
|
|
||||||
|
if (evento.metadata?.alertaTriggered) {
|
||||||
|
alertasGeneradas++;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (evento.tipoEvento) {
|
||||||
|
case TipoEventoGeocerca.ENTRADA:
|
||||||
|
totalEntradas++;
|
||||||
|
entradaActualPorUnidad[evento.unidadId] = evento.tiempoEvento;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TipoEventoGeocerca.SALIDA:
|
||||||
|
totalSalidas++;
|
||||||
|
if (entradaActualPorUnidad[evento.unidadId]) {
|
||||||
|
const duracion =
|
||||||
|
(evento.tiempoEvento.getTime() - entradaActualPorUnidad[evento.unidadId].getTime()) /
|
||||||
|
(1000 * 60);
|
||||||
|
tiemposVisita.push(duracion);
|
||||||
|
delete entradaActualPorUnidad[evento.unidadId];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TipoEventoGeocerca.PERMANENCIA:
|
||||||
|
totalPermanencias++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiempoPromedioMinutos =
|
||||||
|
tiemposVisita.length > 0
|
||||||
|
? tiemposVisita.reduce((a, b) => a + b, 0) / tiemposVisita.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalEventos: eventos.length,
|
||||||
|
totalEntradas,
|
||||||
|
totalSalidas,
|
||||||
|
totalPermanencias,
|
||||||
|
unidadesUnicas: unidadesSet.size,
|
||||||
|
tiempoPromedioMinutos: Math.round(tiempoPromedioMinutos * 100) / 100,
|
||||||
|
alertasGeneradas,
|
||||||
|
periodoInicio: dateRange.fechaInicio,
|
||||||
|
periodoFin: dateRange.fechaFin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find events with filters
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
filters: EventoGeocercaFilters = {},
|
||||||
|
pagination = { page: 1, limit: 50 }
|
||||||
|
) {
|
||||||
|
const queryBuilder = this.eventoGeocercaRepository
|
||||||
|
.createQueryBuilder('evento')
|
||||||
|
.where('evento.tenant_id = :tenantId', { tenantId });
|
||||||
|
|
||||||
|
if (filters.geocercaId) {
|
||||||
|
queryBuilder.andWhere('evento.geocerca_id = :geocercaId', {
|
||||||
|
geocercaId: filters.geocercaId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.dispositivoId) {
|
||||||
|
queryBuilder.andWhere('evento.dispositivo_id = :dispositivoId', {
|
||||||
|
dispositivoId: filters.dispositivoId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.unidadId) {
|
||||||
|
queryBuilder.andWhere('evento.unidad_id = :unidadId', {
|
||||||
|
unidadId: filters.unidadId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.tipoEvento) {
|
||||||
|
queryBuilder.andWhere('evento.tipo_evento = :tipoEvento', {
|
||||||
|
tipoEvento: filters.tipoEvento,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.viajeId) {
|
||||||
|
queryBuilder.andWhere('evento.viaje_id = :viajeId', {
|
||||||
|
viajeId: filters.viajeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (pagination.page - 1) * pagination.limit;
|
||||||
|
|
||||||
|
const [data, total] = await queryBuilder
|
||||||
|
.leftJoinAndSelect('evento.dispositivo', 'dispositivo')
|
||||||
|
.orderBy('evento.tiempo_evento', 'DESC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(pagination.limit)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page: pagination.page,
|
||||||
|
limit: pagination.limit,
|
||||||
|
totalPages: Math.ceil(total / pagination.limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete old events (for data retention)
|
||||||
|
*/
|
||||||
|
async eliminarEventosAntiguos(tenantId: string, antesDe: Date): Promise<number> {
|
||||||
|
const result = await this.eventoGeocercaRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where('tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('tiempo_evento < :antesDe', { antesDe })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return result.affected || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,3 +26,13 @@ export {
|
|||||||
SegmentoRutaFilters,
|
SegmentoRutaFilters,
|
||||||
RutaCalculada,
|
RutaCalculada,
|
||||||
} from './segmento-ruta.service';
|
} from './segmento-ruta.service';
|
||||||
|
|
||||||
|
export {
|
||||||
|
EventoGeocercaService,
|
||||||
|
CreateEventoGeocercaDto,
|
||||||
|
DateRange as GeocercaDateRange,
|
||||||
|
EventoGeocercaFilters,
|
||||||
|
TiempoEnGeocerca,
|
||||||
|
UnidadEnGeocerca,
|
||||||
|
GeocercaEstadisticas,
|
||||||
|
} from './evento-geocerca.service';
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { Request, Response, NextFunction, Router } from 'express';
|
import { Request, Response, NextFunction, Router } from 'express';
|
||||||
import { OrdenesTransporteService, CreateOrdenTransporteDto, UpdateOrdenTransporteDto } from '../services/ordenes-transporte.service';
|
import {
|
||||||
|
OrdenesTransporteService,
|
||||||
|
CreateOrdenTransporteDto,
|
||||||
|
UpdateOrdenTransporteDto,
|
||||||
|
AsignarUnidadDto,
|
||||||
|
} from '../services/ordenes-transporte.service';
|
||||||
import { EstadoOrdenTransporte, ModalidadServicio } from '../entities';
|
import { EstadoOrdenTransporte, ModalidadServicio } from '../entities';
|
||||||
|
|
||||||
export class OrdenesTransporteController {
|
export class OrdenesTransporteController {
|
||||||
@ -26,6 +31,11 @@ export class OrdenesTransporteController {
|
|||||||
this.router.post('/:id/confirmar', this.confirmar.bind(this));
|
this.router.post('/:id/confirmar', this.confirmar.bind(this));
|
||||||
this.router.post('/:id/asignar', this.asignar.bind(this));
|
this.router.post('/:id/asignar', this.asignar.bind(this));
|
||||||
this.router.post('/:id/cancelar', this.cancelar.bind(this));
|
this.router.post('/:id/cancelar', this.cancelar.bind(this));
|
||||||
|
|
||||||
|
// MAI-003: Nuevas operaciones de asignacion y tarifa
|
||||||
|
this.router.post('/:id/asignar-unidad', this.asignarUnidad.bind(this));
|
||||||
|
this.router.get('/:id/calcular-tarifa', this.calcularTarifa.bind(this));
|
||||||
|
this.router.get('/:id/estadisticas', this.getEstadisticas.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findAll(req: Request, res: Response, next: NextFunction): Promise<void> {
|
private async findAll(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
@ -329,4 +339,129 @@ export class OrdenesTransporteController {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /:id/asignar-unidad
|
||||||
|
* Asignar unidad y operador a una OT, creando el viaje asociado
|
||||||
|
*/
|
||||||
|
private async asignarUnidad(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(400).json({ error: 'User ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { unidadId, operadorId, remolqueId, fechaSalidaProgramada, fechaLlegadaProgramada } = req.body;
|
||||||
|
|
||||||
|
if (!unidadId) {
|
||||||
|
res.status(400).json({ error: 'unidadId es requerido' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!operadorId) {
|
||||||
|
res.status(400).json({ error: 'operadorId es requerido' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: AsignarUnidadDto = {
|
||||||
|
unidadId,
|
||||||
|
operadorId,
|
||||||
|
remolqueId,
|
||||||
|
fechaSalidaProgramada: fechaSalidaProgramada ? new Date(fechaSalidaProgramada) : undefined,
|
||||||
|
fechaLlegadaProgramada: fechaLlegadaProgramada ? new Date(fechaLlegadaProgramada) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.otService.asignarUnidad(id, dto, { tenantId, userId });
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({ error: 'Orden de transporte no encontrada' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: result.orden,
|
||||||
|
viajeId: result.viajeId,
|
||||||
|
message: `Orden asignada exitosamente a viaje ${result.viajeId}`,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes('no disponible') ||
|
||||||
|
error.message.includes('documentos vencidos') ||
|
||||||
|
error.message.includes('No se puede asignar')) {
|
||||||
|
res.status(422).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:id/calcular-tarifa
|
||||||
|
* Calcular tarifa para una OT basada en lane y recargos
|
||||||
|
*/
|
||||||
|
private async calcularTarifa(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string || 'system';
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const tarifa = await this.otService.calcularTarifa(id, { tenantId, userId });
|
||||||
|
|
||||||
|
if (!tarifa) {
|
||||||
|
res.status(404).json({ error: 'Orden de transporte no encontrada' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: tarifa });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes('no encontrada')) {
|
||||||
|
res.status(404).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /:id/estadisticas
|
||||||
|
* Obtener estadisticas de ordenes de transporte
|
||||||
|
*/
|
||||||
|
private async getEstadisticas(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fechaDesde, fechaHasta } = req.query;
|
||||||
|
|
||||||
|
// Por defecto, ultimos 30 dias
|
||||||
|
const to = fechaHasta ? new Date(fechaHasta as string) : new Date();
|
||||||
|
const from = fechaDesde
|
||||||
|
? new Date(fechaDesde as string)
|
||||||
|
: new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const stats = await this.otService.getStatistics(tenantId, { from, to });
|
||||||
|
|
||||||
|
res.json({ data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,6 +138,70 @@ export interface DateRange {
|
|||||||
to: Date;
|
to: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexto de servicio para operaciones con tenant y usuario
|
||||||
|
*/
|
||||||
|
export interface ServiceContext {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
correlationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para asignar unidad y operador a una OT
|
||||||
|
*/
|
||||||
|
export interface AsignarUnidadDto {
|
||||||
|
unidadId: string;
|
||||||
|
operadorId: string;
|
||||||
|
remolqueId?: string;
|
||||||
|
fechaSalidaProgramada?: Date;
|
||||||
|
fechaLlegadaProgramada?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado del calculo de tarifa
|
||||||
|
*/
|
||||||
|
export interface TarifaCalculada {
|
||||||
|
ordenId: string;
|
||||||
|
tarifaId?: string;
|
||||||
|
tarifaCodigo?: string;
|
||||||
|
laneId?: string;
|
||||||
|
// Desglose de tarifa
|
||||||
|
tarifaBase: number;
|
||||||
|
montoVariable: number;
|
||||||
|
// Recargos
|
||||||
|
recargoCombustible: number;
|
||||||
|
recargoManiobras: number;
|
||||||
|
recargoEspera: number;
|
||||||
|
recargoOtros: number;
|
||||||
|
totalRecargos: number;
|
||||||
|
// Descuentos
|
||||||
|
descuentos: number;
|
||||||
|
// Totales
|
||||||
|
subtotal: number;
|
||||||
|
iva: number;
|
||||||
|
ivaRate: number;
|
||||||
|
total: number;
|
||||||
|
moneda: string;
|
||||||
|
// Informacion adicional
|
||||||
|
distanciaEstimadaKm?: number;
|
||||||
|
tiempoEstimadoHoras?: number;
|
||||||
|
minimoAplicado: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para validacion de documentos de operador
|
||||||
|
*/
|
||||||
|
export interface DocumentosOperadorStatus {
|
||||||
|
operadorId: string;
|
||||||
|
documentosVigentes: boolean;
|
||||||
|
licenciaVigente: boolean;
|
||||||
|
certificadoFisicoVigente: boolean;
|
||||||
|
antidopingVigente: boolean;
|
||||||
|
capacitacionMpVigente?: boolean;
|
||||||
|
documentosVencidos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SERVICIO ORDENES DE TRANSPORTE
|
// SERVICIO ORDENES DE TRANSPORTE
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@ -812,6 +876,585 @@ export class OrdenesTransporteService {
|
|||||||
return this.changeStatus(tenantId, id, EstadoOrdenTransporte.ENTREGADA, deliveredById);
|
return this.changeStatus(tenantId, id, EstadoOrdenTransporte.ENTREGADA, deliveredById);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// ASIGNACION DE UNIDAD Y OPERADOR (MAI-003 REQUERIMIENTO)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asignar unidad y operador a una orden de transporte.
|
||||||
|
* Crea un viaje asociado y valida:
|
||||||
|
* - Unidad disponible
|
||||||
|
* - Operador con documentos vigentes
|
||||||
|
* - Orden en estado CONFIRMADA
|
||||||
|
*
|
||||||
|
* @param id - ID de la orden de transporte
|
||||||
|
* @param dto - Datos de asignacion (unidadId, operadorId, remolqueId opcional)
|
||||||
|
* @param ctx - Contexto del servicio (tenantId, userId)
|
||||||
|
* @returns Objeto con la orden actualizada y el viaje creado
|
||||||
|
*/
|
||||||
|
async asignarUnidad(
|
||||||
|
id: string,
|
||||||
|
dto: AsignarUnidadDto,
|
||||||
|
ctx: ServiceContext
|
||||||
|
): Promise<{ orden: OrdenTransporte; viajeId: string } | null> {
|
||||||
|
const { tenantId, userId } = ctx;
|
||||||
|
|
||||||
|
// 1. Obtener la orden
|
||||||
|
const orden = await this.findById(tenantId, id);
|
||||||
|
if (!orden) {
|
||||||
|
throw new Error('Orden de transporte no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validar que la orden este en estado valido para asignacion
|
||||||
|
const estadosAsignables = [
|
||||||
|
EstadoOrdenTransporte.CONFIRMADA,
|
||||||
|
EstadoOrdenTransporte.PENDIENTE,
|
||||||
|
EstadoOrdenTransporte.SOLICITADA,
|
||||||
|
];
|
||||||
|
if (!estadosAsignables.includes(orden.estado)) {
|
||||||
|
throw new Error(
|
||||||
|
`No se puede asignar unidad a una OT en estado ${orden.estado}. ` +
|
||||||
|
`Estados permitidos: ${estadosAsignables.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validar que la orden no tenga ya un viaje asignado
|
||||||
|
if (orden.viajeId) {
|
||||||
|
throw new Error(
|
||||||
|
`La orden ya tiene un viaje asignado: ${orden.viajeId}. ` +
|
||||||
|
`Desasigne primero el viaje actual.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Validar unidad disponible
|
||||||
|
const unidadValidation = await this.validateUnidadDisponible(
|
||||||
|
tenantId,
|
||||||
|
dto.unidadId,
|
||||||
|
dto.fechaSalidaProgramada || orden.fechaRecoleccionProgramada,
|
||||||
|
dto.fechaLlegadaProgramada || orden.fechaEntregaProgramada
|
||||||
|
);
|
||||||
|
if (!unidadValidation.disponible) {
|
||||||
|
throw new Error(
|
||||||
|
`Unidad ${dto.unidadId} no disponible: ${unidadValidation.razon}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Validar documentos del operador
|
||||||
|
const docsStatus = await this.validateDocumentosOperador(tenantId, dto.operadorId);
|
||||||
|
if (!docsStatus.documentosVigentes) {
|
||||||
|
throw new Error(
|
||||||
|
`Operador ${dto.operadorId} tiene documentos vencidos: ${docsStatus.documentosVencidos.join(', ')}. ` +
|
||||||
|
`Debe renovar los documentos antes de asignar viajes.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Generar codigo de viaje
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const month = String(new Date().getMonth() + 1).padStart(2, '0');
|
||||||
|
const viajeCount = await this.otRepository.manager.query(
|
||||||
|
`SELECT COUNT(*) as count FROM transport.viajes WHERE tenant_id = $1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const viajeNumero = parseInt(viajeCount[0]?.count || '0', 10) + 1;
|
||||||
|
const viajeCodigo = `VJ-${year}${month}-${String(viajeNumero).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
// 7. Crear viaje asociado
|
||||||
|
const viajeInsertResult = await this.otRepository.manager.query(
|
||||||
|
`INSERT INTO transport.viajes (
|
||||||
|
tenant_id, codigo, numero_viaje, unidad_id, remolque_id, operador_id,
|
||||||
|
cliente_id, origen_principal, origen_ciudad, destino_principal, destino_ciudad,
|
||||||
|
distancia_estimada_km, tiempo_estimado_horas,
|
||||||
|
fecha_salida_programada, fecha_llegada_programada, estado,
|
||||||
|
created_by_id, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'PLANEADO', $16, NOW(), NOW()
|
||||||
|
) RETURNING id`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
viajeCodigo,
|
||||||
|
viajeCodigo,
|
||||||
|
dto.unidadId,
|
||||||
|
dto.remolqueId || null,
|
||||||
|
dto.operadorId,
|
||||||
|
orden.clienteId,
|
||||||
|
orden.origenDireccion,
|
||||||
|
orden.origenCiudad,
|
||||||
|
orden.destinoDireccion,
|
||||||
|
orden.destinoCiudad,
|
||||||
|
this.calculateDistanceFromCoords(orden),
|
||||||
|
this.calculateTimeFromDistance(this.calculateDistanceFromCoords(orden)),
|
||||||
|
dto.fechaSalidaProgramada || orden.fechaRecoleccionProgramada,
|
||||||
|
dto.fechaLlegadaProgramada || orden.fechaEntregaProgramada,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const viajeId = viajeInsertResult[0]?.id;
|
||||||
|
if (!viajeId) {
|
||||||
|
throw new Error('Error al crear el viaje asociado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Actualizar la orden con el viaje y cambiar estado
|
||||||
|
orden.viajeId = viajeId;
|
||||||
|
orden.estado = EstadoOrdenTransporte.ASIGNADA;
|
||||||
|
orden.updatedById = userId;
|
||||||
|
|
||||||
|
const ordenActualizada = await this.otRepository.save(orden);
|
||||||
|
|
||||||
|
// 9. Registrar evento de tracking (si existe tabla)
|
||||||
|
try {
|
||||||
|
await this.otRepository.manager.query(
|
||||||
|
`INSERT INTO tracking.eventos_tracking (
|
||||||
|
tenant_id, viaje_id, orden_id, tipo_evento, descripcion,
|
||||||
|
latitud, longitud, created_at, created_by_id
|
||||||
|
) VALUES ($1, $2, $3, 'ASIGNACION', $4, $5, $6, NOW(), $7)`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
viajeId,
|
||||||
|
orden.id,
|
||||||
|
`Orden ${orden.codigo} asignada a viaje ${viajeCodigo}`,
|
||||||
|
orden.origenLatitud,
|
||||||
|
orden.origenLongitud,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignorar si la tabla de eventos no existe
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orden: ordenActualizada,
|
||||||
|
viajeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar si una unidad esta disponible para un rango de fechas
|
||||||
|
*/
|
||||||
|
private async validateUnidadDisponible(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string,
|
||||||
|
fechaInicio?: Date,
|
||||||
|
fechaFin?: Date
|
||||||
|
): Promise<{ disponible: boolean; razon?: string }> {
|
||||||
|
// Verificar que la unidad existe y su estado
|
||||||
|
const unidadResult = await this.otRepository.manager.query(
|
||||||
|
`SELECT id, estado, activo FROM fleet.unidades WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[unidadId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!unidadResult || unidadResult.length === 0) {
|
||||||
|
return { disponible: false, razon: 'Unidad no encontrada' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const unidad = unidadResult[0];
|
||||||
|
if (!unidad.activo) {
|
||||||
|
return { disponible: false, razon: 'Unidad inactiva' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const estadosNoDisponibles = ['EN_VIAJE', 'EN_RUTA', 'EN_TALLER', 'BLOQUEADA', 'BAJA'];
|
||||||
|
if (estadosNoDisponibles.includes(unidad.estado)) {
|
||||||
|
return { disponible: false, razon: `Estado actual: ${unidad.estado}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar conflictos de viajes en el rango de fechas
|
||||||
|
if (fechaInicio && fechaFin) {
|
||||||
|
const conflictos = await this.otRepository.manager.query(
|
||||||
|
`SELECT id, codigo, fecha_salida_programada, fecha_llegada_programada
|
||||||
|
FROM transport.viajes
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND unidad_id = $2
|
||||||
|
AND estado NOT IN ('CANCELADO', 'CERRADO', 'FACTURADO', 'COBRADO')
|
||||||
|
AND (
|
||||||
|
(fecha_salida_programada <= $3 AND fecha_llegada_programada >= $3)
|
||||||
|
OR (fecha_salida_programada <= $4 AND fecha_llegada_programada >= $4)
|
||||||
|
OR (fecha_salida_programada >= $3 AND fecha_llegada_programada <= $4)
|
||||||
|
)`,
|
||||||
|
[tenantId, unidadId, fechaInicio, fechaFin]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (conflictos && conflictos.length > 0) {
|
||||||
|
return {
|
||||||
|
disponible: false,
|
||||||
|
razon: `Conflicto con viaje(s): ${conflictos.map((v: any) => v.codigo).join(', ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { disponible: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar documentos vigentes del operador
|
||||||
|
*/
|
||||||
|
private async validateDocumentosOperador(
|
||||||
|
tenantId: string,
|
||||||
|
operadorId: string
|
||||||
|
): Promise<DocumentosOperadorStatus> {
|
||||||
|
const operadorResult = await this.otRepository.manager.query(
|
||||||
|
`SELECT id, nombre, apellido_paterno, estado, activo,
|
||||||
|
licencia_vigencia, certificado_fisico_vigencia,
|
||||||
|
antidoping_vigencia, capacitacion_mp_vigencia,
|
||||||
|
capacitacion_materiales_peligrosos
|
||||||
|
FROM fleet.operadores
|
||||||
|
WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[operadorId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!operadorResult || operadorResult.length === 0) {
|
||||||
|
return {
|
||||||
|
operadorId,
|
||||||
|
documentosVigentes: false,
|
||||||
|
licenciaVigente: false,
|
||||||
|
certificadoFisicoVigente: false,
|
||||||
|
antidopingVigente: false,
|
||||||
|
documentosVencidos: ['Operador no encontrado'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const operador = operadorResult[0];
|
||||||
|
const hoy = new Date();
|
||||||
|
const documentosVencidos: string[] = [];
|
||||||
|
|
||||||
|
// Verificar estado activo
|
||||||
|
if (!operador.activo) {
|
||||||
|
documentosVencidos.push('Operador inactivo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const estadosNoDisponibles = ['SUSPENDIDO', 'BAJA', 'VACACIONES', 'INCAPACIDAD'];
|
||||||
|
if (estadosNoDisponibles.includes(operador.estado)) {
|
||||||
|
documentosVencidos.push(`Estado: ${operador.estado}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar licencia
|
||||||
|
const licenciaVigente = operador.licencia_vigencia && new Date(operador.licencia_vigencia) >= hoy;
|
||||||
|
if (!licenciaVigente) {
|
||||||
|
documentosVencidos.push('Licencia de conducir');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar certificado fisico
|
||||||
|
const certificadoFisicoVigente = operador.certificado_fisico_vigencia &&
|
||||||
|
new Date(operador.certificado_fisico_vigencia) >= hoy;
|
||||||
|
if (!certificadoFisicoVigente) {
|
||||||
|
documentosVencidos.push('Certificado fisico');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar antidoping
|
||||||
|
const antidopingVigente = operador.antidoping_vigencia &&
|
||||||
|
new Date(operador.antidoping_vigencia) >= hoy;
|
||||||
|
if (!antidopingVigente) {
|
||||||
|
documentosVencidos.push('Antidoping');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar capacitacion materiales peligrosos (si aplica)
|
||||||
|
let capacitacionMpVigente: boolean | undefined;
|
||||||
|
if (operador.capacitacion_materiales_peligrosos) {
|
||||||
|
capacitacionMpVigente = operador.capacitacion_mp_vigencia &&
|
||||||
|
new Date(operador.capacitacion_mp_vigencia) >= hoy;
|
||||||
|
if (!capacitacionMpVigente) {
|
||||||
|
documentosVencidos.push('Capacitacion materiales peligrosos');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
operadorId,
|
||||||
|
documentosVigentes: documentosVencidos.length === 0,
|
||||||
|
licenciaVigente,
|
||||||
|
certificadoFisicoVigente,
|
||||||
|
antidopingVigente,
|
||||||
|
capacitacionMpVigente,
|
||||||
|
documentosVencidos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// CALCULO DE TARIFA (MAI-003 REQUERIMIENTO)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular tarifa para una orden de transporte.
|
||||||
|
* Busca la tarifa aplicable por lane (origen-destino) y cliente.
|
||||||
|
* Aplica recargos (combustible, maniobras, etc.) y calcula total con IVA.
|
||||||
|
*
|
||||||
|
* @param id - ID de la orden de transporte
|
||||||
|
* @param ctx - Contexto del servicio (tenantId, userId)
|
||||||
|
* @returns Desglose completo de la tarifa calculada
|
||||||
|
*/
|
||||||
|
async calcularTarifa(id: string, ctx: ServiceContext): Promise<TarifaCalculada | null> {
|
||||||
|
const { tenantId } = ctx;
|
||||||
|
|
||||||
|
// 1. Obtener la orden
|
||||||
|
const orden = await this.findById(tenantId, id);
|
||||||
|
if (!orden) {
|
||||||
|
throw new Error('Orden de transporte no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Calcular distancia si hay coordenadas
|
||||||
|
let distanciaEstimadaKm = this.calculateDistanceFromCoords(orden);
|
||||||
|
let tiempoEstimadoHoras = this.calculateTimeFromDistance(distanciaEstimadaKm);
|
||||||
|
|
||||||
|
// 3. Buscar tarifa aplicable
|
||||||
|
const tarifa = await this.findTarifaAplicable(tenantId, {
|
||||||
|
clienteId: orden.clienteId,
|
||||||
|
origenCiudad: orden.origenCiudad,
|
||||||
|
origenEstado: orden.origenEstado,
|
||||||
|
destinoCiudad: orden.destinoCiudad,
|
||||||
|
destinoEstado: orden.destinoEstado,
|
||||||
|
modalidadServicio: orden.modalidadServicio,
|
||||||
|
tipoCarga: orden.tipoCarga,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Calcular monto base
|
||||||
|
let tarifaBase = 0;
|
||||||
|
let montoVariable = 0;
|
||||||
|
let tarifaId: string | undefined;
|
||||||
|
let tarifaCodigo: string | undefined;
|
||||||
|
let laneId: string | undefined;
|
||||||
|
let minimoAplicado = false;
|
||||||
|
let moneda = 'MXN';
|
||||||
|
|
||||||
|
if (tarifa) {
|
||||||
|
tarifaId = tarifa.id;
|
||||||
|
tarifaCodigo = tarifa.codigo;
|
||||||
|
laneId = tarifa.laneId;
|
||||||
|
moneda = tarifa.moneda || 'MXN';
|
||||||
|
tarifaBase = Number(tarifa.tarifaBase) || 0;
|
||||||
|
|
||||||
|
// Calcular monto variable segun tipo de tarifa
|
||||||
|
switch (tarifa.tipoTarifa) {
|
||||||
|
case 'POR_KM':
|
||||||
|
montoVariable = (distanciaEstimadaKm || 0) * (Number(tarifa.tarifaKm) || 0);
|
||||||
|
break;
|
||||||
|
case 'POR_TONELADA':
|
||||||
|
montoVariable = ((orden.pesoKg || 0) / 1000) * (Number(tarifa.tarifaTonelada) || 0);
|
||||||
|
break;
|
||||||
|
case 'POR_M3':
|
||||||
|
montoVariable = (orden.volumenM3 || 0) * (Number(tarifa.tarifaM3) || 0);
|
||||||
|
break;
|
||||||
|
case 'POR_PALLET':
|
||||||
|
montoVariable = (orden.pallets || 0) * (Number(tarifa.tarifaPallet) || 0);
|
||||||
|
break;
|
||||||
|
case 'MIXTA':
|
||||||
|
montoVariable = (distanciaEstimadaKm || 0) * (Number(tarifa.tarifaKm) || 0);
|
||||||
|
montoVariable += ((orden.pesoKg || 0) / 1000) * (Number(tarifa.tarifaTonelada) || 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
montoVariable = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplicar minimo si existe
|
||||||
|
const montoBase = tarifaBase + montoVariable;
|
||||||
|
if (tarifa.minimoFacturar && montoBase < Number(tarifa.minimoFacturar)) {
|
||||||
|
tarifaBase = Number(tarifa.minimoFacturar);
|
||||||
|
montoVariable = 0;
|
||||||
|
minimoAplicado = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Sin tarifa especifica, usar estimacion por defecto
|
||||||
|
// Tarifa base de $15,000 MXN + $25/km
|
||||||
|
tarifaBase = 15000;
|
||||||
|
montoVariable = (distanciaEstimadaKm || 0) * 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Calcular recargos
|
||||||
|
const recargoCombustible = this.calcularRecargoCombustible(
|
||||||
|
distanciaEstimadaKm,
|
||||||
|
orden.requiereTemperatura
|
||||||
|
);
|
||||||
|
const recargoManiobras = this.calcularRecargoManiobras(orden);
|
||||||
|
const recargoEspera = 0; // Se calcula al momento de la entrega
|
||||||
|
const recargoOtros = Number(orden.recargos) || 0;
|
||||||
|
const totalRecargos = recargoCombustible + recargoManiobras + recargoEspera + recargoOtros;
|
||||||
|
|
||||||
|
// 6. Aplicar descuentos
|
||||||
|
const descuentos = Number(orden.descuentos) || 0;
|
||||||
|
|
||||||
|
// 7. Calcular totales
|
||||||
|
const subtotal = tarifaBase + montoVariable + totalRecargos - descuentos;
|
||||||
|
const ivaRate = 0.16; // IVA Mexico 16%
|
||||||
|
const iva = subtotal * ivaRate;
|
||||||
|
const total = subtotal + iva;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ordenId: orden.id,
|
||||||
|
tarifaId,
|
||||||
|
tarifaCodigo,
|
||||||
|
laneId,
|
||||||
|
tarifaBase: Math.round(tarifaBase * 100) / 100,
|
||||||
|
montoVariable: Math.round(montoVariable * 100) / 100,
|
||||||
|
recargoCombustible: Math.round(recargoCombustible * 100) / 100,
|
||||||
|
recargoManiobras: Math.round(recargoManiobras * 100) / 100,
|
||||||
|
recargoEspera: Math.round(recargoEspera * 100) / 100,
|
||||||
|
recargoOtros: Math.round(recargoOtros * 100) / 100,
|
||||||
|
totalRecargos: Math.round(totalRecargos * 100) / 100,
|
||||||
|
descuentos: Math.round(descuentos * 100) / 100,
|
||||||
|
subtotal: Math.round(subtotal * 100) / 100,
|
||||||
|
iva: Math.round(iva * 100) / 100,
|
||||||
|
ivaRate,
|
||||||
|
total: Math.round(total * 100) / 100,
|
||||||
|
moneda,
|
||||||
|
distanciaEstimadaKm: distanciaEstimadaKm ? Math.round(distanciaEstimadaKm) : undefined,
|
||||||
|
tiempoEstimadoHoras: tiempoEstimadoHoras ? Math.round(tiempoEstimadoHoras * 10) / 10 : undefined,
|
||||||
|
minimoAplicado,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar la tarifa aplicable para una orden
|
||||||
|
*/
|
||||||
|
private async findTarifaAplicable(
|
||||||
|
tenantId: string,
|
||||||
|
params: {
|
||||||
|
clienteId?: string;
|
||||||
|
origenCiudad?: string;
|
||||||
|
origenEstado?: string;
|
||||||
|
destinoCiudad?: string;
|
||||||
|
destinoEstado?: string;
|
||||||
|
modalidadServicio?: string;
|
||||||
|
tipoCarga?: string;
|
||||||
|
}
|
||||||
|
): Promise<any | null> {
|
||||||
|
const hoy = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// 1. Buscar lane que coincida con origen-destino
|
||||||
|
let laneId: string | null = null;
|
||||||
|
if (params.origenCiudad && params.destinoCiudad) {
|
||||||
|
const laneResult = await this.otRepository.manager.query(
|
||||||
|
`SELECT id FROM billing.lanes
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND activa = true
|
||||||
|
AND (
|
||||||
|
(origen_ciudad ILIKE $2 AND destino_ciudad ILIKE $3)
|
||||||
|
OR (origen_estado = $4 AND destino_estado = $5)
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN origen_ciudad ILIKE $2 AND destino_ciudad ILIKE $3 THEN 0 ELSE 1 END
|
||||||
|
LIMIT 1`,
|
||||||
|
[tenantId, params.origenCiudad, params.destinoCiudad, params.origenEstado, params.destinoEstado]
|
||||||
|
);
|
||||||
|
if (laneResult && laneResult.length > 0) {
|
||||||
|
laneId = laneResult[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Buscar tarifa por prioridad: cliente+lane > cliente > lane > general
|
||||||
|
const tarifaQuery = `
|
||||||
|
SELECT t.*, l.id as lane_id, l.origen_ciudad, l.destino_ciudad
|
||||||
|
FROM billing.tarifas t
|
||||||
|
LEFT JOIN billing.lanes l ON t.lane_id = l.id
|
||||||
|
WHERE t.tenant_id = $1
|
||||||
|
AND t.activa = true
|
||||||
|
AND t.fecha_inicio <= $2
|
||||||
|
AND (t.fecha_fin IS NULL OR t.fecha_fin >= $2)
|
||||||
|
AND (
|
||||||
|
-- Prioridad 1: cliente + lane especificos
|
||||||
|
(t.cliente_id = $3 AND t.lane_id = $4)
|
||||||
|
-- Prioridad 2: cliente especifico, cualquier lane
|
||||||
|
OR (t.cliente_id = $3 AND t.lane_id IS NULL)
|
||||||
|
-- Prioridad 3: lane especifica, cualquier cliente
|
||||||
|
OR (t.cliente_id IS NULL AND t.lane_id = $4)
|
||||||
|
-- Prioridad 4: tarifa general
|
||||||
|
OR (t.cliente_id IS NULL AND t.lane_id IS NULL)
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN t.cliente_id = $3 AND t.lane_id = $4 THEN 1
|
||||||
|
WHEN t.cliente_id = $3 AND t.lane_id IS NULL THEN 2
|
||||||
|
WHEN t.cliente_id IS NULL AND t.lane_id = $4 THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
t.created_at DESC
|
||||||
|
LIMIT 1`;
|
||||||
|
|
||||||
|
const tarifaResult = await this.otRepository.manager.query(tarifaQuery, [
|
||||||
|
tenantId,
|
||||||
|
hoy,
|
||||||
|
params.clienteId || null,
|
||||||
|
laneId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return tarifaResult && tarifaResult.length > 0 ? tarifaResult[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular recargo por combustible
|
||||||
|
*/
|
||||||
|
private calcularRecargoCombustible(distanciaKm?: number, requiereRefrigeracion?: boolean): number {
|
||||||
|
if (!distanciaKm || distanciaKm <= 0) return 0;
|
||||||
|
|
||||||
|
// Factor de ajuste por precio de diesel (fuel surcharge)
|
||||||
|
// Precio base referencia: $22 MXN/litro
|
||||||
|
// Consumo promedio: 2.5 km/litro
|
||||||
|
const precioDieselActual = 24; // MXN/litro (puede venir de configuracion)
|
||||||
|
const precioDieselBase = 22;
|
||||||
|
const consumoPromedio = 2.5; // km/litro
|
||||||
|
|
||||||
|
const litrosEstimados = distanciaKm / consumoPromedio;
|
||||||
|
const costoBaseCombus = litrosEstimados * precioDieselBase;
|
||||||
|
const costoActualCombus = litrosEstimados * precioDieselActual;
|
||||||
|
let recargo = costoActualCombus - costoBaseCombus;
|
||||||
|
|
||||||
|
// Recargo adicional por refrigeracion (30% mas consumo)
|
||||||
|
if (requiereRefrigeracion) {
|
||||||
|
recargo *= 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, recargo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular recargo por maniobras de carga/descarga
|
||||||
|
*/
|
||||||
|
private calcularRecargoManiobras(orden: OrdenTransporte): number {
|
||||||
|
let recargo = 0;
|
||||||
|
|
||||||
|
// Maniobras por piezas o pallets
|
||||||
|
if (orden.piezas && orden.piezas > 100) {
|
||||||
|
recargo += (orden.piezas - 100) * 5; // $5 por pieza extra despues de 100
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orden.pallets && orden.pallets > 10) {
|
||||||
|
recargo += (orden.pallets - 10) * 150; // $150 por pallet extra despues de 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carga peligrosa tiene recargo adicional
|
||||||
|
if (orden.tipoCarga === TipoCarga.PELIGROSA) {
|
||||||
|
recargo += 3000; // Recargo fijo por manejo especial
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carga sobredimensionada
|
||||||
|
if (orden.tipoCarga === TipoCarga.SOBREDIMENSIONADA) {
|
||||||
|
recargo += 5000; // Recargo por permisos especiales
|
||||||
|
}
|
||||||
|
|
||||||
|
return recargo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular distancia desde coordenadas de la orden
|
||||||
|
*/
|
||||||
|
private calculateDistanceFromCoords(orden: OrdenTransporte): number | undefined {
|
||||||
|
if (orden.origenLatitud && orden.origenLongitud &&
|
||||||
|
orden.destinoLatitud && orden.destinoLongitud) {
|
||||||
|
return this.calculateDistance(
|
||||||
|
orden.origenLatitud,
|
||||||
|
orden.origenLongitud,
|
||||||
|
orden.destinoLatitud,
|
||||||
|
orden.destinoLongitud
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular tiempo estimado desde distancia
|
||||||
|
*/
|
||||||
|
private calculateTimeFromDistance(distanciaKm?: number): number | undefined {
|
||||||
|
if (!distanciaKm) return undefined;
|
||||||
|
// Velocidad promedio de 60 km/h considerando paradas
|
||||||
|
return distanciaKm / 60;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cambiar estado con validacion de transiciones
|
* Cambiar estado con validacion de transiciones
|
||||||
*/
|
*/
|
||||||
|
|||||||
489
src/modules/tracking/services/evento-tracking.service.ts
Normal file
489
src/modules/tracking/services/evento-tracking.service.ts
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
/**
|
||||||
|
* EventoTracking Service
|
||||||
|
* ERP Transportistas
|
||||||
|
*
|
||||||
|
* Business logic for tracking events, alerts, and trip timelines.
|
||||||
|
* Module: MAI-006 Tracking
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource, Between, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
|
import { EventoTracking, TipoEventoTracking, FuenteEvento } from '../entities/evento-tracking.entity';
|
||||||
|
import { Geocerca } from '../entities/geocerca.entity';
|
||||||
|
|
||||||
|
// ==================== DTOs ====================
|
||||||
|
|
||||||
|
export interface CreateEventoTrackingDto {
|
||||||
|
viajeId?: string;
|
||||||
|
unidadId?: string;
|
||||||
|
operadorId?: string;
|
||||||
|
tipoEvento: TipoEventoTracking;
|
||||||
|
fuente?: FuenteEvento;
|
||||||
|
latitud?: number;
|
||||||
|
longitud?: number;
|
||||||
|
direccion?: string;
|
||||||
|
timestamp?: Date;
|
||||||
|
timestampEvento?: Date;
|
||||||
|
velocidad?: number;
|
||||||
|
rumbo?: number;
|
||||||
|
altitud?: number;
|
||||||
|
precision?: number;
|
||||||
|
odometro?: number;
|
||||||
|
nivelCombustible?: number;
|
||||||
|
motorEncendido?: boolean;
|
||||||
|
descripcion?: string;
|
||||||
|
datosAdicionales?: Record<string, any>;
|
||||||
|
datos?: Record<string, any>;
|
||||||
|
paradaId?: string;
|
||||||
|
generadoPorId?: string;
|
||||||
|
generadoPorTipo?: string;
|
||||||
|
evidencias?: Record<string, any>;
|
||||||
|
observaciones?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
fechaInicio: Date;
|
||||||
|
fechaFin: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAlertaDto {
|
||||||
|
unidadId?: string;
|
||||||
|
operadorId?: string;
|
||||||
|
tipoAlerta: string;
|
||||||
|
severidad: 'baja' | 'media' | 'alta' | 'critica';
|
||||||
|
latitud?: number;
|
||||||
|
longitud?: number;
|
||||||
|
descripcion: string;
|
||||||
|
datos?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PosicionGpsInput {
|
||||||
|
latitud: number;
|
||||||
|
longitud: number;
|
||||||
|
velocidad?: number;
|
||||||
|
rumbo?: number;
|
||||||
|
altitud?: number;
|
||||||
|
precision?: number;
|
||||||
|
odometro?: number;
|
||||||
|
nivelCombustible?: number;
|
||||||
|
motorEncendido?: boolean;
|
||||||
|
timestamp?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventoEstadisticas {
|
||||||
|
totalEventos: number;
|
||||||
|
eventosPorTipo: Record<string, number>;
|
||||||
|
eventosPorFuente: Record<string, number>;
|
||||||
|
alertasTotales: number;
|
||||||
|
alertasPorSeveridad: Record<string, number>;
|
||||||
|
alertasSinAcknowledge: number;
|
||||||
|
promedioEventosPorDia: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Service ====================
|
||||||
|
|
||||||
|
export class EventoTrackingService {
|
||||||
|
private eventoRepository: Repository<EventoTracking>;
|
||||||
|
private geocercaRepository: Repository<Geocerca>;
|
||||||
|
|
||||||
|
constructor(dataSource: DataSource) {
|
||||||
|
this.eventoRepository = dataSource.getRepository(EventoTracking);
|
||||||
|
this.geocercaRepository = dataSource.getRepository(Geocerca);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tracking event
|
||||||
|
*/
|
||||||
|
async create(tenantId: string, viajeId: string, data: CreateEventoTrackingDto): Promise<EventoTracking> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const evento = this.eventoRepository.create({
|
||||||
|
tenantId,
|
||||||
|
viajeId,
|
||||||
|
unidadId: data.unidadId,
|
||||||
|
operadorId: data.operadorId,
|
||||||
|
tipoEvento: data.tipoEvento,
|
||||||
|
fuente: data.fuente || FuenteEvento.SISTEMA,
|
||||||
|
latitud: data.latitud,
|
||||||
|
longitud: data.longitud,
|
||||||
|
direccion: data.direccion,
|
||||||
|
timestamp: data.timestamp || now,
|
||||||
|
timestampEvento: data.timestampEvento || data.timestamp || now,
|
||||||
|
velocidad: data.velocidad,
|
||||||
|
rumbo: data.rumbo,
|
||||||
|
altitud: data.altitud,
|
||||||
|
precision: data.precision,
|
||||||
|
odometro: data.odometro,
|
||||||
|
nivelCombustible: data.nivelCombustible,
|
||||||
|
motorEncendido: data.motorEncendido,
|
||||||
|
descripcion: data.descripcion,
|
||||||
|
datosAdicionales: data.datosAdicionales,
|
||||||
|
datos: data.datos,
|
||||||
|
paradaId: data.paradaId,
|
||||||
|
generadoPorId: data.generadoPorId,
|
||||||
|
generadoPorTipo: data.generadoPorTipo,
|
||||||
|
evidencias: data.evidencias,
|
||||||
|
observaciones: data.observaciones,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.eventoRepository.save(evento);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all events for a trip
|
||||||
|
*/
|
||||||
|
async findByViaje(tenantId: string, viajeId: string): Promise<EventoTracking[]> {
|
||||||
|
return this.eventoRepository.find({
|
||||||
|
where: { tenantId, viajeId },
|
||||||
|
order: { timestampEvento: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find events by vehicle/unit in date range
|
||||||
|
*/
|
||||||
|
async findByUnidad(
|
||||||
|
tenantId: string,
|
||||||
|
unidadId: string,
|
||||||
|
dateRange: DateRange
|
||||||
|
): Promise<EventoTracking[]> {
|
||||||
|
return this.eventoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
unidadId,
|
||||||
|
timestampEvento: Between(dateRange.fechaInicio, dateRange.fechaFin),
|
||||||
|
},
|
||||||
|
order: { timestampEvento: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find events by type in date range
|
||||||
|
*/
|
||||||
|
async findByTipo(
|
||||||
|
tenantId: string,
|
||||||
|
tipo: TipoEventoTracking,
|
||||||
|
dateRange: DateRange
|
||||||
|
): Promise<EventoTracking[]> {
|
||||||
|
return this.eventoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
tipoEvento: tipo,
|
||||||
|
timestampEvento: Between(dateRange.fechaInicio, dateRange.fechaFin),
|
||||||
|
},
|
||||||
|
order: { timestampEvento: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last event for a trip
|
||||||
|
*/
|
||||||
|
async getUltimoEvento(tenantId: string, viajeId: string): Promise<EventoTracking | null> {
|
||||||
|
return this.eventoRepository.findOne({
|
||||||
|
where: { tenantId, viajeId },
|
||||||
|
order: { timestampEvento: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent events across the fleet
|
||||||
|
*/
|
||||||
|
async getEventosRecientes(tenantId: string, limit: number = 50): Promise<EventoTracking[]> {
|
||||||
|
return this.eventoRepository.find({
|
||||||
|
where: { tenantId },
|
||||||
|
order: { timestampEvento: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full timeline for a trip (ordered events)
|
||||||
|
*/
|
||||||
|
async getTimeline(tenantId: string, viajeId: string): Promise<EventoTracking[]> {
|
||||||
|
return this.eventoRepository.find({
|
||||||
|
where: { tenantId, viajeId },
|
||||||
|
order: { timestampEvento: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-create event from GPS position data
|
||||||
|
*/
|
||||||
|
async createFromGPS(
|
||||||
|
tenantId: string,
|
||||||
|
viajeId: string,
|
||||||
|
posicionGps: PosicionGpsInput & { unidadId: string; operadorId?: string }
|
||||||
|
): Promise<EventoTracking> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const evento = this.eventoRepository.create({
|
||||||
|
tenantId,
|
||||||
|
viajeId,
|
||||||
|
unidadId: posicionGps.unidadId,
|
||||||
|
operadorId: posicionGps.operadorId,
|
||||||
|
tipoEvento: TipoEventoTracking.GPS_POSICION,
|
||||||
|
fuente: FuenteEvento.GPS,
|
||||||
|
latitud: posicionGps.latitud,
|
||||||
|
longitud: posicionGps.longitud,
|
||||||
|
timestamp: posicionGps.timestamp || now,
|
||||||
|
timestampEvento: posicionGps.timestamp || now,
|
||||||
|
velocidad: posicionGps.velocidad,
|
||||||
|
rumbo: posicionGps.rumbo,
|
||||||
|
altitud: posicionGps.altitud,
|
||||||
|
precision: posicionGps.precision,
|
||||||
|
odometro: posicionGps.odometro,
|
||||||
|
nivelCombustible: posicionGps.nivelCombustible,
|
||||||
|
motorEncendido: posicionGps.motorEncendido,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedEvento = await this.eventoRepository.save(evento);
|
||||||
|
|
||||||
|
// Check geofences asynchronously
|
||||||
|
this.checkGeocercas(tenantId, savedEvento).catch(err => {
|
||||||
|
console.error('Error checking geofences:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return savedEvento;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an alert event
|
||||||
|
*/
|
||||||
|
async createAlerta(
|
||||||
|
tenantId: string,
|
||||||
|
viajeId: string,
|
||||||
|
alertaData: CreateAlertaDto
|
||||||
|
): Promise<EventoTracking> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Map alert type to event type
|
||||||
|
let tipoEvento = TipoEventoTracking.INCIDENTE;
|
||||||
|
if (alertaData.tipoAlerta === 'desvio') {
|
||||||
|
tipoEvento = TipoEventoTracking.DESVIO;
|
||||||
|
} else if (alertaData.tipoAlerta === 'parada') {
|
||||||
|
tipoEvento = TipoEventoTracking.PARADA;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evento = this.eventoRepository.create({
|
||||||
|
tenantId,
|
||||||
|
viajeId,
|
||||||
|
unidadId: alertaData.unidadId,
|
||||||
|
operadorId: alertaData.operadorId,
|
||||||
|
tipoEvento,
|
||||||
|
fuente: FuenteEvento.SISTEMA,
|
||||||
|
latitud: alertaData.latitud,
|
||||||
|
longitud: alertaData.longitud,
|
||||||
|
timestamp: now,
|
||||||
|
timestampEvento: now,
|
||||||
|
descripcion: alertaData.descripcion,
|
||||||
|
datos: {
|
||||||
|
esAlerta: true,
|
||||||
|
tipoAlerta: alertaData.tipoAlerta,
|
||||||
|
severidad: alertaData.severidad,
|
||||||
|
acknowledged: false,
|
||||||
|
acknowledgedAt: null,
|
||||||
|
acknowledgedBy: null,
|
||||||
|
...alertaData.datos,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.eventoRepository.save(evento);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get alerts in date range, optionally filtered by severity
|
||||||
|
*/
|
||||||
|
async getAlertas(
|
||||||
|
tenantId: string,
|
||||||
|
dateRange: DateRange,
|
||||||
|
severidad?: 'baja' | 'media' | 'alta' | 'critica'
|
||||||
|
): Promise<EventoTracking[]> {
|
||||||
|
const queryBuilder = this.eventoRepository
|
||||||
|
.createQueryBuilder('evento')
|
||||||
|
.where('evento.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere("evento.datos->>'esAlerta' = :esAlerta", { esAlerta: 'true' })
|
||||||
|
.andWhere('evento.timestamp_evento BETWEEN :fechaInicio AND :fechaFin', {
|
||||||
|
fechaInicio: dateRange.fechaInicio,
|
||||||
|
fechaFin: dateRange.fechaFin,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (severidad) {
|
||||||
|
queryBuilder.andWhere("evento.datos->>'severidad' = :severidad", { severidad });
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryBuilder
|
||||||
|
.orderBy('evento.timestamp_evento', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acknowledge an alert
|
||||||
|
*/
|
||||||
|
async acknowledgeAlerta(
|
||||||
|
tenantId: string,
|
||||||
|
alertaId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<EventoTracking | null> {
|
||||||
|
const evento = await this.eventoRepository.findOne({
|
||||||
|
where: { id: alertaId, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!evento || !evento.datos?.esAlerta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
evento.datos = {
|
||||||
|
...evento.datos,
|
||||||
|
acknowledged: true,
|
||||||
|
acknowledgedAt: now.toISOString(),
|
||||||
|
acknowledgedBy: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.eventoRepository.save(evento);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event statistics for date range
|
||||||
|
*/
|
||||||
|
async getEstadisticas(tenantId: string, dateRange: DateRange): Promise<EventoEstadisticas> {
|
||||||
|
// Get events in range
|
||||||
|
const eventos = await this.eventoRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
timestampEvento: Between(dateRange.fechaInicio, dateRange.fechaFin),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
const eventosPorTipo: Record<string, number> = {};
|
||||||
|
const eventosPorFuente: Record<string, number> = {};
|
||||||
|
const alertasPorSeveridad: Record<string, number> = {};
|
||||||
|
let alertasTotales = 0;
|
||||||
|
let alertasSinAcknowledge = 0;
|
||||||
|
|
||||||
|
for (const evento of eventos) {
|
||||||
|
// Count by type
|
||||||
|
eventosPorTipo[evento.tipoEvento] = (eventosPorTipo[evento.tipoEvento] || 0) + 1;
|
||||||
|
|
||||||
|
// Count by source
|
||||||
|
if (evento.fuente) {
|
||||||
|
eventosPorFuente[evento.fuente] = (eventosPorFuente[evento.fuente] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count alerts
|
||||||
|
if (evento.datos?.esAlerta) {
|
||||||
|
alertasTotales++;
|
||||||
|
const severidad = evento.datos.severidad || 'media';
|
||||||
|
alertasPorSeveridad[severidad] = (alertasPorSeveridad[severidad] || 0) + 1;
|
||||||
|
|
||||||
|
if (!evento.datos.acknowledged) {
|
||||||
|
alertasSinAcknowledge++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average events per day
|
||||||
|
const diasEnRango = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(
|
||||||
|
(dateRange.fechaFin.getTime() - dateRange.fechaInicio.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const promedioEventosPorDia = eventos.length / diasEnRango;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalEventos: eventos.length,
|
||||||
|
eventosPorTipo,
|
||||||
|
eventosPorFuente,
|
||||||
|
alertasTotales,
|
||||||
|
alertasPorSeveridad,
|
||||||
|
alertasSinAcknowledge,
|
||||||
|
promedioEventosPorDia: Math.round(promedioEventosPorDia * 100) / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a position triggers any geofence events
|
||||||
|
*/
|
||||||
|
private async checkGeocercas(tenantId: string, evento: EventoTracking): Promise<void> {
|
||||||
|
if (!evento.latitud || !evento.longitud) return;
|
||||||
|
|
||||||
|
// Get active geofences
|
||||||
|
const geocercas = await this.geocercaRepository.find({
|
||||||
|
where: { tenantId, activa: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const geocerca of geocercas) {
|
||||||
|
const dentroGeocerca = this.puntoEnGeocerca(
|
||||||
|
Number(evento.latitud),
|
||||||
|
Number(evento.longitud),
|
||||||
|
geocerca
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dentroGeocerca && geocerca.alertaEntrada) {
|
||||||
|
// Create geofence entry event
|
||||||
|
await this.create(tenantId, evento.viajeId, {
|
||||||
|
unidadId: evento.unidadId,
|
||||||
|
operadorId: evento.operadorId,
|
||||||
|
tipoEvento: TipoEventoTracking.GEOCERCA_ENTRADA,
|
||||||
|
fuente: FuenteEvento.GEOCERCA,
|
||||||
|
latitud: evento.latitud,
|
||||||
|
longitud: evento.longitud,
|
||||||
|
timestamp: evento.timestamp,
|
||||||
|
descripcion: `Entrada a geocerca: ${geocerca.nombre}`,
|
||||||
|
datosAdicionales: {
|
||||||
|
geocercaId: geocerca.id,
|
||||||
|
geocercaNombre: geocerca.nombre,
|
||||||
|
geocercaTipo: geocerca.tipo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a point is inside a geofence
|
||||||
|
*/
|
||||||
|
private puntoEnGeocerca(lat: number, lon: number, geocerca: Geocerca): boolean {
|
||||||
|
if (geocerca.esCircular && geocerca.centroLatitud && geocerca.centroLongitud && geocerca.radioMetros) {
|
||||||
|
const distancia = this.calcularDistancia(
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
Number(geocerca.centroLatitud),
|
||||||
|
Number(geocerca.centroLongitud)
|
||||||
|
);
|
||||||
|
return distancia <= Number(geocerca.radioMetros);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For circular geofences using geometria
|
||||||
|
if (geocerca.geometria?.type === 'Point' && geocerca.radio) {
|
||||||
|
const centro = geocerca.geometria.coordinates;
|
||||||
|
if (centro && centro.length >= 2) {
|
||||||
|
const distancia = this.calcularDistancia(lat, lon, centro[1], centro[0]);
|
||||||
|
return distancia <= Number(geocerca.radio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For polygon geofences, would need proper geometric check (PostGIS recommended)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance between two points using Haversine formula
|
||||||
|
*/
|
||||||
|
private calcularDistancia(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371000; // Earth radius in meters
|
||||||
|
const dLat = this.toRad(lat2 - lat1);
|
||||||
|
const dLon = this.toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) *
|
||||||
|
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRad(deg: number): number {
|
||||||
|
return deg * (Math.PI / 180);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Tracking Services
|
* Tracking Services
|
||||||
|
* ERP Transportistas
|
||||||
|
* Module: MAI-006 Tracking
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './tracking.service';
|
export * from './tracking.service';
|
||||||
|
|
||||||
|
export {
|
||||||
|
EventoTrackingService,
|
||||||
|
CreateEventoTrackingDto,
|
||||||
|
DateRange,
|
||||||
|
CreateAlertaDto,
|
||||||
|
PosicionGpsInput,
|
||||||
|
EventoEstadisticas,
|
||||||
|
} from './evento-tracking.service';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user