[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
|
||||
* CFDI con Complemento Carta Porte 3.1
|
||||
*/
|
||||
|
||||
// Main 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
|
||||
// - carta-porte-validator.service.ts (validacion SAT detallada)
|
||||
// - 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)
|
||||
export * from './unidad.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,
|
||||
RutaCalculada,
|
||||
} 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 { OrdenesTransporteService, CreateOrdenTransporteDto, UpdateOrdenTransporteDto } from '../services/ordenes-transporte.service';
|
||||
import {
|
||||
OrdenesTransporteService,
|
||||
CreateOrdenTransporteDto,
|
||||
UpdateOrdenTransporteDto,
|
||||
AsignarUnidadDto,
|
||||
} from '../services/ordenes-transporte.service';
|
||||
import { EstadoOrdenTransporte, ModalidadServicio } from '../entities';
|
||||
|
||||
export class OrdenesTransporteController {
|
||||
@ -26,6 +31,11 @@ export class OrdenesTransporteController {
|
||||
this.router.post('/:id/confirmar', this.confirmar.bind(this));
|
||||
this.router.post('/:id/asignar', this.asignar.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> {
|
||||
@ -329,4 +339,129 @@ export class OrdenesTransporteController {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// =============================================================================
|
||||
@ -812,6 +876,585 @@ export class OrdenesTransporteService {
|
||||
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
|
||||
*/
|
||||
|
||||
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
|
||||
* ERP Transportistas
|
||||
* Module: MAI-006 Tracking
|
||||
*/
|
||||
|
||||
export * from './tracking.service';
|
||||
|
||||
export {
|
||||
EventoTrackingService,
|
||||
CreateEventoTrackingDto,
|
||||
DateRange,
|
||||
CreateAlertaDto,
|
||||
PosicionGpsInput,
|
||||
EventoEstadisticas,
|
||||
} from './evento-tracking.service';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user