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:
parent
6a71183121
commit
19fcf169c0
@ -9,6 +9,7 @@
|
|||||||
import { Repository, FindOptionsWhere, LessThan } from 'typeorm';
|
import { Repository, FindOptionsWhere, LessThan } from 'typeorm';
|
||||||
import { Contract, ContractStatus, ContractType } from '../entities/contract.entity';
|
import { Contract, ContractStatus, ContractType } from '../entities/contract.entity';
|
||||||
import { ContractAddendum } from '../entities/contract-addendum.entity';
|
import { ContractAddendum } from '../entities/contract-addendum.entity';
|
||||||
|
import { ContractPartida } from '../entities/contract-partida.entity';
|
||||||
|
|
||||||
interface ServiceContext {
|
interface ServiceContext {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
@ -48,6 +49,17 @@ export interface CreateAddendumDto {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreatePartidaDto {
|
||||||
|
conceptoId: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePartidaDto {
|
||||||
|
quantity?: number;
|
||||||
|
unitPrice?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContractFilters {
|
export interface ContractFilters {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
fraccionamientoId?: string;
|
fraccionamientoId?: string;
|
||||||
@ -60,7 +72,8 @@ export interface ContractFilters {
|
|||||||
export class ContractService {
|
export class ContractService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly contractRepository: Repository<Contract>,
|
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 {
|
private generateContractNumber(type: ContractType): string {
|
||||||
@ -415,4 +428,133 @@ export class ContractService {
|
|||||||
order: { endDate: 'ASC' },
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -225,6 +225,22 @@ export class SubcontractorService {
|
|||||||
return this.subcontractorRepository.save(subcontractor);
|
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> {
|
async blacklist(ctx: ServiceContext, id: string, reason: string): Promise<Subcontractor | null> {
|
||||||
const subcontractor = await this.findById(ctx, id);
|
const subcontractor = await this.findById(ctx, id);
|
||||||
if (!subcontractor) {
|
if (!subcontractor) {
|
||||||
@ -263,4 +279,42 @@ export class SubcontractorService {
|
|||||||
order: { averageRating: 'DESC' },
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user