feat(entities): Complete viajes entities and adapt inventory/financial

- Add Viaje entity with transport-specific workflow states
- Add ParadaViaje entity for multi-stop support
- Add Pod entity for Proof of Delivery
- Adapt inventory/product.entity with TipoRefaccion for fleet parts
- Adapt financial/account.entity with TipoCuentaTransporte and TipoCentroCosto
- Update index exports for all modified modules

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 10:38:08 -06:00
parent 3cc989cf30
commit 99d18bb340
9 changed files with 606 additions and 2 deletions

View File

@ -12,6 +12,35 @@ import {
import { AccountType } from './account-type.entity.js';
import { Company } from '../../auth/entities/company.entity.js';
/**
* Tipo de Cuenta de Transporte
* Para clasificar cuentas específicas del giro
*/
export enum TipoCuentaTransporte {
GENERAL = 'GENERAL', // Cuenta genérica
COMBUSTIBLE = 'COMBUSTIBLE', // Gastos de combustible
PEAJES = 'PEAJES', // Cruces de casetas
MANTENIMIENTO = 'MANTENIMIENTO', // Mantenimiento de flota
VIATICOS = 'VIATICOS', // Viáticos y hospedaje
SEGURO = 'SEGURO', // Seguros de unidades/carga
DEPRECIACION = 'DEPRECIACION', // Depreciación de flota
NOMINA_OPERADORES = 'NOMINA_OPERADORES', // Sueldos operadores
INGRESOS_FLETE = 'INGRESOS_FLETE', // Ingresos por fletes
CARRIER = 'CARRIER', // Pagos a carriers terceros
}
/**
* Tipo de Centro de Costo
*/
export enum TipoCentroCosto {
GENERAL = 'GENERAL',
UNIDAD = 'UNIDAD', // Por unidad/vehículo
RUTA = 'RUTA', // Por ruta/lane
CLIENTE = 'CLIENTE', // Por cliente
OPERADOR = 'OPERADOR', // Por operador
SUCURSAL = 'SUCURSAL', // Por sucursal/patio
}
@Entity({ schema: 'financial', name: 'accounts' })
@Index('idx_accounts_tenant_id', ['tenantId'])
@Index('idx_accounts_company_id', ['companyId'])
@ -52,6 +81,57 @@ export class Account {
@Column({ type: 'text', nullable: true })
notes: string | null;
// === CAMPOS ESPECÍFICOS PARA TRANSPORTE ===
/**
* Tipo de cuenta específico para operaciones de transporte
*/
@Column({
type: 'enum',
enum: TipoCuentaTransporte,
nullable: true,
name: 'tipo_cuenta_transporte',
})
tipoCuentaTransporte: TipoCuentaTransporte | null;
/**
* Tipo de centro de costo para análisis financiero
*/
@Column({
type: 'enum',
enum: TipoCentroCosto,
nullable: true,
name: 'tipo_centro_costo',
})
tipoCentroCosto: TipoCentroCosto | null;
/**
* ID del centro de costo asociado (unidad, operador, ruta, etc.)
* Referencia dinámica según tipoCentroCosto
*/
@Column({ type: 'uuid', nullable: true, name: 'centro_costo_id' })
centroCostoId: string | null;
/**
* Nombre/descripción del centro de costo para reportes
*/
@Column({ type: 'varchar', length: 200, nullable: true, name: 'centro_costo_nombre' })
centroCostoNombre: string | null;
/**
* Indica si esta cuenta aplica para liquidaciones de viajes
*/
@Column({ type: 'boolean', default: false, name: 'aplica_liquidacion' })
aplicaLiquidacion: boolean;
/**
* Indica si requiere comprobante fiscal (CFDI)
*/
@Column({ type: 'boolean', default: false, name: 'requiere_cfdi' })
requiereCfdi: boolean;
// === FIN CAMPOS TRANSPORTE ===
// Relations
@ManyToOne(() => Company)
@JoinColumn({ name: 'company_id' })

View File

@ -1,6 +1,6 @@
// Account entities
export { AccountType, AccountTypeEnum } from './account-type.entity.js';
export { Account } from './account.entity.js';
export { Account, TipoCuentaTransporte, TipoCentroCosto } from './account.entity.js';
export { AccountMapping, AccountMappingType } from './account-mapping.entity.js';
// Journal entities

View File

@ -1,5 +1,5 @@
// Core Inventory Entities
export { Product } from './product.entity.js';
export { Product, TipoRefaccion, ProductType, TrackingType, ValuationMethod } from './product.entity.js';
// Re-export Warehouse from canonical location in warehouses module
export { Warehouse } from '../../warehouses/entities/warehouse.entity.js';
export { Location } from './location.entity.js';

View File

@ -13,12 +13,18 @@ import { Lot } from './lot.entity.js';
/**
* Inventory Product Entity (schema: inventory.products)
*
* ADAPTADO PARA ERP TRANSPORTISTAS:
* Este módulo se usa principalmente para gestionar refacciones de flota.
* Incluye campos específicos para identificar partes de vehículos y su
* compatibilidad con unidades de la flota.
*
* NOTE: This is NOT a duplicate of products/entities/product.entity.ts
*
* Key differences:
* - This entity: inventory.products - Warehouse/stock management focused (Odoo-style)
* - Has: valuationMethod, tracking (lot/serial), isStorable, StockQuant/Lot relations
* - Used by: Inventory module for stock tracking, valuation, picking operations
* - TRANSPORT: tipoRefaccion, numeroParte, unidadesCompatibles
*
* - Products entity: products.products - Commerce/retail focused
* - Has: SAT codes, tax rates, detailed dimensions, min/max stock, reorder points
@ -33,6 +39,24 @@ export enum ProductType {
SERVICE = 'service',
}
/**
* Tipo de Refacción (específico para transporte)
*/
export enum TipoRefaccion {
MOTOR = 'MOTOR',
FRENOS = 'FRENOS',
LLANTAS = 'LLANTAS',
SUSPENSION = 'SUSPENSION',
TRANSMISION = 'TRANSMISION',
ELECTRICO = 'ELECTRICO',
CARROCERIA = 'CARROCERIA',
REFRIGERACION = 'REFRIGERACION',
LUBRICANTES = 'LUBRICANTES',
FILTROS = 'FILTROS',
ACCESORIOS = 'ACCESORIOS',
OTRO = 'OTRO',
}
export enum TrackingType {
NONE = 'none',
LOT = 'lot',
@ -139,6 +163,77 @@ export class Product {
@Column({ type: 'boolean', default: true, nullable: false })
active: boolean;
// === CAMPOS ESPECÍFICOS PARA REFACCIONES DE FLOTA ===
/**
* Tipo de refacción (para clasificación en mantenimiento)
*/
@Column({
type: 'enum',
enum: TipoRefaccion,
nullable: true,
name: 'tipo_refaccion',
})
tipoRefaccion: TipoRefaccion | null;
/**
* Número de parte del fabricante (OEM)
*/
@Column({ type: 'varchar', length: 100, nullable: true, name: 'numero_parte' })
numeroParte: string | null;
/**
* Número de parte alterno/equivalente
*/
@Column({ type: 'varchar', length: 100, nullable: true, name: 'numero_parte_alterno' })
numeroParteAlterno: string | null;
/**
* Marca del fabricante de la refacción
*/
@Column({ type: 'varchar', length: 100, nullable: true, name: 'marca_refaccion' })
marcaRefaccion: string | null;
/**
* IDs de unidades donde esta refacción es compatible
* Referencia a fleet.unidades
*/
@Column({ type: 'uuid', array: true, nullable: true, name: 'unidades_compatibles' })
unidadesCompatibles: string[] | null;
/**
* Modelos de vehículos compatibles (texto libre)
* Ej: "Kenworth T680 2020-2024, Freightliner Cascadia"
*/
@Column({ type: 'text', nullable: true, name: 'modelos_compatibles' })
modelosCompatibles: string | null;
/**
* Vida útil estimada en kilómetros
*/
@Column({ type: 'int', nullable: true, name: 'vida_util_km' })
vidaUtilKm: number | null;
/**
* Vida útil estimada en horas de operación
*/
@Column({ type: 'int', nullable: true, name: 'vida_util_horas' })
vidaUtilHoras: number | null;
/**
* Indica si es una refacción crítica (afecta operación)
*/
@Column({ type: 'boolean', default: false, name: 'es_critica' })
esCritica: boolean;
/**
* Stock mínimo recomendado para refacciones críticas
*/
@Column({ type: 'int', nullable: true, name: 'stock_minimo_critico' })
stockMinimoCritico: number | null;
// === FIN CAMPOS REFACCIONES ===
// Relations
@OneToMany(() => StockQuant, (stockQuant) => stockQuant.product)
stockQuants: StockQuant[];

View File

@ -1,3 +1,12 @@
/**
* Ordenes de Transporte Entities
* Schema: transport
*/
// Entities de Transporte
export * from './orden-transporte.entity';
// Entities heredadas de sales (para cotizaciones)
export { Quotation } from './quotation.entity';
export { QuotationItem } from './quotation-item.entity';
export { SalesOrder } from './sales-order.entity';

View File

@ -1 +1,10 @@
/**
* Viajes Entities
* Schema: transport
*/
export * from './viaje.entity';
export * from './parada-viaje.entity';
export * from './pod.entity';
// Entities heredadas de projects
export * from './timesheet.entity.js';

View File

@ -0,0 +1,124 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Viaje } from './viaje.entity';
/**
* Tipo de Parada
*/
export enum TipoParada {
RECOLECCION = 'RECOLECCION',
ENTREGA = 'ENTREGA',
ESCALA = 'ESCALA',
}
/**
* Estado de la Parada
*/
export enum EstadoParada {
PENDIENTE = 'PENDIENTE',
EN_CAMINO = 'EN_CAMINO',
LLEGADA = 'LLEGADA',
EN_PROCESO = 'EN_PROCESO',
COMPLETADA = 'COMPLETADA',
OMITIDA = 'OMITIDA',
}
@Entity({ schema: 'transport', name: 'paradas_viaje' })
@Index('idx_parada_viaje', ['viajeId'])
export class ParadaViaje {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'viaje_id', type: 'uuid' })
viajeId: string;
@ManyToOne(() => Viaje)
@JoinColumn({ name: 'viaje_id' })
viaje: Viaje;
// Secuencia
@Column({ type: 'int' })
secuencia: number;
// Tipo de parada
@Column({ type: 'enum', enum: TipoParada })
tipo: TipoParada;
// Ubicación
@Column({ type: 'text' })
direccion: string;
@Column({ name: 'codigo_postal', type: 'varchar', length: 10, nullable: true })
codigoPostal: string;
@Column({ type: 'varchar', length: 100, nullable: true })
ciudad: string;
@Column({ type: 'varchar', length: 100, nullable: true })
estado: string;
@Column({ type: 'decimal', precision: 10, scale: 7, nullable: true })
latitud: number;
@Column({ type: 'decimal', precision: 10, scale: 7, nullable: true })
longitud: number;
// Contacto
@Column({ name: 'contacto_nombre', type: 'varchar', length: 200, nullable: true })
contactoNombre: string;
@Column({ name: 'contacto_telefono', type: 'varchar', length: 30, nullable: true })
contactoTelefono: string;
// Programación
@Column({ name: 'hora_programada_llegada', type: 'timestamptz', nullable: true })
horaProgramadaLlegada: Date;
@Column({ name: 'hora_programada_salida', type: 'timestamptz', nullable: true })
horaProgramadaSalida: Date;
// Real
@Column({ name: 'hora_real_llegada', type: 'timestamptz', nullable: true })
horaRealLlegada: Date;
@Column({ name: 'hora_real_salida', type: 'timestamptz', nullable: true })
horaRealSalida: Date;
// OTs asociadas
@Column({ name: 'ots_ids', type: 'uuid', array: true, nullable: true })
otsIds: string[];
// Estado
@Column({ name: 'estado_parada', type: 'enum', enum: EstadoParada, default: EstadoParada.PENDIENTE })
estadoParada: EstadoParada;
// Observaciones
@Column({ type: 'text', nullable: true })
observaciones: string;
// Helper para calcular tiempo de permanencia
get tiempoPermanenciaMinutos(): number | null {
if (this.horaRealLlegada && this.horaRealSalida) {
const diff = this.horaRealSalida.getTime() - this.horaRealLlegada.getTime();
return Math.round(diff / (1000 * 60));
}
return null;
}
// Helper para verificar si está retrasada
get estaRetrasada(): boolean {
if (this.horaProgramadaLlegada && this.horaRealLlegada) {
return this.horaRealLlegada > this.horaProgramadaLlegada;
}
return false;
}
}

View File

@ -0,0 +1,120 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Viaje } from './viaje.entity';
import { ParadaViaje } from './parada-viaje.entity';
/**
* Estado del POD (Proof of Delivery)
*/
export enum EstadoPod {
PENDIENTE = 'PENDIENTE',
PARCIAL = 'PARCIAL',
COMPLETO = 'COMPLETO',
RECHAZADO = 'RECHAZADO',
}
/**
* Foto de Evidencia
*/
export interface FotoEvidencia {
url: string;
descripcion?: string;
tipo: 'MERCANCIA' | 'FIRMA' | 'DANIO' | 'SELLO' | 'DOCUMENTO' | 'OTRO';
timestamp: Date;
}
@Entity({ schema: 'transport', name: 'pod' })
@Index('idx_pod_viaje', ['viajeId'])
@Index('idx_pod_estado', ['tenantId', 'estado'])
export class Pod {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'viaje_id', type: 'uuid' })
viajeId: string;
@ManyToOne(() => Viaje)
@JoinColumn({ name: 'viaje_id' })
viaje: Viaje;
@Column({ name: 'parada_id', type: 'uuid', nullable: true })
paradaId: string;
@ManyToOne(() => ParadaViaje)
@JoinColumn({ name: 'parada_id' })
parada: ParadaViaje;
@Column({ name: 'ot_id', type: 'uuid', nullable: true })
otId: string;
// Estado POD
@Column({ type: 'enum', enum: EstadoPod, default: EstadoPod.PENDIENTE })
estado: EstadoPod;
// Recepción
@Column({ name: 'receptor_nombre', type: 'varchar', length: 200, nullable: true })
receptorNombre: string;
@Column({ name: 'receptor_identificacion', type: 'varchar', length: 50, nullable: true })
receptorIdentificacion: string;
@Column({ name: 'fecha_recepcion', type: 'timestamptz', nullable: true })
fechaRecepcion: Date;
// Firma digital (Base64 de la imagen de firma)
@Column({ name: 'firma_digital', type: 'text', nullable: true })
firmaDigital: string;
// Evidencias (URLs o IDs de archivos)
@Column({ name: 'fotos_entrega', type: 'jsonb', nullable: true })
fotosEntrega: FotoEvidencia[];
// Cantidades
@Column({ name: 'piezas_entregadas', type: 'int', nullable: true })
piezasEntregadas: number;
@Column({ name: 'piezas_rechazadas', type: 'int', nullable: true })
piezasRechazadas: number;
@Column({ name: 'piezas_danadas', type: 'int', nullable: true })
piezasDanadas: number;
// Observaciones
@Column({ type: 'text', nullable: true })
observaciones: string;
@Column({ name: 'motivo_rechazo', type: 'text', nullable: true })
motivoRechazo: string;
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by_id', type: 'uuid', nullable: true })
createdById: string;
// Helpers
get entregaCompleta(): boolean {
return this.estado === EstadoPod.COMPLETO &&
(this.piezasRechazadas || 0) === 0 &&
(this.piezasDanadas || 0) === 0;
}
get tieneFirma(): boolean {
return !!this.firmaDigital;
}
get tieneEvidenciasFotograficas(): boolean {
return Array.isArray(this.fotosEntrega) && this.fotosEntrega.length > 0;
}
}

View File

@ -0,0 +1,167 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
OneToMany,
ManyToOne,
JoinColumn,
} from 'typeorm';
/**
* Estado del Viaje
*/
export enum EstadoViaje {
BORRADOR = 'BORRADOR',
PLANEADO = 'PLANEADO',
DESPACHADO = 'DESPACHADO',
EN_TRANSITO = 'EN_TRANSITO',
EN_DESTINO = 'EN_DESTINO',
ENTREGADO = 'ENTREGADO',
CERRADO = 'CERRADO',
FACTURADO = 'FACTURADO',
COBRADO = 'COBRADO',
CANCELADO = 'CANCELADO',
}
/**
* Información de Sello
*/
export interface InfoSello {
numero: string;
tipo: 'PLASTICO' | 'METALICO' | 'ELECTRONICO' | 'CANDADO';
colocadoPor?: string;
foto?: string;
timestamp?: Date;
}
@Entity({ schema: 'transport', name: 'viajes' })
@Index('idx_viaje_tenant', ['tenantId'])
@Index('idx_viaje_estado', ['tenantId', 'estado'])
@Index('idx_viaje_unidad', ['tenantId', 'unidadId'])
@Index('idx_viaje_operador', ['tenantId', 'operadorId'])
@Index('idx_viaje_fechas', ['tenantId', 'fechaSalidaProgramada'])
export class Viaje {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
// Identificación
@Column({ type: 'varchar', length: 50 })
codigo: string;
// Unidad y operador (referencias a fleet schema)
@Column({ name: 'unidad_id', type: 'uuid' })
unidadId: string;
@Column({ name: 'remolque_id', type: 'uuid', nullable: true })
remolqueId: string;
@Column({ name: 'operador_id', type: 'uuid' })
operadorId: string;
// Ruta
@Column({ name: 'origen_principal', type: 'varchar', length: 200, nullable: true })
origenPrincipal: string;
@Column({ name: 'destino_principal', type: 'varchar', length: 200, nullable: true })
destinoPrincipal: string;
@Column({ name: 'distancia_estimada_km', type: 'decimal', precision: 10, scale: 2, nullable: true })
distanciaEstimadaKm: number;
@Column({ name: 'tiempo_estimado_horas', type: 'decimal', precision: 6, scale: 2, nullable: true })
tiempoEstimadoHoras: number;
// Fechas programadas
@Column({ name: 'fecha_salida_programada', type: 'timestamptz', nullable: true })
fechaSalidaProgramada: Date;
@Column({ name: 'fecha_llegada_programada', type: 'timestamptz', nullable: true })
fechaLlegadaProgramada: Date;
// Fechas reales
@Column({ name: 'fecha_salida_real', type: 'timestamptz', nullable: true })
fechaSalidaReal: Date;
@Column({ name: 'fecha_llegada_real', type: 'timestamptz', nullable: true })
fechaLlegadaReal: Date;
// Kilometraje
@Column({ name: 'km_inicio', type: 'int', nullable: true })
kmInicio: number;
@Column({ name: 'km_fin', type: 'int', nullable: true })
kmFin: number;
// Calculado: km_recorridos = km_fin - km_inicio
get kmRecorridos(): number | null {
if (this.kmInicio != null && this.kmFin != null) {
return this.kmFin - this.kmInicio;
}
return null;
}
// Estado
@Column({ type: 'enum', enum: EstadoViaje, default: EstadoViaje.BORRADOR })
estado: EstadoViaje;
// Checklist pre-viaje
@Column({ name: 'checklist_completado', type: 'boolean', default: false })
checklistCompletado: boolean;
@Column({ name: 'checklist_fecha', type: 'timestamptz', nullable: true })
checklistFecha: Date;
@Column({ name: 'checklist_observaciones', type: 'text', nullable: true })
checklistObservaciones: string;
// Sellos
@Column({ name: 'sellos_salida', type: 'jsonb', nullable: true })
sellosSalida: InfoSello[];
@Column({ name: 'sellos_llegada', type: 'jsonb', nullable: true })
sellosLlegada: InfoSello[];
// Costos
@Column({ name: 'costo_combustible', type: 'decimal', precision: 15, scale: 2, default: 0 })
costoCombustible: number;
@Column({ name: 'costo_peajes', type: 'decimal', precision: 15, scale: 2, default: 0 })
costoPeajes: number;
@Column({ name: 'costo_viaticos', type: 'decimal', precision: 15, scale: 2, default: 0 })
costoViaticos: number;
@Column({ name: 'costo_otros', type: 'decimal', precision: 15, scale: 2, default: 0 })
costoOtros: number;
@Column({ name: 'costo_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
costoTotal: number;
// Ingresos
@Column({ name: 'ingreso_total', type: 'decimal', precision: 15, scale: 2, default: 0 })
ingresoTotal: number;
// Calculado: margen = ingreso_total - costo_total
get margen(): number {
return (this.ingresoTotal || 0) - (this.costoTotal || 0);
}
// Auditoría
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by_id', type: 'uuid' })
createdById: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
updatedById: string;
}