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:
parent
7d6d4b7caa
commit
6e89e7f4c5
@ -6,3 +6,9 @@ export { default as authRoutes } from './auth.routes.js';
|
|||||||
export * from './apiKeys.service.js';
|
export * from './apiKeys.service.js';
|
||||||
export * from './apiKeys.controller.js';
|
export * from './apiKeys.controller.js';
|
||||||
export { default as apiKeysRoutes } from './apiKeys.routes.js';
|
export { default as apiKeysRoutes } from './apiKeys.routes.js';
|
||||||
|
|
||||||
|
// Entities
|
||||||
|
export * from './entities/index.js';
|
||||||
|
|
||||||
|
// Additional Services (roles, permissions)
|
||||||
|
export * from './services/index.js';
|
||||||
|
|||||||
7
src/modules/auth/services/index.ts
Normal file
7
src/modules/auth/services/index.ts
Normal 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';
|
||||||
292
src/modules/auth/services/permissions.service.ts
Normal file
292
src/modules/auth/services/permissions.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
302
src/modules/auth/services/roles.service.ts
Normal file
302
src/modules/auth/services/roles.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
446
src/modules/carta-porte/services/carta-porte.service.ts
Normal file
446
src/modules/carta-porte/services/carta-porte.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Carta Porte Services
|
* Carta Porte Services
|
||||||
|
* CFDI con Complemento Carta Porte 3.1
|
||||||
*/
|
*/
|
||||||
// TODO: Implement services
|
export * from './carta-porte.service';
|
||||||
// - carta-porte.service.ts
|
|
||||||
// - carta-porte-validator.service.ts
|
// TODO: Implement additional services
|
||||||
// - pac-integration.service.ts
|
// - carta-porte-validator.service.ts (validacion SAT detallada)
|
||||||
// - cfdi-generator.service.ts
|
// - pac-integration.service.ts (integracion con PAC)
|
||||||
|
// - cfdi-generator.service.ts (generacion XML CFDI)
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Tarifas Services
|
* Tarifas de Transporte Services
|
||||||
|
* Cotizacion, lanes, tarifas
|
||||||
*/
|
*/
|
||||||
// TODO: Implement services
|
export * from './tarifas.service';
|
||||||
// - tarifas.service.ts
|
export * from './lanes.service';
|
||||||
// - recargos.service.ts
|
|
||||||
// - cotizador.service.ts
|
// TODO: Implement additional services
|
||||||
|
// - recargos.service.ts (RecargoCatalogo)
|
||||||
|
// - fuel-surcharge.service.ts (FuelSurcharge)
|
||||||
|
// - cotizador.service.ts (motor de cotizacion avanzado)
|
||||||
|
|||||||
319
src/modules/tarifas-transporte/services/lanes.service.ts
Normal file
319
src/modules/tarifas-transporte/services/lanes.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
442
src/modules/tarifas-transporte/services/tarifas.service.ts
Normal file
442
src/modules/tarifas-transporte/services/tarifas.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user