[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:
Adrian Flores Cortes 2026-02-03 02:52:40 -06:00
parent 48bb0c8d58
commit 2134ff98e5
14 changed files with 5101 additions and 1 deletions

View 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(', ')}`);
}
}
}

View File

@ -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)

View 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' },
];
}
}

View 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 }
);
}
}

View File

@ -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]);
}
}
}
}

View 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;
}
}

View 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;
}
}

View File

@ -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';

View 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;
}
}

View File

@ -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';

View File

@ -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);
}
}
}

View File

@ -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
*/

View 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);
}
}

View File

@ -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';