feat: Implementar servicios core críticos

- CartaPorteService: CRUD completo para Carta Porte 3.1 SAT
- RolesService: Gestión de roles con aislamiento por tenant
- PermissionsService: Gestión de permisos con bulk creation
- TarifasService: Motor de cotización con múltiples tipos de tarifa
- LanesService: Gestión de rutas origen-destino

Total: ~1,800 líneas de código de servicios

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-27 10:48:45 -06:00
parent 7d6d4b7caa
commit 6e89e7f4c5
9 changed files with 1830 additions and 10 deletions

View File

@ -6,3 +6,9 @@ export { default as authRoutes } from './auth.routes.js';
export * from './apiKeys.service.js';
export * from './apiKeys.controller.js';
export { default as apiKeysRoutes } from './apiKeys.routes.js';
// Entities
export * from './entities/index.js';
// Additional Services (roles, permissions)
export * from './services/index.js';

View File

@ -0,0 +1,7 @@
/**
* Auth Services
* Roles, permisos, autenticacion
*/
export * from './token.service.js';
export * from './roles.service.js';
export * from './permissions.service.js';

View File

@ -0,0 +1,292 @@
import { Repository, FindOptionsWhere, ILike, In } from 'typeorm';
import { Permission, PermissionAction, Role } from '../entities/index.js';
export interface PermissionSearchParams {
search?: string;
resource?: string;
action?: PermissionAction;
module?: string;
limit?: number;
offset?: number;
}
export interface CreatePermissionDto {
resource: string;
action: PermissionAction;
description?: string;
module?: string;
}
export interface UpdatePermissionDto {
description?: string;
module?: string;
}
export interface BulkCreatePermissionsDto {
resource: string;
actions: PermissionAction[];
module?: string;
}
export class PermissionsService {
constructor(
private readonly permissionRepository: Repository<Permission>,
private readonly roleRepository: Repository<Role>,
) {}
async findAll(params: PermissionSearchParams): Promise<{ data: Permission[]; total: number }> {
const {
search,
resource,
action,
module,
limit = 100,
offset = 0,
} = params;
const where: FindOptionsWhere<Permission>[] = [];
const baseWhere: FindOptionsWhere<Permission> = {};
if (resource) {
baseWhere.resource = resource;
}
if (action) {
baseWhere.action = action;
}
if (module) {
baseWhere.module = module;
}
if (search) {
where.push(
{ ...baseWhere, resource: ILike(`%${search}%`) },
{ ...baseWhere, description: ILike(`%${search}%`) },
{ ...baseWhere, module: ILike(`%${search}%`) }
);
} else {
where.push(baseWhere);
}
const [data, total] = await this.permissionRepository.findAndCount({
where,
take: limit,
skip: offset,
order: { module: 'ASC', resource: 'ASC', action: 'ASC' },
});
return { data, total };
}
async findOne(id: string): Promise<Permission | null> {
return this.permissionRepository.findOne({
where: { id },
relations: ['roles'],
});
}
async findByResourceAction(resource: string, action: PermissionAction): Promise<Permission | null> {
return this.permissionRepository.findOne({
where: { resource, action },
});
}
async findByModule(module: string): Promise<Permission[]> {
return this.permissionRepository.find({
where: { module },
order: { resource: 'ASC', action: 'ASC' },
});
}
async findByResource(resource: string): Promise<Permission[]> {
return this.permissionRepository.find({
where: { resource },
order: { action: 'ASC' },
});
}
async create(dto: CreatePermissionDto): Promise<Permission> {
// Check if permission already exists
const existing = await this.findByResourceAction(dto.resource, dto.action);
if (existing) {
throw new Error(`Ya existe el permiso ${dto.action} para el recurso ${dto.resource}`);
}
const permission = this.permissionRepository.create({
resource: dto.resource,
action: dto.action,
description: dto.description ?? null,
module: dto.module ?? null,
});
return this.permissionRepository.save(permission);
}
async update(id: string, dto: UpdatePermissionDto): Promise<Permission | null> {
const permission = await this.findOne(id);
if (!permission) return null;
if (dto.description !== undefined) {
permission.description = dto.description ?? null;
}
if (dto.module !== undefined) {
permission.module = dto.module ?? null;
}
return this.permissionRepository.save(permission);
}
// Create multiple permissions for a resource (CRUD operations)
async bulkCreateForResource(dto: BulkCreatePermissionsDto): Promise<Permission[]> {
const created: Permission[] = [];
for (const action of dto.actions) {
const existing = await this.findByResourceAction(dto.resource, action);
if (!existing) {
const permission = this.permissionRepository.create({
resource: dto.resource,
action,
description: `${action} ${dto.resource}`,
module: dto.module ?? null,
});
const saved = await this.permissionRepository.save(permission);
created.push(saved);
}
}
return created;
}
// Create standard CRUD permissions for a resource
async createCrudPermissions(resource: string, module?: string): Promise<Permission[]> {
const crudActions = [
PermissionAction.CREATE,
PermissionAction.READ,
PermissionAction.UPDATE,
PermissionAction.DELETE,
];
return this.bulkCreateForResource({
resource,
actions: crudActions,
module,
});
}
// Get all unique resources
async getResources(): Promise<string[]> {
const permissions = await this.permissionRepository
.createQueryBuilder('p')
.select('DISTINCT p.resource', 'resource')
.orderBy('p.resource', 'ASC')
.getRawMany();
return permissions.map(p => p.resource);
}
// Get all unique modules
async getModules(): Promise<string[]> {
const permissions = await this.permissionRepository
.createQueryBuilder('p')
.select('DISTINCT p.module', 'module')
.where('p.module IS NOT NULL')
.orderBy('p.module', 'ASC')
.getRawMany();
return permissions.map(p => p.module);
}
// Get permissions grouped by module
async getPermissionsByModule(): Promise<Map<string, Permission[]>> {
const permissions = await this.permissionRepository.find({
order: { module: 'ASC', resource: 'ASC', action: 'ASC' },
});
const grouped = new Map<string, Permission[]>();
for (const permission of permissions) {
const module = permission.module ?? 'other';
if (!grouped.has(module)) {
grouped.set(module, []);
}
grouped.get(module)!.push(permission);
}
return grouped;
}
// Get permissions grouped by resource
async getPermissionsByResource(): Promise<Map<string, Permission[]>> {
const permissions = await this.permissionRepository.find({
order: { resource: 'ASC', action: 'ASC' },
});
const grouped = new Map<string, Permission[]>();
for (const permission of permissions) {
if (!grouped.has(permission.resource)) {
grouped.set(permission.resource, []);
}
grouped.get(permission.resource)!.push(permission);
}
return grouped;
}
// Check if a role has a specific permission
async roleHasPermission(
roleId: string,
resource: string,
action: PermissionAction
): Promise<boolean> {
const permission = await this.findByResourceAction(resource, action);
if (!permission) return false;
const role = await this.roleRepository.findOne({
where: { id: roleId },
relations: ['permissions'],
});
if (!role) return false;
return role.permissions.some(p => p.id === permission.id);
}
// Get roles that have a specific permission
async getRolesWithPermission(permissionId: string): Promise<Role[]> {
const permission = await this.findOne(permissionId);
if (!permission) return [];
return permission.roles ?? [];
}
async delete(id: string): Promise<boolean> {
const permission = await this.findOne(id);
if (!permission) return false;
// Check if permission is assigned to any roles
if (permission.roles && permission.roles.length > 0) {
throw new Error('No se puede eliminar un permiso asignado a roles. Primero quite el permiso de los roles.');
}
const result = await this.permissionRepository.delete(id);
return (result.affected ?? 0) > 0;
}
// Delete all permissions for a resource
async deleteByResource(resource: string): Promise<number> {
const permissions = await this.findByResource(resource);
// Check if any permission is assigned to roles
for (const permission of permissions) {
const fullPermission = await this.findOne(permission.id);
if (fullPermission?.roles && fullPermission.roles.length > 0) {
throw new Error(`El permiso ${permission.action} de ${resource} esta asignado a roles`);
}
}
const result = await this.permissionRepository.delete({ resource });
return result.affected ?? 0;
}
}

View File

@ -0,0 +1,302 @@
import { Repository, FindOptionsWhere, ILike, In } from 'typeorm';
import { Role, Permission, User } from '../entities/index.js';
export interface RoleSearchParams {
tenantId: string;
search?: string;
isSystem?: boolean;
limit?: number;
offset?: number;
}
export interface CreateRoleDto {
name: string;
code: string;
description?: string;
isSystem?: boolean;
color?: string;
permissionIds?: string[];
}
export interface UpdateRoleDto extends Partial<CreateRoleDto> {}
export interface AssignPermissionsDto {
permissionIds: string[];
}
export class RolesService {
constructor(
private readonly roleRepository: Repository<Role>,
private readonly permissionRepository: Repository<Permission>,
) {}
async findAll(params: RoleSearchParams): Promise<{ data: Role[]; total: number }> {
const {
tenantId,
search,
isSystem,
limit = 50,
offset = 0,
} = params;
const where: FindOptionsWhere<Role>[] = [];
const baseWhere: FindOptionsWhere<Role> = { tenantId };
if (isSystem !== undefined) {
baseWhere.isSystem = isSystem;
}
if (search) {
where.push(
{ ...baseWhere, name: ILike(`%${search}%`) },
{ ...baseWhere, code: ILike(`%${search}%`) },
{ ...baseWhere, description: ILike(`%${search}%`) }
);
} else {
where.push(baseWhere);
}
const [data, total] = await this.roleRepository.findAndCount({
where,
relations: ['permissions'],
take: limit,
skip: offset,
order: { isSystem: 'DESC', name: 'ASC' },
});
return { data, total };
}
async findOne(id: string, tenantId: string): Promise<Role | null> {
return this.roleRepository.findOne({
where: { id, tenantId },
relations: ['permissions', 'users'],
});
}
async findByCode(code: string, tenantId: string): Promise<Role | null> {
return this.roleRepository.findOne({
where: { code, tenantId },
relations: ['permissions'],
});
}
async create(tenantId: string, dto: CreateRoleDto, createdBy: string): Promise<Role> {
// Check if code already exists
const existingCode = await this.findByCode(dto.code, tenantId);
if (existingCode) {
throw new Error(`Ya existe un rol con el codigo "${dto.code}"`);
}
const role = this.roleRepository.create({
tenantId,
name: dto.name,
code: dto.code,
description: dto.description ?? null,
isSystem: dto.isSystem ?? false,
color: dto.color ?? null,
createdBy,
});
// If permissions were provided, assign them
if (dto.permissionIds && dto.permissionIds.length > 0) {
const permissions = await this.permissionRepository.findBy({
id: In(dto.permissionIds),
});
role.permissions = permissions;
}
return this.roleRepository.save(role);
}
async update(
id: string,
tenantId: string,
dto: UpdateRoleDto,
updatedBy: string
): Promise<Role | null> {
const role = await this.findOne(id, tenantId);
if (!role) return null;
// Cannot modify system roles
if (role.isSystem && !dto.description && !dto.color) {
throw new Error('Los roles del sistema solo permiten modificar descripcion y color');
}
// Check for code uniqueness if changing code
if (dto.code && dto.code !== role.code) {
const existingCode = await this.findByCode(dto.code, tenantId);
if (existingCode && existingCode.id !== id) {
throw new Error(`Ya existe un rol con el codigo "${dto.code}"`);
}
}
// Update basic fields
if (dto.name !== undefined && !role.isSystem) role.name = dto.name;
if (dto.code !== undefined && !role.isSystem) role.code = dto.code;
if (dto.description !== undefined) role.description = dto.description ?? null;
if (dto.color !== undefined) role.color = dto.color ?? null;
role.updatedBy = updatedBy;
// Update permissions if provided
if (dto.permissionIds !== undefined) {
const permissions = await this.permissionRepository.findBy({
id: In(dto.permissionIds),
});
role.permissions = permissions;
}
return this.roleRepository.save(role);
}
async assignPermissions(
id: string,
tenantId: string,
dto: AssignPermissionsDto,
updatedBy: string
): Promise<Role | null> {
const role = await this.findOne(id, tenantId);
if (!role) return null;
const permissions = await this.permissionRepository.findBy({
id: In(dto.permissionIds),
});
role.permissions = permissions;
role.updatedBy = updatedBy;
return this.roleRepository.save(role);
}
async addPermission(
id: string,
tenantId: string,
permissionId: string,
updatedBy: string
): Promise<Role | null> {
const role = await this.findOne(id, tenantId);
if (!role) return null;
const permission = await this.permissionRepository.findOne({
where: { id: permissionId },
});
if (!permission) {
throw new Error('Permiso no encontrado');
}
// Check if permission already assigned
const hasPermission = role.permissions.some(p => p.id === permissionId);
if (!hasPermission) {
role.permissions.push(permission);
role.updatedBy = updatedBy;
return this.roleRepository.save(role);
}
return role;
}
async removePermission(
id: string,
tenantId: string,
permissionId: string,
updatedBy: string
): Promise<Role | null> {
const role = await this.findOne(id, tenantId);
if (!role) return null;
role.permissions = role.permissions.filter(p => p.id !== permissionId);
role.updatedBy = updatedBy;
return this.roleRepository.save(role);
}
async getPermissions(id: string, tenantId: string): Promise<Permission[]> {
const role = await this.findOne(id, tenantId);
if (!role) return [];
return role.permissions;
}
async getUsers(id: string, tenantId: string): Promise<User[]> {
const role = await this.findOne(id, tenantId);
if (!role) return [];
return role.users;
}
async getUserCount(id: string, tenantId: string): Promise<number> {
const role = await this.roleRepository.findOne({
where: { id, tenantId },
relations: ['users'],
});
return role?.users?.length ?? 0;
}
// Get system roles (admin, user, etc.)
async getSystemRoles(tenantId: string): Promise<Role[]> {
return this.roleRepository.find({
where: { tenantId, isSystem: true },
relations: ['permissions'],
order: { name: 'ASC' },
});
}
// Get custom roles (non-system)
async getCustomRoles(tenantId: string): Promise<Role[]> {
return this.roleRepository.find({
where: { tenantId, isSystem: false },
relations: ['permissions'],
order: { name: 'ASC' },
});
}
// Clone a role
async cloneRole(
id: string,
tenantId: string,
newCode: string,
newName: string,
createdBy: string
): Promise<Role | null> {
const sourceRole = await this.findOne(id, tenantId);
if (!sourceRole) return null;
// Check if new code exists
const existingCode = await this.findByCode(newCode, tenantId);
if (existingCode) {
throw new Error(`Ya existe un rol con el codigo "${newCode}"`);
}
const newRole = this.roleRepository.create({
tenantId,
name: newName,
code: newCode,
description: sourceRole.description,
isSystem: false, // Cloned roles are never system roles
color: sourceRole.color,
permissions: sourceRole.permissions,
createdBy,
});
return this.roleRepository.save(newRole);
}
async delete(id: string, tenantId: string): Promise<boolean> {
const role = await this.findOne(id, tenantId);
if (!role) return false;
// Cannot delete system roles
if (role.isSystem) {
throw new Error('No se pueden eliminar roles del sistema');
}
// Check if role has users
if (role.users && role.users.length > 0) {
throw new Error('No se puede eliminar un rol que tiene usuarios asignados');
}
const result = await this.roleRepository.softDelete(id);
return (result.affected ?? 0) > 0;
}
}

View File

@ -0,0 +1,446 @@
import { Repository, FindOptionsWhere, ILike, In, Between } from 'typeorm';
import {
CartaPorte,
EstadoCartaPorte,
TipoCfdiCartaPorte,
UbicacionCartaPorte,
MercanciaCartaPorte,
FiguraTransporte,
AutotransporteCartaPorte,
} from '../entities';
export interface CartaPorteSearchParams {
tenantId: string;
search?: string;
estado?: EstadoCartaPorte;
estados?: EstadoCartaPorte[];
tipoCfdi?: TipoCfdiCartaPorte;
viajeId?: string;
emisorRfc?: string;
receptorRfc?: string;
fechaDesde?: Date;
fechaHasta?: Date;
limit?: number;
offset?: number;
}
export interface CreateCartaPorteDto {
viajeId: string;
tipoCfdi: TipoCfdiCartaPorte;
versionCartaPorte?: string;
serie?: string;
// Emisor
emisorRfc: string;
emisorNombre: string;
emisorRegimenFiscal?: string;
// Receptor
receptorRfc: string;
receptorNombre: string;
receptorUsoCfdi?: string;
receptorDomicilioFiscalCp?: string;
// Totales
subtotal?: number;
total?: number;
moneda?: string;
// Transporte internacional
transporteInternacional?: boolean;
entradaSalidaMerc?: string;
paisOrigenDestino?: string;
// Datos autotransporte
permisoSct?: string;
numPermisoSct?: string;
configVehicular?: string;
pesoBrutoTotal?: number;
unidadPeso?: string;
numTotalMercancias?: number;
// Seguros
aseguraRespCivil?: string;
polizaRespCivil?: string;
aseguraMedAmbiente?: string;
polizaMedAmbiente?: string;
aseguraCarga?: string;
polizaCarga?: string;
primaSeguro?: number;
}
export interface UpdateCartaPorteDto extends Partial<CreateCartaPorteDto> {
estado?: EstadoCartaPorte;
}
export interface TimbrarCartaPorteDto {
uuidCfdi: string;
fechaTimbrado: Date;
xmlCfdi: string;
xmlCartaPorte: string;
pdfUrl?: string;
qrUrl?: string;
}
export interface CancelarCartaPorteDto {
motivoCancelacion: string;
uuidSustitucion?: string;
}
export class CartaPorteService {
constructor(
private readonly cartaPorteRepository: Repository<CartaPorte>,
private readonly ubicacionRepository: Repository<UbicacionCartaPorte>,
private readonly mercanciaRepository: Repository<MercanciaCartaPorte>,
private readonly figuraRepository: Repository<FiguraTransporte>,
private readonly autotransporteRepository: Repository<AutotransporteCartaPorte>,
) {}
async findAll(params: CartaPorteSearchParams): Promise<{ data: CartaPorte[]; total: number }> {
const {
tenantId,
search,
estado,
estados,
tipoCfdi,
viajeId,
emisorRfc,
receptorRfc,
fechaDesde,
fechaHasta,
limit = 50,
offset = 0,
} = params;
const where: FindOptionsWhere<CartaPorte>[] = [];
const baseWhere: FindOptionsWhere<CartaPorte> = { tenantId };
if (estado) {
baseWhere.estado = estado;
}
if (estados && estados.length > 0) {
baseWhere.estado = In(estados);
}
if (tipoCfdi) {
baseWhere.tipoCfdi = tipoCfdi;
}
if (viajeId) {
baseWhere.viajeId = viajeId;
}
if (emisorRfc) {
baseWhere.emisorRfc = emisorRfc;
}
if (receptorRfc) {
baseWhere.receptorRfc = receptorRfc;
}
if (fechaDesde && fechaHasta) {
baseWhere.createdAt = Between(fechaDesde, fechaHasta);
}
if (search) {
where.push(
{ ...baseWhere, folio: ILike(`%${search}%`) },
{ ...baseWhere, serie: ILike(`%${search}%`) },
{ ...baseWhere, emisorNombre: ILike(`%${search}%`) },
{ ...baseWhere, receptorNombre: ILike(`%${search}%`) },
{ ...baseWhere, receptorRfc: ILike(`%${search}%`) }
);
} else {
where.push(baseWhere);
}
const [data, total] = await this.cartaPorteRepository.findAndCount({
where,
relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'],
take: limit,
skip: offset,
order: { createdAt: 'DESC' },
});
return { data, total };
}
async findOne(id: string, tenantId: string): Promise<CartaPorte | null> {
return this.cartaPorteRepository.findOne({
where: { id, tenantId },
relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'],
});
}
async findByViaje(viajeId: string, tenantId: string): Promise<CartaPorte[]> {
return this.cartaPorteRepository.find({
where: { viajeId, tenantId },
relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'],
order: { createdAt: 'DESC' },
});
}
async findByUuid(uuidCfdi: string, tenantId: string): Promise<CartaPorte | null> {
return this.cartaPorteRepository.findOne({
where: { uuidCfdi, tenantId },
relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'],
});
}
async create(tenantId: string, dto: CreateCartaPorteDto, createdBy: string): Promise<CartaPorte> {
const cartaPorte = this.cartaPorteRepository.create({
...dto,
tenantId,
estado: EstadoCartaPorte.BORRADOR,
createdById: createdBy,
});
return this.cartaPorteRepository.save(cartaPorte);
}
async update(
id: string,
tenantId: string,
dto: UpdateCartaPorteDto,
updatedBy: string
): Promise<CartaPorte | null> {
const cartaPorte = await this.findOne(id, tenantId);
if (!cartaPorte) return null;
// Cannot update if already timbrada or cancelada
if (cartaPorte.estado === EstadoCartaPorte.TIMBRADA) {
throw new Error('No se puede modificar una Carta Porte ya timbrada');
}
if (cartaPorte.estado === EstadoCartaPorte.CANCELADA) {
throw new Error('No se puede modificar una Carta Porte cancelada');
}
Object.assign(cartaPorte, dto);
return this.cartaPorteRepository.save(cartaPorte);
}
async validar(id: string, tenantId: string): Promise<{ valid: boolean; errors: string[] }> {
const cartaPorte = await this.findOne(id, tenantId);
if (!cartaPorte) {
return { valid: false, errors: ['Carta Porte no encontrada'] };
}
const errors: string[] = [];
// Validaciones requeridas para Carta Porte 3.1
if (!cartaPorte.emisorRfc) errors.push('RFC del emisor es requerido');
if (!cartaPorte.emisorNombre) errors.push('Nombre del emisor es requerido');
if (!cartaPorte.receptorRfc) errors.push('RFC del receptor es requerido');
if (!cartaPorte.receptorNombre) errors.push('Nombre del receptor es requerido');
// Validar ubicaciones (minimo origen y destino)
if (!cartaPorte.ubicaciones || cartaPorte.ubicaciones.length < 2) {
errors.push('Se requieren al menos 2 ubicaciones (origen y destino)');
}
// Validar mercancias
if (!cartaPorte.mercancias || cartaPorte.mercancias.length === 0) {
errors.push('Se requiere al menos una mercancia');
}
// Validar figuras de transporte
if (!cartaPorte.figuras || cartaPorte.figuras.length === 0) {
errors.push('Se requiere al menos una figura de transporte (operador)');
}
// Validar autotransporte
if (!cartaPorte.autotransporte || cartaPorte.autotransporte.length === 0) {
errors.push('Se requiere informacion de autotransporte');
}
// Validar datos de transporte federal
if (!cartaPorte.permisoSct) errors.push('Tipo de permiso SCT es requerido');
if (!cartaPorte.numPermisoSct) errors.push('Numero de permiso SCT es requerido');
if (!cartaPorte.configVehicular) errors.push('Configuracion vehicular es requerida');
// Si es CFDI de ingreso, validar totales
if (cartaPorte.tipoCfdi === TipoCfdiCartaPorte.INGRESO) {
if (cartaPorte.total === null || cartaPorte.total === undefined) {
errors.push('Total del CFDI es requerido para tipo Ingreso');
}
}
return { valid: errors.length === 0, errors };
}
async marcarValidada(id: string, tenantId: string): Promise<CartaPorte | null> {
const cartaPorte = await this.findOne(id, tenantId);
if (!cartaPorte) return null;
const validation = await this.validar(id, tenantId);
if (!validation.valid) {
throw new Error(`Carta Porte no valida: ${validation.errors.join(', ')}`);
}
cartaPorte.estado = EstadoCartaPorte.VALIDADA;
return this.cartaPorteRepository.save(cartaPorte);
}
async timbrar(
id: string,
tenantId: string,
dto: TimbrarCartaPorteDto
): Promise<CartaPorte | null> {
const cartaPorte = await this.findOne(id, tenantId);
if (!cartaPorte) return null;
if (cartaPorte.estado !== EstadoCartaPorte.VALIDADA) {
throw new Error('La Carta Porte debe estar validada antes de timbrar');
}
Object.assign(cartaPorte, {
...dto,
estado: EstadoCartaPorte.TIMBRADA,
});
return this.cartaPorteRepository.save(cartaPorte);
}
async cancelar(
id: string,
tenantId: string,
dto: CancelarCartaPorteDto
): Promise<CartaPorte | null> {
const cartaPorte = await this.findOne(id, tenantId);
if (!cartaPorte) return null;
if (cartaPorte.estado !== EstadoCartaPorte.TIMBRADA) {
throw new Error('Solo se pueden cancelar Cartas Porte timbradas');
}
Object.assign(cartaPorte, {
estado: EstadoCartaPorte.CANCELADA,
fechaCancelacion: new Date(),
motivoCancelacion: dto.motivoCancelacion,
uuidSustitucion: dto.uuidSustitucion,
});
return this.cartaPorteRepository.save(cartaPorte);
}
// Ubicaciones
async addUbicacion(
cartaPorteId: string,
tenantId: string,
ubicacionData: Partial<UbicacionCartaPorte>
): Promise<UbicacionCartaPorte> {
const cartaPorte = await this.findOne(cartaPorteId, tenantId);
if (!cartaPorte) {
throw new Error('Carta Porte no encontrada');
}
if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) {
throw new Error('Solo se pueden agregar ubicaciones en estado BORRADOR');
}
const ubicacion = this.ubicacionRepository.create({
...ubicacionData,
cartaPorteId,
});
return this.ubicacionRepository.save(ubicacion);
}
// Mercancias
async addMercancia(
cartaPorteId: string,
tenantId: string,
mercanciaData: Partial<MercanciaCartaPorte>
): Promise<MercanciaCartaPorte> {
const cartaPorte = await this.findOne(cartaPorteId, tenantId);
if (!cartaPorte) {
throw new Error('Carta Porte no encontrada');
}
if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) {
throw new Error('Solo se pueden agregar mercancias en estado BORRADOR');
}
const mercancia = this.mercanciaRepository.create({
...mercanciaData,
cartaPorteId,
});
return this.mercanciaRepository.save(mercancia);
}
// Figuras de transporte
async addFigura(
cartaPorteId: string,
tenantId: string,
figuraData: Partial<FiguraTransporte>
): Promise<FiguraTransporte> {
const cartaPorte = await this.findOne(cartaPorteId, tenantId);
if (!cartaPorte) {
throw new Error('Carta Porte no encontrada');
}
if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) {
throw new Error('Solo se pueden agregar figuras en estado BORRADOR');
}
const figura = this.figuraRepository.create({
...figuraData,
cartaPorteId,
});
return this.figuraRepository.save(figura);
}
// Autotransporte
async addAutotransporte(
cartaPorteId: string,
tenantId: string,
autoData: Partial<AutotransporteCartaPorte>
): Promise<AutotransporteCartaPorte> {
const cartaPorte = await this.findOne(cartaPorteId, tenantId);
if (!cartaPorte) {
throw new Error('Carta Porte no encontrada');
}
if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) {
throw new Error('Solo se puede agregar autotransporte en estado BORRADOR');
}
const auto = this.autotransporteRepository.create({
...autoData,
cartaPorteId,
});
return this.autotransporteRepository.save(auto);
}
// Cartas porte pendientes de timbrar
async getPendientesTimbrar(tenantId: string): Promise<CartaPorte[]> {
return this.cartaPorteRepository.find({
where: {
tenantId,
estado: In([EstadoCartaPorte.BORRADOR, EstadoCartaPorte.VALIDADA]),
},
order: { createdAt: 'ASC' },
});
}
// Cartas porte por viaje (para reportes)
async getCartasPorteViaje(viajeId: string, tenantId: string): Promise<CartaPorte[]> {
return this.cartaPorteRepository.find({
where: { viajeId, tenantId },
relations: ['ubicaciones', 'mercancias', 'figuras', 'autotransporte'],
order: { createdAt: 'DESC' },
});
}
async delete(id: string, tenantId: string): Promise<boolean> {
const cartaPorte = await this.findOne(id, tenantId);
if (!cartaPorte) return false;
// Only allow deletion of BORRADOR
if (cartaPorte.estado !== EstadoCartaPorte.BORRADOR) {
throw new Error('Solo se pueden eliminar Cartas Porte en estado BORRADOR');
}
const result = await this.cartaPorteRepository.delete(id);
return (result.affected ?? 0) > 0;
}
}

View File

@ -1,8 +1,10 @@
/**
* Carta Porte Services
* CFDI con Complemento Carta Porte 3.1
*/
// TODO: Implement services
// - carta-porte.service.ts
// - carta-porte-validator.service.ts
// - pac-integration.service.ts
// - cfdi-generator.service.ts
export * from './carta-porte.service';
// TODO: Implement additional services
// - carta-porte-validator.service.ts (validacion SAT detallada)
// - pac-integration.service.ts (integracion con PAC)
// - cfdi-generator.service.ts (generacion XML CFDI)

View File

@ -1,7 +1,11 @@
/**
* Tarifas Services
* Tarifas de Transporte Services
* Cotizacion, lanes, tarifas
*/
// TODO: Implement services
// - tarifas.service.ts
// - recargos.service.ts
// - cotizador.service.ts
export * from './tarifas.service';
export * from './lanes.service';
// TODO: Implement additional services
// - recargos.service.ts (RecargoCatalogo)
// - fuel-surcharge.service.ts (FuelSurcharge)
// - cotizador.service.ts (motor de cotizacion avanzado)

View File

@ -0,0 +1,319 @@
import { Repository, FindOptionsWhere, ILike } from 'typeorm';
import { Lane } from '../entities';
export interface LaneSearchParams {
tenantId: string;
search?: string;
origenCiudad?: string;
origenEstado?: string;
destinoCiudad?: string;
destinoEstado?: string;
activo?: boolean;
limit?: number;
offset?: number;
}
export interface CreateLaneDto {
codigo: string;
nombre: string;
origenCiudad: string;
origenEstado: string;
origenCodigoPostal?: string;
destinoCiudad: string;
destinoEstado: string;
destinoCodigoPostal?: string;
distanciaKm?: number;
tiempoEstimadoHoras?: number;
}
export interface UpdateLaneDto extends Partial<CreateLaneDto> {
activo?: boolean;
}
export class LanesService {
constructor(private readonly laneRepository: Repository<Lane>) {}
async findAll(params: LaneSearchParams): Promise<{ data: Lane[]; total: number }> {
const {
tenantId,
search,
origenCiudad,
origenEstado,
destinoCiudad,
destinoEstado,
activo,
limit = 50,
offset = 0,
} = params;
const where: FindOptionsWhere<Lane>[] = [];
const baseWhere: FindOptionsWhere<Lane> = { tenantId };
if (activo !== undefined) {
baseWhere.activo = activo;
}
if (origenCiudad) {
baseWhere.origenCiudad = ILike(`%${origenCiudad}%`);
}
if (origenEstado) {
baseWhere.origenEstado = ILike(`%${origenEstado}%`);
}
if (destinoCiudad) {
baseWhere.destinoCiudad = ILike(`%${destinoCiudad}%`);
}
if (destinoEstado) {
baseWhere.destinoEstado = ILike(`%${destinoEstado}%`);
}
if (search) {
where.push(
{ ...baseWhere, codigo: ILike(`%${search}%`) },
{ ...baseWhere, nombre: ILike(`%${search}%`) },
{ ...baseWhere, origenCiudad: ILike(`%${search}%`) },
{ ...baseWhere, destinoCiudad: ILike(`%${search}%`) }
);
} else {
where.push(baseWhere);
}
const [data, total] = await this.laneRepository.findAndCount({
where,
take: limit,
skip: offset,
order: { activo: 'DESC', codigo: 'ASC' },
});
return { data, total };
}
async findOne(id: string, tenantId: string): Promise<Lane | null> {
return this.laneRepository.findOne({
where: { id, tenantId },
});
}
async findByCodigo(codigo: string, tenantId: string): Promise<Lane | null> {
return this.laneRepository.findOne({
where: { codigo, tenantId },
});
}
async findByOrigenDestino(
origenCiudad: string,
destinoCiudad: string,
tenantId: string
): Promise<Lane | null> {
return this.laneRepository.findOne({
where: {
origenCiudad: ILike(origenCiudad),
destinoCiudad: ILike(destinoCiudad),
tenantId,
activo: true,
},
});
}
async create(tenantId: string, dto: CreateLaneDto, createdBy: string): Promise<Lane> {
// Check if codigo already exists
const existingCodigo = await this.findByCodigo(dto.codigo, tenantId);
if (existingCodigo) {
throw new Error(`Ya existe una lane con el codigo "${dto.codigo}"`);
}
const lane = this.laneRepository.create({
...dto,
tenantId,
activo: true,
createdById: createdBy,
});
return this.laneRepository.save(lane);
}
async update(
id: string,
tenantId: string,
dto: UpdateLaneDto,
updatedBy: string
): Promise<Lane | null> {
const lane = await this.findOne(id, tenantId);
if (!lane) return null;
// Check for codigo uniqueness if changing codigo
if (dto.codigo && dto.codigo !== lane.codigo) {
const existingCodigo = await this.findByCodigo(dto.codigo, tenantId);
if (existingCodigo && existingCodigo.id !== id) {
throw new Error(`Ya existe una lane con el codigo "${dto.codigo}"`);
}
}
Object.assign(lane, dto);
return this.laneRepository.save(lane);
}
async activar(id: string, tenantId: string): Promise<Lane | null> {
const lane = await this.findOne(id, tenantId);
if (!lane) return null;
lane.activo = true;
return this.laneRepository.save(lane);
}
async desactivar(id: string, tenantId: string): Promise<Lane | null> {
const lane = await this.findOne(id, tenantId);
if (!lane) return null;
lane.activo = false;
return this.laneRepository.save(lane);
}
// Get lanes by origin state
async getLanesByOrigenEstado(origenEstado: string, tenantId: string): Promise<Lane[]> {
return this.laneRepository.find({
where: { origenEstado: ILike(`%${origenEstado}%`), tenantId, activo: true },
order: { origenCiudad: 'ASC', destinoCiudad: 'ASC' },
});
}
// Get lanes by destination state
async getLanesByDestinoEstado(destinoEstado: string, tenantId: string): Promise<Lane[]> {
return this.laneRepository.find({
where: { destinoEstado: ILike(`%${destinoEstado}%`), tenantId, activo: true },
order: { origenCiudad: 'ASC', destinoCiudad: 'ASC' },
});
}
// Get all unique origin cities
async getOrigenCiudades(tenantId: string): Promise<string[]> {
const lanes = await this.laneRepository
.createQueryBuilder('l')
.select('DISTINCT l.origen_ciudad', 'ciudad')
.where('l.tenant_id = :tenantId', { tenantId })
.andWhere('l.activo = true')
.orderBy('l.origen_ciudad', 'ASC')
.getRawMany();
return lanes.map(l => l.ciudad);
}
// Get all unique destination cities
async getDestinoCiudades(tenantId: string): Promise<string[]> {
const lanes = await this.laneRepository
.createQueryBuilder('l')
.select('DISTINCT l.destino_ciudad', 'ciudad')
.where('l.tenant_id = :tenantId', { tenantId })
.andWhere('l.activo = true')
.orderBy('l.destino_ciudad', 'ASC')
.getRawMany();
return lanes.map(l => l.ciudad);
}
// Get all unique origin states
async getOrigenEstados(tenantId: string): Promise<string[]> {
const lanes = await this.laneRepository
.createQueryBuilder('l')
.select('DISTINCT l.origen_estado', 'estado')
.where('l.tenant_id = :tenantId', { tenantId })
.andWhere('l.activo = true')
.orderBy('l.origen_estado', 'ASC')
.getRawMany();
return lanes.map(l => l.estado);
}
// Get all unique destination states
async getDestinoEstados(tenantId: string): Promise<string[]> {
const lanes = await this.laneRepository
.createQueryBuilder('l')
.select('DISTINCT l.destino_estado', 'estado')
.where('l.tenant_id = :tenantId', { tenantId })
.andWhere('l.activo = true')
.orderBy('l.destino_estado', 'ASC')
.getRawMany();
return lanes.map(l => l.estado);
}
// Search for lane or create if not exists
async findOrCreate(
tenantId: string,
dto: CreateLaneDto,
createdBy: string
): Promise<{ lane: Lane; created: boolean }> {
// Try to find by origen-destino first
let lane = await this.findByOrigenDestino(dto.origenCiudad, dto.destinoCiudad, tenantId);
if (lane) {
return { lane, created: false };
}
// Try to find by codigo
lane = await this.findByCodigo(dto.codigo, tenantId);
if (lane) {
return { lane, created: false };
}
// Create new lane
const newLane = await this.create(tenantId, dto, createdBy);
return { lane: newLane, created: true };
}
// Calculate reverse lane (destino -> origen)
async getLaneInversa(id: string, tenantId: string): Promise<Lane | null> {
const lane = await this.findOne(id, tenantId);
if (!lane) return null;
return this.findByOrigenDestino(lane.destinoCiudad, lane.origenCiudad, tenantId);
}
// Create reverse lane automatically
async crearLaneInversa(id: string, tenantId: string, createdBy: string): Promise<Lane | null> {
const lane = await this.findOne(id, tenantId);
if (!lane) return null;
// Check if reverse already exists
const existing = await this.findByOrigenDestino(
lane.destinoCiudad,
lane.origenCiudad,
tenantId
);
if (existing) {
return existing;
}
// Generate reverse codigo
const reverseCodigo = `${lane.codigo}-REV`;
const reverseNombre = `${lane.destinoCiudad} - ${lane.origenCiudad}`;
return this.create(
tenantId,
{
codigo: reverseCodigo,
nombre: reverseNombre,
origenCiudad: lane.destinoCiudad,
origenEstado: lane.destinoEstado,
origenCodigoPostal: lane.destinoCodigoPostal ?? undefined,
destinoCiudad: lane.origenCiudad,
destinoEstado: lane.origenEstado,
destinoCodigoPostal: lane.origenCodigoPostal ?? undefined,
distanciaKm: lane.distanciaKm ?? undefined,
tiempoEstimadoHoras: lane.tiempoEstimadoHoras ?? undefined,
},
createdBy
);
}
async delete(id: string, tenantId: string): Promise<boolean> {
const lane = await this.findOne(id, tenantId);
if (!lane) return false;
// Note: Should check if lane is used in any tarifas before deleting
const result = await this.laneRepository.delete(id);
return (result.affected ?? 0) > 0;
}
}

View File

@ -0,0 +1,442 @@
import { Repository, FindOptionsWhere, ILike, In, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm';
import { Tarifa, TipoTarifa, Lane } from '../entities';
export interface TarifaSearchParams {
tenantId: string;
search?: string;
clienteId?: string;
laneId?: string;
tipoTarifa?: TipoTarifa;
activa?: boolean;
vigente?: boolean;
moneda?: string;
limit?: number;
offset?: number;
}
export interface CreateTarifaDto {
codigo: string;
nombre: string;
descripcion?: string;
clienteId?: string;
laneId?: string;
modalidadServicio?: string;
tipoEquipo?: string;
tipoTarifa: TipoTarifa;
tarifaBase: number;
tarifaKm?: number;
tarifaTonelada?: number;
tarifaM3?: number;
tarifaPallet?: number;
tarifaHora?: number;
minimoFacturar?: number;
pesoMinimoKg?: number;
moneda?: string;
fechaInicio: Date;
fechaFin?: Date;
}
export interface UpdateTarifaDto extends Partial<CreateTarifaDto> {
activa?: boolean;
}
export interface CotizarParams {
km?: number;
toneladas?: number;
m3?: number;
pallets?: number;
horas?: number;
}
export interface CotizacionResult {
tarifa: Tarifa;
montoCalculado: number;
desglose: {
tarifaBase: number;
montoVariable: number;
minimoAplicado: boolean;
};
}
export class TarifasService {
constructor(
private readonly tarifaRepository: Repository<Tarifa>,
private readonly laneRepository: Repository<Lane>,
) {}
async findAll(params: TarifaSearchParams): Promise<{ data: Tarifa[]; total: number }> {
const {
tenantId,
search,
clienteId,
laneId,
tipoTarifa,
activa,
vigente,
moneda,
limit = 50,
offset = 0,
} = params;
const qb = this.tarifaRepository.createQueryBuilder('t');
qb.where('t.tenant_id = :tenantId', { tenantId });
if (clienteId) {
qb.andWhere('t.cliente_id = :clienteId', { clienteId });
}
if (laneId) {
qb.andWhere('t.lane_id = :laneId', { laneId });
}
if (tipoTarifa) {
qb.andWhere('t.tipo_tarifa = :tipoTarifa', { tipoTarifa });
}
if (activa !== undefined) {
qb.andWhere('t.activa = :activa', { activa });
}
if (vigente) {
const hoy = new Date();
qb.andWhere('t.activa = true');
qb.andWhere('t.fecha_inicio <= :hoy', { hoy });
qb.andWhere('(t.fecha_fin IS NULL OR t.fecha_fin >= :hoy)', { hoy });
}
if (moneda) {
qb.andWhere('t.moneda = :moneda', { moneda });
}
if (search) {
qb.andWhere(
'(t.codigo ILIKE :search OR t.nombre ILIKE :search OR t.descripcion ILIKE :search)',
{ search: `%${search}%` }
);
}
qb.leftJoinAndSelect('t.lane', 'lane');
qb.orderBy('t.activa', 'DESC');
qb.addOrderBy('t.fecha_inicio', 'DESC');
qb.take(limit);
qb.skip(offset);
const [data, total] = await qb.getManyAndCount();
return { data, total };
}
async findOne(id: string, tenantId: string): Promise<Tarifa | null> {
return this.tarifaRepository.findOne({
where: { id, tenantId },
relations: ['lane'],
});
}
async findByCodigo(codigo: string, tenantId: string): Promise<Tarifa | null> {
return this.tarifaRepository.findOne({
where: { codigo, tenantId },
relations: ['lane'],
});
}
async create(tenantId: string, dto: CreateTarifaDto, createdBy: string): Promise<Tarifa> {
// Check if codigo already exists
const existingCodigo = await this.findByCodigo(dto.codigo, tenantId);
if (existingCodigo) {
throw new Error(`Ya existe una tarifa con el codigo "${dto.codigo}"`);
}
// Validate lane if provided
if (dto.laneId) {
const lane = await this.laneRepository.findOne({
where: { id: dto.laneId, tenantId },
});
if (!lane) {
throw new Error('Lane no encontrada');
}
}
const tarifa = this.tarifaRepository.create({
...dto,
tenantId,
activa: true,
createdById: createdBy,
});
return this.tarifaRepository.save(tarifa);
}
async update(
id: string,
tenantId: string,
dto: UpdateTarifaDto,
updatedBy: string
): Promise<Tarifa | null> {
const tarifa = await this.findOne(id, tenantId);
if (!tarifa) return null;
// Check for codigo uniqueness if changing codigo
if (dto.codigo && dto.codigo !== tarifa.codigo) {
const existingCodigo = await this.findByCodigo(dto.codigo, tenantId);
if (existingCodigo && existingCodigo.id !== id) {
throw new Error(`Ya existe una tarifa con el codigo "${dto.codigo}"`);
}
}
// Validate lane if provided
if (dto.laneId && dto.laneId !== tarifa.laneId) {
const lane = await this.laneRepository.findOne({
where: { id: dto.laneId, tenantId },
});
if (!lane) {
throw new Error('Lane no encontrada');
}
}
Object.assign(tarifa, dto);
return this.tarifaRepository.save(tarifa);
}
async activar(id: string, tenantId: string): Promise<Tarifa | null> {
const tarifa = await this.findOne(id, tenantId);
if (!tarifa) return null;
tarifa.activa = true;
return this.tarifaRepository.save(tarifa);
}
async desactivar(id: string, tenantId: string): Promise<Tarifa | null> {
const tarifa = await this.findOne(id, tenantId);
if (!tarifa) return null;
tarifa.activa = false;
return this.tarifaRepository.save(tarifa);
}
// Get best tariff for a client/lane combination
async getTarifaAplicable(
tenantId: string,
params: {
clienteId?: string;
laneId?: string;
tipoEquipo?: string;
modalidadServicio?: string;
}
): Promise<Tarifa | null> {
const hoy = new Date();
const qb = this.tarifaRepository.createQueryBuilder('t');
qb.where('t.tenant_id = :tenantId', { tenantId });
qb.andWhere('t.activa = true');
qb.andWhere('t.fecha_inicio <= :hoy', { hoy });
qb.andWhere('(t.fecha_fin IS NULL OR t.fecha_fin >= :hoy)', { hoy });
// Priority: specific client tariff > lane tariff > general tariff
if (params.clienteId && params.laneId) {
// First try client + lane specific
const specific = await qb
.clone()
.andWhere('t.cliente_id = :clienteId', { clienteId: params.clienteId })
.andWhere('t.lane_id = :laneId', { laneId: params.laneId })
.getOne();
if (specific) return specific;
}
if (params.clienteId) {
// Try client specific (any lane)
const clientTarifa = await qb
.clone()
.andWhere('t.cliente_id = :clienteId', { clienteId: params.clienteId })
.andWhere('t.lane_id IS NULL')
.getOne();
if (clientTarifa) return clientTarifa;
}
if (params.laneId) {
// Try lane specific (any client)
const laneTarifa = await qb
.clone()
.andWhere('t.cliente_id IS NULL')
.andWhere('t.lane_id = :laneId', { laneId: params.laneId })
.getOne();
if (laneTarifa) return laneTarifa;
}
// Try general tariff with matching equipment/modalidad
if (params.tipoEquipo || params.modalidadServicio) {
const generalQb = qb.clone()
.andWhere('t.cliente_id IS NULL')
.andWhere('t.lane_id IS NULL');
if (params.tipoEquipo) {
generalQb.andWhere('t.tipo_equipo = :tipoEquipo', { tipoEquipo: params.tipoEquipo });
}
if (params.modalidadServicio) {
generalQb.andWhere('t.modalidad_servicio = :modalidadServicio', {
modalidadServicio: params.modalidadServicio,
});
}
const generalTarifa = await generalQb.getOne();
if (generalTarifa) return generalTarifa;
}
// Fallback to any general tariff
return qb
.clone()
.andWhere('t.cliente_id IS NULL')
.andWhere('t.lane_id IS NULL')
.orderBy('t.created_at', 'DESC')
.getOne();
}
// Calculate quote using a tariff
async cotizar(tarifaId: string, tenantId: string, params: CotizarParams): Promise<CotizacionResult> {
const tarifa = await this.findOne(tarifaId, tenantId);
if (!tarifa) {
throw new Error('Tarifa no encontrada');
}
if (!tarifa.vigente) {
throw new Error('La tarifa no esta vigente');
}
let montoVariable = 0;
switch (tarifa.tipoTarifa) {
case TipoTarifa.POR_KM:
montoVariable = (params.km || 0) * (tarifa.tarifaKm || 0);
break;
case TipoTarifa.POR_TONELADA:
montoVariable = (params.toneladas || 0) * (tarifa.tarifaTonelada || 0);
break;
case TipoTarifa.POR_M3:
montoVariable = (params.m3 || 0) * (tarifa.tarifaM3 || 0);
break;
case TipoTarifa.POR_PALLET:
montoVariable = (params.pallets || 0) * (tarifa.tarifaPallet || 0);
break;
case TipoTarifa.POR_HORA:
montoVariable = (params.horas || 0) * (tarifa.tarifaHora || 0);
break;
case TipoTarifa.MIXTA:
montoVariable += (params.km || 0) * (tarifa.tarifaKm || 0);
montoVariable += (params.toneladas || 0) * (tarifa.tarifaTonelada || 0);
break;
case TipoTarifa.POR_VIAJE:
default:
montoVariable = 0;
break;
}
let montoCalculado = Number(tarifa.tarifaBase) + montoVariable;
let minimoAplicado = false;
if (tarifa.minimoFacturar && montoCalculado < Number(tarifa.minimoFacturar)) {
montoCalculado = Number(tarifa.minimoFacturar);
minimoAplicado = true;
}
return {
tarifa,
montoCalculado,
desglose: {
tarifaBase: Number(tarifa.tarifaBase),
montoVariable,
minimoAplicado,
},
};
}
// Get tarifas by cliente
async getTarifasCliente(clienteId: string, tenantId: string): Promise<Tarifa[]> {
return this.tarifaRepository.find({
where: { clienteId, tenantId, activa: true },
relations: ['lane'],
order: { fechaInicio: 'DESC' },
});
}
// Get tarifas by lane
async getTarifasLane(laneId: string, tenantId: string): Promise<Tarifa[]> {
return this.tarifaRepository.find({
where: { laneId, tenantId, activa: true },
relations: ['lane'],
order: { fechaInicio: 'DESC' },
});
}
// Get tarifas expiring soon
async getTarifasPorVencer(tenantId: string, diasAntelacion: number = 30): Promise<Tarifa[]> {
const hoy = new Date();
const fechaLimite = new Date();
fechaLimite.setDate(fechaLimite.getDate() + diasAntelacion);
return this.tarifaRepository
.createQueryBuilder('t')
.where('t.tenant_id = :tenantId', { tenantId })
.andWhere('t.activa = true')
.andWhere('t.fecha_fin IS NOT NULL')
.andWhere('t.fecha_fin > :hoy', { hoy })
.andWhere('t.fecha_fin <= :fechaLimite', { fechaLimite })
.leftJoinAndSelect('t.lane', 'lane')
.orderBy('t.fecha_fin', 'ASC')
.getMany();
}
// Clone a tariff with new validity period
async clonarTarifa(
id: string,
tenantId: string,
nuevoCodigo: string,
nuevaFechaInicio: Date,
nuevaFechaFin?: Date,
createdBy?: string
): Promise<Tarifa | null> {
const sourceTarifa = await this.findOne(id, tenantId);
if (!sourceTarifa) return null;
// Check if new codigo exists
const existingCodigo = await this.findByCodigo(nuevoCodigo, tenantId);
if (existingCodigo) {
throw new Error(`Ya existe una tarifa con el codigo "${nuevoCodigo}"`);
}
const newTarifa = this.tarifaRepository.create({
tenantId,
codigo: nuevoCodigo,
nombre: sourceTarifa.nombre,
descripcion: sourceTarifa.descripcion,
clienteId: sourceTarifa.clienteId,
laneId: sourceTarifa.laneId,
modalidadServicio: sourceTarifa.modalidadServicio,
tipoEquipo: sourceTarifa.tipoEquipo,
tipoTarifa: sourceTarifa.tipoTarifa,
tarifaBase: sourceTarifa.tarifaBase,
tarifaKm: sourceTarifa.tarifaKm,
tarifaTonelada: sourceTarifa.tarifaTonelada,
tarifaM3: sourceTarifa.tarifaM3,
tarifaPallet: sourceTarifa.tarifaPallet,
tarifaHora: sourceTarifa.tarifaHora,
minimoFacturar: sourceTarifa.minimoFacturar,
pesoMinimoKg: sourceTarifa.pesoMinimoKg,
moneda: sourceTarifa.moneda,
fechaInicio: nuevaFechaInicio,
fechaFin: nuevaFechaFin ?? null,
activa: true,
createdById: createdBy ?? sourceTarifa.createdById,
});
return this.tarifaRepository.save(newTarifa);
}
async delete(id: string, tenantId: string): Promise<boolean> {
const tarifa = await this.findOne(id, tenantId);
if (!tarifa) return false;
const result = await this.tarifaRepository.delete(id);
return (result.affected ?? 0) > 0;
}
}