473 lines
12 KiB
TypeScript
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();
|