erp-core-backend-v2/src/modules/partners/services/partners.service.ts
Adrian Flores Cortes 7a957a69c7 refactor: Consolidate duplicate services and normalize module structure
- Partners: Moved services to services/ directory, consolidated duplicates,
  kept singleton pattern version with ranking service
- Products: Moved service to services/, removed duplicate class-based version,
  kept singleton with deletedAt filtering
- Reports: Moved service to services/, kept raw SQL version for active controller
- Warehouses: Moved service to services/, removed duplicate class-based version,
  kept singleton with proper tenant isolation

All modules now follow consistent structure:
- services/*.service.ts for business logic
- services/index.ts for exports
- Controllers import from ./services/index.js

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 04:40:16 -06:00

351 lines
10 KiB
TypeScript

import { Repository, IsNull, Like } from 'typeorm';
import { AppDataSource } from '../../../config/typeorm.js';
import { Partner, PartnerType } from '../entities/index.js';
import { NotFoundError, ValidationError, ForbiddenError } from '../../../shared/types/index.js';
import { logger } from '../../../shared/utils/logger.js';
// Re-export PartnerType for controller use
export type { PartnerType };
// ===== Interfaces =====
export interface CreatePartnerDto {
code: string;
displayName: string;
legalName?: string;
partnerType?: PartnerType;
email?: string;
phone?: string;
mobile?: string;
website?: string;
taxId?: string;
taxRegime?: string;
cfdiUse?: string;
paymentTermDays?: number;
creditLimit?: number;
priceListId?: string;
discountPercent?: number;
category?: string;
tags?: string[];
notes?: string;
salesRepId?: string;
}
export interface UpdatePartnerDto {
displayName?: string;
legalName?: string | null;
partnerType?: PartnerType;
email?: string | null;
phone?: string | null;
mobile?: string | null;
website?: string | null;
taxId?: string | null;
taxRegime?: string | null;
cfdiUse?: string | null;
paymentTermDays?: number;
creditLimit?: number;
priceListId?: string | null;
discountPercent?: number;
category?: string | null;
tags?: string[];
notes?: string | null;
isActive?: boolean;
isVerified?: boolean;
salesRepId?: string | null;
}
export interface PartnerFilters {
search?: string;
partnerType?: PartnerType;
category?: string;
isActive?: boolean;
isVerified?: boolean;
page?: number;
limit?: number;
}
export interface PartnerWithRelations extends Partner {
// Add computed fields if needed
}
// ===== PartnersService Class =====
class PartnersService {
private partnerRepository: Repository<Partner>;
constructor() {
this.partnerRepository = AppDataSource.getRepository(Partner);
}
/**
* Get all partners for a tenant with filters and pagination
*/
async findAll(
tenantId: string,
filters: PartnerFilters = {}
): Promise<{ data: Partner[]; total: number }> {
try {
const { search, partnerType, category, isActive, isVerified, page = 1, limit = 20 } = filters;
const skip = (page - 1) * limit;
const queryBuilder = this.partnerRepository
.createQueryBuilder('partner')
.where('partner.tenantId = :tenantId', { tenantId })
.andWhere('partner.deletedAt IS NULL');
// Apply search filter
if (search) {
queryBuilder.andWhere(
'(partner.displayName ILIKE :search OR partner.legalName ILIKE :search OR partner.email ILIKE :search OR partner.taxId ILIKE :search OR partner.code ILIKE :search)',
{ search: `%${search}%` }
);
}
// Filter by partner type
if (partnerType !== undefined) {
queryBuilder.andWhere('partner.partnerType = :partnerType', { partnerType });
}
// Filter by category
if (category) {
queryBuilder.andWhere('partner.category = :category', { category });
}
// Filter by active status
if (isActive !== undefined) {
queryBuilder.andWhere('partner.isActive = :isActive', { isActive });
}
// Filter by verified status
if (isVerified !== undefined) {
queryBuilder.andWhere('partner.isVerified = :isVerified', { isVerified });
}
// Get total count
const total = await queryBuilder.getCount();
// Get paginated results
const data = await queryBuilder
.orderBy('partner.displayName', 'ASC')
.skip(skip)
.take(limit)
.getMany();
logger.debug('Partners retrieved', { tenantId, count: data.length, total });
return { data, total };
} catch (error) {
logger.error('Error retrieving partners', {
error: (error as Error).message,
tenantId,
});
throw error;
}
}
/**
* Get partner by ID
*/
async findById(id: string, tenantId: string): Promise<Partner> {
try {
const partner = await this.partnerRepository.findOne({
where: {
id,
tenantId,
deletedAt: IsNull(),
},
});
if (!partner) {
throw new NotFoundError('Contacto no encontrado');
}
return partner;
} catch (error) {
logger.error('Error finding partner', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Create a new partner
*/
async create(
dto: CreatePartnerDto,
tenantId: string,
userId: string
): Promise<Partner> {
try {
// Check if code already exists
const existing = await this.partnerRepository.findOne({
where: { code: dto.code, tenantId },
});
if (existing) {
throw new ValidationError('Ya existe un contacto con este código');
}
// Create partner - only include defined fields
const partnerData: Partial<Partner> = {
tenantId,
code: dto.code,
displayName: dto.displayName,
partnerType: dto.partnerType || 'customer',
paymentTermDays: dto.paymentTermDays ?? 0,
creditLimit: dto.creditLimit ?? 0,
discountPercent: dto.discountPercent ?? 0,
tags: dto.tags || [],
isActive: true,
isVerified: false,
createdBy: userId,
};
// Add optional fields only if defined
if (dto.legalName) partnerData.legalName = dto.legalName;
if (dto.email) partnerData.email = dto.email.toLowerCase();
if (dto.phone) partnerData.phone = dto.phone;
if (dto.mobile) partnerData.mobile = dto.mobile;
if (dto.website) partnerData.website = dto.website;
if (dto.taxId) partnerData.taxId = dto.taxId;
if (dto.taxRegime) partnerData.taxRegime = dto.taxRegime;
if (dto.cfdiUse) partnerData.cfdiUse = dto.cfdiUse;
if (dto.priceListId) partnerData.priceListId = dto.priceListId;
if (dto.category) partnerData.category = dto.category;
if (dto.notes) partnerData.notes = dto.notes;
if (dto.salesRepId) partnerData.salesRepId = dto.salesRepId;
const partner = this.partnerRepository.create(partnerData);
await this.partnerRepository.save(partner);
logger.info('Partner created', {
partnerId: partner.id,
tenantId,
code: partner.code,
displayName: partner.displayName,
createdBy: userId,
});
return partner;
} catch (error) {
logger.error('Error creating partner', {
error: (error as Error).message,
tenantId,
dto,
});
throw error;
}
}
/**
* Update a partner
*/
async update(
id: string,
dto: UpdatePartnerDto,
tenantId: string,
userId: string
): Promise<Partner> {
try {
const existing = await this.findById(id, tenantId);
// Update allowed fields
if (dto.displayName !== undefined) existing.displayName = dto.displayName;
if (dto.legalName !== undefined) existing.legalName = dto.legalName as string;
if (dto.partnerType !== undefined) existing.partnerType = dto.partnerType;
if (dto.email !== undefined) existing.email = dto.email?.toLowerCase() || null as any;
if (dto.phone !== undefined) existing.phone = dto.phone as string;
if (dto.mobile !== undefined) existing.mobile = dto.mobile as string;
if (dto.website !== undefined) existing.website = dto.website as string;
if (dto.taxId !== undefined) existing.taxId = dto.taxId as string;
if (dto.taxRegime !== undefined) existing.taxRegime = dto.taxRegime as string;
if (dto.cfdiUse !== undefined) existing.cfdiUse = dto.cfdiUse as string;
if (dto.paymentTermDays !== undefined) existing.paymentTermDays = dto.paymentTermDays;
if (dto.creditLimit !== undefined) existing.creditLimit = dto.creditLimit;
if (dto.priceListId !== undefined) existing.priceListId = dto.priceListId as string;
if (dto.discountPercent !== undefined) existing.discountPercent = dto.discountPercent;
if (dto.category !== undefined) existing.category = dto.category as string;
if (dto.tags !== undefined) existing.tags = dto.tags;
if (dto.notes !== undefined) existing.notes = dto.notes as string;
if (dto.isActive !== undefined) existing.isActive = dto.isActive;
if (dto.isVerified !== undefined) existing.isVerified = dto.isVerified;
if (dto.salesRepId !== undefined) existing.salesRepId = dto.salesRepId as string;
existing.updatedBy = userId;
await this.partnerRepository.save(existing);
logger.info('Partner updated', {
partnerId: id,
tenantId,
updatedBy: userId,
});
return await this.findById(id, tenantId);
} catch (error) {
logger.error('Error updating partner', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Soft delete a partner
*/
async delete(id: string, tenantId: string, userId: string): Promise<void> {
try {
const partner = await this.findById(id, tenantId);
// Soft delete using the deletedAt column
partner.deletedAt = new Date();
partner.isActive = false;
await this.partnerRepository.save(partner);
logger.info('Partner deleted', {
partnerId: id,
tenantId,
deletedBy: userId,
});
} catch (error) {
logger.error('Error deleting partner', {
error: (error as Error).message,
id,
tenantId,
});
throw error;
}
}
/**
* Get customers only
*/
async findCustomers(
tenantId: string,
filters: Omit<PartnerFilters, 'partnerType'>
): Promise<{ data: Partner[]; total: number }> {
return this.findAll(tenantId, { ...filters, partnerType: 'customer' });
}
/**
* Get suppliers only
*/
async findSuppliers(
tenantId: string,
filters: Omit<PartnerFilters, 'partnerType'>
): Promise<{ data: Partner[]; total: number }> {
return this.findAll(tenantId, { ...filters, partnerType: 'supplier' });
}
}
// ===== Export Singleton Instance =====
export const partnersService = new PartnersService();