271 lines
7.8 KiB
TypeScript
271 lines
7.8 KiB
TypeScript
/**
|
|
* SubcontractorService - Servicio de gestión de subcontratistas
|
|
*
|
|
* Catálogo de subcontratistas con evaluaciones.
|
|
*
|
|
* @module Contracts
|
|
*/
|
|
|
|
import { Repository, FindOptionsWhere } from 'typeorm';
|
|
import { Subcontractor, SubcontractorStatus, SubcontractorSpecialty } from '../entities/subcontractor.entity';
|
|
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
|
|
|
export interface CreateSubcontractorDto {
|
|
businessName: string;
|
|
tradeName?: string;
|
|
rfc: string;
|
|
address?: string;
|
|
phone?: string;
|
|
email?: string;
|
|
contactName?: string;
|
|
contactPhone?: string;
|
|
primarySpecialty: SubcontractorSpecialty;
|
|
secondarySpecialties?: string[];
|
|
bankName?: string;
|
|
bankAccount?: string;
|
|
clabe?: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface UpdateSubcontractorDto {
|
|
tradeName?: string;
|
|
address?: string;
|
|
phone?: string;
|
|
email?: string;
|
|
contactName?: string;
|
|
contactPhone?: string;
|
|
secondarySpecialties?: string[];
|
|
bankName?: string;
|
|
bankAccount?: string;
|
|
clabe?: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface SubcontractorFilters {
|
|
specialty?: SubcontractorSpecialty;
|
|
status?: SubcontractorStatus;
|
|
search?: string;
|
|
minRating?: number;
|
|
}
|
|
|
|
export class SubcontractorService {
|
|
constructor(private readonly subcontractorRepository: Repository<Subcontractor>) {}
|
|
|
|
private generateCode(): string {
|
|
const now = new Date();
|
|
const year = now.getFullYear().toString().slice(-2);
|
|
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
|
return `SC-${year}-${random}`;
|
|
}
|
|
|
|
async findWithFilters(
|
|
ctx: ServiceContext,
|
|
filters: SubcontractorFilters = {},
|
|
page: number = 1,
|
|
limit: number = 20
|
|
): Promise<PaginatedResult<Subcontractor>> {
|
|
const skip = (page - 1) * limit;
|
|
|
|
const queryBuilder = this.subcontractorRepository
|
|
.createQueryBuilder('sc')
|
|
.leftJoinAndSelect('sc.createdBy', 'createdBy')
|
|
.where('sc.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
|
.andWhere('sc.deleted_at IS NULL');
|
|
|
|
if (filters.specialty) {
|
|
queryBuilder.andWhere('sc.primary_specialty = :specialty', { specialty: filters.specialty });
|
|
}
|
|
|
|
if (filters.status) {
|
|
queryBuilder.andWhere('sc.status = :status', { status: filters.status });
|
|
}
|
|
|
|
if (filters.search) {
|
|
queryBuilder.andWhere(
|
|
'(sc.business_name ILIKE :search OR sc.trade_name ILIKE :search OR sc.rfc ILIKE :search)',
|
|
{ search: `%${filters.search}%` }
|
|
);
|
|
}
|
|
|
|
if (filters.minRating !== undefined) {
|
|
queryBuilder.andWhere('sc.average_rating >= :minRating', { minRating: filters.minRating });
|
|
}
|
|
|
|
queryBuilder
|
|
.orderBy('sc.business_name', 'ASC')
|
|
.skip(skip)
|
|
.take(limit);
|
|
|
|
const [data, total] = await queryBuilder.getManyAndCount();
|
|
|
|
return {
|
|
data,
|
|
meta: {
|
|
total,
|
|
page,
|
|
limit,
|
|
totalPages: Math.ceil(total / limit),
|
|
},
|
|
};
|
|
}
|
|
|
|
async findById(ctx: ServiceContext, id: string): Promise<Subcontractor | null> {
|
|
return this.subcontractorRepository.findOne({
|
|
where: {
|
|
id,
|
|
tenantId: ctx.tenantId,
|
|
deletedAt: null,
|
|
} as unknown as FindOptionsWhere<Subcontractor>,
|
|
});
|
|
}
|
|
|
|
async findByRfc(ctx: ServiceContext, rfc: string): Promise<Subcontractor | null> {
|
|
return this.subcontractorRepository.findOne({
|
|
where: {
|
|
rfc: rfc.toUpperCase(),
|
|
tenantId: ctx.tenantId,
|
|
deletedAt: null,
|
|
} as unknown as FindOptionsWhere<Subcontractor>,
|
|
});
|
|
}
|
|
|
|
async create(ctx: ServiceContext, dto: CreateSubcontractorDto): Promise<Subcontractor> {
|
|
// Check for existing RFC
|
|
const existing = await this.findByRfc(ctx, dto.rfc);
|
|
if (existing) {
|
|
throw new Error('A subcontractor with this RFC already exists');
|
|
}
|
|
|
|
const subcontractor = this.subcontractorRepository.create({
|
|
tenantId: ctx.tenantId,
|
|
createdById: ctx.userId,
|
|
code: this.generateCode(),
|
|
businessName: dto.businessName,
|
|
tradeName: dto.tradeName,
|
|
rfc: dto.rfc.toUpperCase(),
|
|
address: dto.address,
|
|
phone: dto.phone,
|
|
email: dto.email,
|
|
contactName: dto.contactName,
|
|
contactPhone: dto.contactPhone,
|
|
primarySpecialty: dto.primarySpecialty,
|
|
secondarySpecialties: dto.secondarySpecialties,
|
|
bankName: dto.bankName,
|
|
bankAccount: dto.bankAccount,
|
|
clabe: dto.clabe,
|
|
notes: dto.notes,
|
|
status: 'active',
|
|
});
|
|
|
|
return this.subcontractorRepository.save(subcontractor);
|
|
}
|
|
|
|
async update(ctx: ServiceContext, id: string, dto: UpdateSubcontractorDto): Promise<Subcontractor | null> {
|
|
const subcontractor = await this.findById(ctx, id);
|
|
if (!subcontractor) {
|
|
return null;
|
|
}
|
|
|
|
Object.assign(subcontractor, {
|
|
...dto,
|
|
updatedById: ctx.userId || '',
|
|
});
|
|
|
|
return this.subcontractorRepository.save(subcontractor);
|
|
}
|
|
|
|
async updateRating(ctx: ServiceContext, id: string, rating: number): Promise<Subcontractor | null> {
|
|
const subcontractor = await this.findById(ctx, id);
|
|
if (!subcontractor) {
|
|
return null;
|
|
}
|
|
|
|
// Calculate new average rating
|
|
const totalRatings = subcontractor.completedContracts;
|
|
const currentTotal = subcontractor.averageRating * totalRatings;
|
|
const newTotal = currentTotal + rating;
|
|
subcontractor.averageRating = newTotal / (totalRatings + 1);
|
|
subcontractor.updatedById = ctx.userId || '';
|
|
|
|
return this.subcontractorRepository.save(subcontractor);
|
|
}
|
|
|
|
async incrementContracts(ctx: ServiceContext, id: string, completed: boolean = false): Promise<Subcontractor | null> {
|
|
const subcontractor = await this.findById(ctx, id);
|
|
if (!subcontractor) {
|
|
return null;
|
|
}
|
|
|
|
subcontractor.totalContracts += 1;
|
|
if (completed) {
|
|
subcontractor.completedContracts += 1;
|
|
}
|
|
subcontractor.updatedById = ctx.userId || '';
|
|
|
|
return this.subcontractorRepository.save(subcontractor);
|
|
}
|
|
|
|
async incrementIncidents(ctx: ServiceContext, id: string): Promise<Subcontractor | null> {
|
|
const subcontractor = await this.findById(ctx, id);
|
|
if (!subcontractor) {
|
|
return null;
|
|
}
|
|
|
|
subcontractor.totalIncidents += 1;
|
|
subcontractor.updatedById = ctx.userId || '';
|
|
|
|
return this.subcontractorRepository.save(subcontractor);
|
|
}
|
|
|
|
async deactivate(ctx: ServiceContext, id: string): Promise<Subcontractor | null> {
|
|
const subcontractor = await this.findById(ctx, id);
|
|
if (!subcontractor) {
|
|
return null;
|
|
}
|
|
|
|
subcontractor.status = 'inactive';
|
|
subcontractor.updatedById = ctx.userId || '';
|
|
|
|
return this.subcontractorRepository.save(subcontractor);
|
|
}
|
|
|
|
async blacklist(ctx: ServiceContext, id: string, reason: string): Promise<Subcontractor | null> {
|
|
const subcontractor = await this.findById(ctx, id);
|
|
if (!subcontractor) {
|
|
return null;
|
|
}
|
|
|
|
subcontractor.status = 'blacklisted';
|
|
subcontractor.notes = `${subcontractor.notes || ''}\n[BLACKLISTED] ${reason}`;
|
|
subcontractor.updatedById = ctx.userId || '';
|
|
|
|
return this.subcontractorRepository.save(subcontractor);
|
|
}
|
|
|
|
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
|
const subcontractor = await this.findById(ctx, id);
|
|
if (!subcontractor) {
|
|
return false;
|
|
}
|
|
|
|
await this.subcontractorRepository.update(
|
|
{ id, tenantId: ctx.tenantId } as FindOptionsWhere<Subcontractor>,
|
|
{ deletedAt: new Date(), deletedById: ctx.userId || '' }
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
async getBySpecialty(ctx: ServiceContext, specialty: SubcontractorSpecialty): Promise<Subcontractor[]> {
|
|
return this.subcontractorRepository.find({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
primarySpecialty: specialty,
|
|
status: 'active' as SubcontractorStatus,
|
|
deletedAt: null,
|
|
} as unknown as FindOptionsWhere<Subcontractor>,
|
|
order: { averageRating: 'DESC' },
|
|
});
|
|
}
|
|
}
|