erp-core-backend-v2/src/modules/companies/companies.service.ts

473 lines
12 KiB
TypeScript

import { Repository, IsNull } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Company } from '../auth/entities/index.js';
import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js';
import { logger } from '../../shared/utils/logger.js';
// ===== Interfaces =====
export interface CreateCompanyDto {
name: string;
legalName?: string;
taxId?: string;
currencyId?: string;
parentCompanyId?: string;
settings?: Record<string, any>;
}
export interface UpdateCompanyDto {
name?: string;
legalName?: string | null;
taxId?: string | null;
currencyId?: string | null;
parentCompanyId?: string | null;
settings?: Record<string, any>;
}
export interface CompanyFilters {
search?: string;
parentCompanyId?: string;
page?: number;
limit?: number;
}
export interface CompanyWithRelations extends Company {
currencyCode?: string;
parentCompanyName?: string;
}
// ===== CompaniesService Class =====
class CompaniesService {
private companyRepository: Repository<Company>;
constructor() {
this.companyRepository = AppDataSource.getRepository(Company);
}
/**
* Get all companies for a tenant with filters and pagination
*/
async findAll(
tenantId: string,
filters: CompanyFilters = {}
): Promise<{ data: CompanyWithRelations[]; total: number }> {
try {
const { search, parentCompanyId, page = 1, limit = 20 } = filters;
const skip = (page - 1) * limit;
const queryBuilder = this.companyRepository
.createQueryBuilder('company')
.leftJoin('company.parentCompany', 'parentCompany')
.addSelect(['parentCompany.name'])
.where('company.tenantId = :tenantId', { tenantId })
.andWhere('company.deletedAt IS NULL');
// Apply search filter
if (search) {
queryBuilder.andWhere(
'(company.name ILIKE :search OR company.legalName ILIKE :search OR company.taxId ILIKE :search)',
{ search: `%${search}%` }
);
}
// Filter by parent company
if (parentCompanyId) {
queryBuilder.andWhere('company.parentCompanyId = :parentCompanyId', { parentCompanyId });
}
// Get total count
const total = await queryBuilder.getCount();
// Get paginated results
const companies = await queryBuilder
.orderBy('company.name', 'ASC')
.skip(skip)
.take(limit)
.getMany();
// Map to include relation names
const data: CompanyWithRelations[] = companies.map(company => ({
...company,
parentCompanyName: company.parentCompany?.name,
}));
logger.debug('Companies retrieved', { tenantId, count: data.length, total });
return { data, total };
} catch (error) {
logger.error('Error retrieving companies', {
error: (error as Error).message,
tenantId,
});
throw error;
}
}
/**
* Get company by ID
*/
async findById(id: string, tenantId: string): Promise<CompanyWithRelations> {
try {
const company = await this.companyRepository
.createQueryBuilder('company')
.leftJoin('company.parentCompany', 'parentCompany')
.addSelect(['parentCompany.name'])
.where('company.id = :id', { id })
.andWhere('company.tenantId = :tenantId', { tenantId })
.andWhere('company.deletedAt IS NULL')
.getOne();
if (!company) {
throw new NotFoundError('Empresa no encontrada');
}
return {
...company,
parentCompanyName: company.parentCompany?.name,
};
} catch (error) {
logger.error('Error finding company', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Create a new company
*/
async create(
dto: CreateCompanyDto,
tenantId: string,
userId: string
): Promise<Company> {
try {
// Validate unique tax_id within tenant
if (dto.taxId) {
const existing = await this.companyRepository.findOne({
where: {
tenantId,
taxId: dto.taxId,
deletedAt: IsNull(),
},
});
if (existing) {
throw new ValidationError('Ya existe una empresa con este RFC');
}
}
// Validate parent company exists
if (dto.parentCompanyId) {
const parent = await this.companyRepository.findOne({
where: {
id: dto.parentCompanyId,
tenantId,
deletedAt: IsNull(),
},
});
if (!parent) {
throw new NotFoundError('Empresa matriz no encontrada');
}
}
// Create company
const company = this.companyRepository.create({
tenantId,
name: dto.name,
legalName: dto.legalName || null,
taxId: dto.taxId || null,
currencyId: dto.currencyId || null,
parentCompanyId: dto.parentCompanyId || null,
settings: dto.settings || {},
createdBy: userId,
});
await this.companyRepository.save(company);
logger.info('Company created', {
companyId: company.id,
tenantId,
name: company.name,
createdBy: userId,
});
return company;
} catch (error) {
logger.error('Error creating company', {
error: (error as Error).message,
tenantId,
dto,
});
throw error;
}
}
/**
* Update a company
*/
async update(
id: string,
dto: UpdateCompanyDto,
tenantId: string,
userId: string
): Promise<Company> {
try {
const existing = await this.findById(id, tenantId);
// Validate unique tax_id if changing
if (dto.taxId !== undefined && dto.taxId !== existing.taxId) {
if (dto.taxId) {
const duplicate = await this.companyRepository.findOne({
where: {
tenantId,
taxId: dto.taxId,
deletedAt: IsNull(),
},
});
if (duplicate && duplicate.id !== id) {
throw new ValidationError('Ya existe una empresa con este RFC');
}
}
}
// Validate parent company (prevent self-reference and cycles)
if (dto.parentCompanyId !== undefined && dto.parentCompanyId) {
if (dto.parentCompanyId === id) {
throw new ValidationError('Una empresa no puede ser su propia matriz');
}
const parent = await this.companyRepository.findOne({
where: {
id: dto.parentCompanyId,
tenantId,
deletedAt: IsNull(),
},
});
if (!parent) {
throw new NotFoundError('Empresa matriz no encontrada');
}
// Check for circular reference
if (await this.wouldCreateCycle(id, dto.parentCompanyId, tenantId)) {
throw new ValidationError('La asignación crearía una referencia circular');
}
}
// Update allowed fields
if (dto.name !== undefined) existing.name = dto.name;
if (dto.legalName !== undefined) existing.legalName = dto.legalName;
if (dto.taxId !== undefined) existing.taxId = dto.taxId;
if (dto.currencyId !== undefined) existing.currencyId = dto.currencyId;
if (dto.parentCompanyId !== undefined) existing.parentCompanyId = dto.parentCompanyId;
if (dto.settings !== undefined) {
existing.settings = { ...existing.settings, ...dto.settings };
}
existing.updatedBy = userId;
existing.updatedAt = new Date();
await this.companyRepository.save(existing);
logger.info('Company updated', {
companyId: id,
tenantId,
updatedBy: userId,
});
return await this.findById(id, tenantId);
} catch (error) {
logger.error('Error updating company', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Soft delete a company
*/
async delete(id: string, tenantId: string, userId: string): Promise<void> {
try {
await this.findById(id, tenantId);
// Check if company has child companies
const childrenCount = await this.companyRepository.count({
where: {
parentCompanyId: id,
tenantId,
deletedAt: IsNull(),
},
});
if (childrenCount > 0) {
throw new ForbiddenError(
'No se puede eliminar una empresa que tiene empresas subsidiarias'
);
}
// Soft delete
await this.companyRepository.update(
{ id, tenantId },
{
deletedAt: new Date(),
deletedBy: userId,
}
);
logger.info('Company deleted', {
companyId: id,
tenantId,
deletedBy: userId,
});
} catch (error) {
logger.error('Error deleting company', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Get users assigned to a company
*/
async getUsers(companyId: string, tenantId: string): Promise<any[]> {
try {
await this.findById(companyId, tenantId);
// Using raw query for user_companies junction table
const users = await this.companyRepository.query(
`SELECT u.id, u.email, u.full_name, u.status, uc.is_default, uc.assigned_at
FROM auth.users u
INNER JOIN auth.user_companies uc ON u.id = uc.user_id
WHERE uc.company_id = $1 AND u.tenant_id = $2 AND u.deleted_at IS NULL
ORDER BY u.full_name`,
[companyId, tenantId]
);
return users;
} catch (error) {
logger.error('Error getting company users', {
error: (error as Error).message,
companyId,
tenantId,
});
throw error;
}
}
/**
* Get child companies (subsidiaries)
*/
async getSubsidiaries(companyId: string, tenantId: string): Promise<Company[]> {
try {
await this.findById(companyId, tenantId);
return await this.companyRepository.find({
where: {
parentCompanyId: companyId,
tenantId,
deletedAt: IsNull(),
},
order: { name: 'ASC' },
});
} catch (error) {
logger.error('Error getting subsidiaries', {
error: (error as Error).message,
companyId,
tenantId,
});
throw error;
}
}
/**
* Get full company hierarchy (tree structure)
*/
async getHierarchy(tenantId: string): Promise<any[]> {
try {
// Get all companies
const companies = await this.companyRepository.find({
where: { tenantId, deletedAt: IsNull() },
order: { name: 'ASC' },
});
// Build tree structure
const companyMap = new Map<string, any>();
const roots: any[] = [];
// First pass: create map
for (const company of companies) {
companyMap.set(company.id, {
...company,
children: [],
});
}
// Second pass: build tree
for (const company of companies) {
const node = companyMap.get(company.id);
if (company.parentCompanyId && companyMap.has(company.parentCompanyId)) {
companyMap.get(company.parentCompanyId).children.push(node);
} else {
roots.push(node);
}
}
return roots;
} catch (error) {
logger.error('Error getting company hierarchy', {
error: (error as Error).message,
tenantId,
});
throw error;
}
}
/**
* Check if assigning a parent would create a circular reference
*/
private async wouldCreateCycle(
companyId: string,
newParentId: string,
tenantId: string
): Promise<boolean> {
let currentId: string | null = newParentId;
const visited = new Set<string>();
while (currentId) {
if (visited.has(currentId)) {
return true; // Found a cycle
}
if (currentId === companyId) {
return true; // Would create a cycle
}
visited.add(currentId);
const parent = await this.companyRepository.findOne({
where: { id: currentId, tenantId, deletedAt: IsNull() },
select: ['parentCompanyId'],
});
currentId = parent?.parentCompanyId || null;
}
return false;
}
}
// ===== Export Singleton Instance =====
export const companiesService = new CompaniesService();