feat(contracts): Enhance contract and subcontractor services

Added contract partidas (items) management to ContractService:
- getPartidas: Retrieve all partidas for a contract
- addPartida: Add new partida to draft/review contracts
- updatePartida: Update quantity/price of existing partida
- removePartida: Soft delete partida from contract
- recalculateContractAmount: Recalculate contract total from partidas

Enhanced SubcontractorService with additional methods:
- reactivate: Reactivate inactive subcontractors (blocks blacklisted)
- getTopRated: Get top-rated active subcontractors
- getStatistics: Get aggregate statistics (totals, ratings)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-30 18:12:46 -06:00
parent 6a71183121
commit 19fcf169c0
2 changed files with 197 additions and 1 deletions

View File

@ -9,6 +9,7 @@
import { Repository, FindOptionsWhere, LessThan } from 'typeorm';
import { Contract, ContractStatus, ContractType } from '../entities/contract.entity';
import { ContractAddendum } from '../entities/contract-addendum.entity';
import { ContractPartida } from '../entities/contract-partida.entity';
interface ServiceContext {
tenantId: string;
@ -48,6 +49,17 @@ export interface CreateAddendumDto {
notes?: string;
}
export interface CreatePartidaDto {
conceptoId: string;
quantity: number;
unitPrice: number;
}
export interface UpdatePartidaDto {
quantity?: number;
unitPrice?: number;
}
export interface ContractFilters {
projectId?: string;
fraccionamientoId?: string;
@ -60,7 +72,8 @@ export interface ContractFilters {
export class ContractService {
constructor(
private readonly contractRepository: Repository<Contract>,
private readonly addendumRepository: Repository<ContractAddendum>
private readonly addendumRepository: Repository<ContractAddendum>,
private readonly partidaRepository?: Repository<ContractPartida>
) {}
private generateContractNumber(type: ContractType): string {
@ -415,4 +428,133 @@ export class ContractService {
order: { endDate: 'ASC' },
});
}
// Contract Partidas (Items) Management
async getPartidas(ctx: ServiceContext, contractId: string): Promise<ContractPartida[]> {
if (!this.partidaRepository) {
throw new Error('Partida repository not initialized');
}
return this.partidaRepository.find({
where: {
contractId,
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<ContractPartida>,
order: { createdAt: 'ASC' },
});
}
async addPartida(ctx: ServiceContext, contractId: string, dto: CreatePartidaDto): Promise<ContractPartida> {
if (!this.partidaRepository) {
throw new Error('Partida repository not initialized');
}
const contract = await this.findById(ctx, contractId);
if (!contract) {
throw new Error('Contract not found');
}
if (contract.status !== 'draft' && contract.status !== 'review') {
throw new Error('Can only add partidas to draft or review contracts');
}
const partida = this.partidaRepository.create({
tenantId: ctx.tenantId,
contractId,
conceptoId: dto.conceptoId,
quantity: dto.quantity,
unitPrice: dto.unitPrice,
createdById: ctx.userId,
});
return this.partidaRepository.save(partida);
}
async updatePartida(
ctx: ServiceContext,
partidaId: string,
dto: UpdatePartidaDto
): Promise<ContractPartida | null> {
if (!this.partidaRepository) {
throw new Error('Partida repository not initialized');
}
const partida = await this.partidaRepository.findOne({
where: {
id: partidaId,
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<ContractPartida>,
relations: ['contract'],
});
if (!partida) {
return null;
}
if (partida.contract.status !== 'draft' && partida.contract.status !== 'review') {
throw new Error('Can only update partidas of draft or review contracts');
}
if (dto.quantity !== undefined) {
partida.quantity = dto.quantity;
}
if (dto.unitPrice !== undefined) {
partida.unitPrice = dto.unitPrice;
}
partida.updatedById = ctx.userId || '';
return this.partidaRepository.save(partida);
}
async removePartida(ctx: ServiceContext, partidaId: string): Promise<boolean> {
if (!this.partidaRepository) {
throw new Error('Partida repository not initialized');
}
const partida = await this.partidaRepository.findOne({
where: {
id: partidaId,
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<ContractPartida>,
relations: ['contract'],
});
if (!partida) {
return false;
}
if (partida.contract.status !== 'draft' && partida.contract.status !== 'review') {
throw new Error('Can only remove partidas from draft or review contracts');
}
await this.partidaRepository.update(
{ id: partidaId, tenantId: ctx.tenantId } as FindOptionsWhere<ContractPartida>,
{ deletedAt: new Date(), deletedById: ctx.userId || '' }
);
return true;
}
async recalculateContractAmount(ctx: ServiceContext, contractId: string): Promise<Contract | null> {
if (!this.partidaRepository) {
throw new Error('Partida repository not initialized');
}
const contract = await this.findById(ctx, contractId);
if (!contract) {
return null;
}
const partidas = await this.getPartidas(ctx, contractId);
const totalAmount = partidas.reduce((sum, p) => sum + Number(p.quantity) * Number(p.unitPrice), 0);
contract.contractAmount = totalAmount;
contract.updatedById = ctx.userId || '';
return this.contractRepository.save(contract);
}
}

View File

@ -225,6 +225,22 @@ export class SubcontractorService {
return this.subcontractorRepository.save(subcontractor);
}
async reactivate(ctx: ServiceContext, id: string): Promise<Subcontractor | null> {
const subcontractor = await this.findById(ctx, id);
if (!subcontractor) {
return null;
}
if (subcontractor.status === 'blacklisted') {
throw new Error('Cannot reactivate a blacklisted subcontractor');
}
subcontractor.status = 'active';
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) {
@ -263,4 +279,42 @@ export class SubcontractorService {
order: { averageRating: 'DESC' },
});
}
async getTopRated(ctx: ServiceContext, limit: number = 10): Promise<Subcontractor[]> {
return this.subcontractorRepository.find({
where: {
tenantId: ctx.tenantId,
status: 'active' as SubcontractorStatus,
deletedAt: null,
} as unknown as FindOptionsWhere<Subcontractor>,
order: { averageRating: 'DESC', completedContracts: 'DESC' },
take: limit,
});
}
async getStatistics(ctx: ServiceContext): Promise<{
total: number;
active: number;
inactive: number;
blacklisted: number;
averageRating: number;
}> {
const subcontractors = await this.subcontractorRepository.find({
where: {
tenantId: ctx.tenantId,
deletedAt: null,
} as unknown as FindOptionsWhere<Subcontractor>,
});
const active = subcontractors.filter(s => s.status === 'active');
const totalRating = active.reduce((sum, s) => sum + Number(s.averageRating), 0);
return {
total: subcontractors.length,
active: active.length,
inactive: subcontractors.filter(s => s.status === 'inactive').length,
blacklisted: subcontractors.filter(s => s.status === 'blacklisted').length,
averageRating: active.length > 0 ? totalRating / active.length : 0,
};
}
}