- 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>
351 lines
10 KiB
TypeScript
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();
|