erp-mecanicas-diesel-backen.../src/modules/service-management/services/quote.service.ts
rckrdmrd 8ed7d24e96 Migración desde erp-mecanicas-diesel/backend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:11:24 -06:00

402 lines
11 KiB
TypeScript

/**
* Quote Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for quotations management.
*/
import { Repository, DataSource } from 'typeorm';
import { Quote, QuoteStatus } from '../entities/quote.entity';
import { ServiceOrder, ServiceOrderStatus } from '../entities/service-order.entity';
// DTOs
export interface CreateQuoteDto {
customerId: string;
vehicleId: string;
diagnosticId?: string;
validityDays?: number;
terms?: string;
notes?: string;
}
export interface QuoteItemDto {
itemType: 'service' | 'part';
description: string;
quantity: number;
unitPrice: number;
discountPct?: number;
serviceId?: string;
partId?: string;
}
export interface ApplyDiscountDto {
discountPercent?: number;
discountAmount?: number;
discountReason?: string;
}
export class QuoteService {
private quoteRepository: Repository<Quote>;
private orderRepository: Repository<ServiceOrder>;
constructor(private dataSource: DataSource) {
this.quoteRepository = dataSource.getRepository(Quote);
this.orderRepository = dataSource.getRepository(ServiceOrder);
}
/**
* Generate next quote number for tenant
*/
private async generateQuoteNumber(tenantId: string): Promise<string> {
const year = new Date().getFullYear();
const prefix = `COT-${year}-`;
const lastQuote = await this.quoteRepository.findOne({
where: { tenantId },
order: { createdAt: 'DESC' },
});
let sequence = 1;
if (lastQuote?.quoteNumber?.startsWith(prefix)) {
const lastSeq = parseInt(lastQuote.quoteNumber.replace(prefix, ''), 10);
sequence = isNaN(lastSeq) ? 1 : lastSeq + 1;
}
return `${prefix}${sequence.toString().padStart(5, '0')}`;
}
/**
* Create a new quote
*/
async create(tenantId: string, dto: CreateQuoteDto, userId?: string): Promise<Quote> {
const quoteNumber = await this.generateQuoteNumber(tenantId);
const validityDays = dto.validityDays || 15;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + validityDays);
const quote = this.quoteRepository.create({
tenantId,
quoteNumber,
customerId: dto.customerId,
vehicleId: dto.vehicleId,
diagnosticId: dto.diagnosticId,
status: QuoteStatus.DRAFT,
validityDays,
expiresAt,
terms: dto.terms,
notes: dto.notes,
createdBy: userId,
});
return this.quoteRepository.save(quote);
}
/**
* Find quote by ID
*/
async findById(tenantId: string, id: string): Promise<Quote | null> {
return this.quoteRepository.findOne({
where: { id, tenantId },
});
}
/**
* Find quote by number
*/
async findByNumber(tenantId: string, quoteNumber: string): Promise<Quote | null> {
return this.quoteRepository.findOne({
where: { tenantId, quoteNumber },
});
}
/**
* List quotes with filters
*/
async findAll(
tenantId: string,
filters: {
status?: QuoteStatus;
customerId?: string;
vehicleId?: string;
fromDate?: Date;
toDate?: Date;
} = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.quoteRepository.createQueryBuilder('quote')
.where('quote.tenant_id = :tenantId', { tenantId });
if (filters.status) {
queryBuilder.andWhere('quote.status = :status', { status: filters.status });
}
if (filters.customerId) {
queryBuilder.andWhere('quote.customer_id = :customerId', { customerId: filters.customerId });
}
if (filters.vehicleId) {
queryBuilder.andWhere('quote.vehicle_id = :vehicleId', { vehicleId: filters.vehicleId });
}
if (filters.fromDate) {
queryBuilder.andWhere('quote.created_at >= :fromDate', { fromDate: filters.fromDate });
}
if (filters.toDate) {
queryBuilder.andWhere('quote.created_at <= :toDate', { toDate: filters.toDate });
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('quote.created_at', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Send quote to customer
*/
async send(tenantId: string, id: string, channel: 'email' | 'whatsapp'): Promise<Quote | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
if (quote.status !== QuoteStatus.DRAFT) {
throw new Error('Quote has already been sent');
}
quote.status = QuoteStatus.SENT;
quote.sentAt = new Date();
// TODO: Integrate with notification service
// await notificationService.sendQuote(quote, channel);
return this.quoteRepository.save(quote);
}
/**
* Mark quote as viewed
*/
async markViewed(tenantId: string, id: string): Promise<Quote | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
if (!quote.viewedAt) {
quote.viewedAt = new Date();
if (quote.status === QuoteStatus.SENT) {
quote.status = QuoteStatus.VIEWED;
}
return this.quoteRepository.save(quote);
}
return quote;
}
/**
* Approve quote (customer action)
*/
async approve(
tenantId: string,
id: string,
approvalData: {
approvedByName: string;
approvalSignature?: string;
approvalIp?: string;
}
): Promise<Quote | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
if (quote.status === QuoteStatus.EXPIRED) {
throw new Error('Quote has expired');
}
if (quote.status === QuoteStatus.REJECTED) {
throw new Error('Quote was rejected');
}
if (quote.status === QuoteStatus.APPROVED || quote.status === QuoteStatus.CONVERTED) {
throw new Error('Quote has already been approved');
}
quote.status = QuoteStatus.APPROVED;
quote.respondedAt = new Date();
quote.approvedByName = approvalData.approvedByName;
quote.approvalSignature = approvalData.approvalSignature;
quote.approvalIp = approvalData.approvalIp;
return this.quoteRepository.save(quote);
}
/**
* Reject quote
*/
async reject(tenantId: string, id: string, reason?: string): Promise<Quote | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
quote.status = QuoteStatus.REJECTED;
quote.respondedAt = new Date();
if (reason) {
quote.notes = `${quote.notes || ''}\n\nRejection reason: ${reason}`.trim();
}
return this.quoteRepository.save(quote);
}
/**
* Convert quote to service order
*/
async convertToOrder(tenantId: string, id: string, userId?: string): Promise<ServiceOrder | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
if (quote.status !== QuoteStatus.APPROVED) {
throw new Error('Quote must be approved before conversion');
}
// Generate order number
const year = new Date().getFullYear();
const prefix = `OS-${year}-`;
const lastOrder = await this.orderRepository.findOne({
where: { tenantId },
order: { createdAt: 'DESC' },
});
let sequence = 1;
if (lastOrder?.orderNumber?.startsWith(prefix)) {
const lastSeq = parseInt(lastOrder.orderNumber.replace(prefix, ''), 10);
sequence = isNaN(lastSeq) ? 1 : lastSeq + 1;
}
const orderNumber = `${prefix}${sequence.toString().padStart(5, '0')}`;
// Create service order
const order = this.orderRepository.create({
tenantId,
orderNumber,
customerId: quote.customerId,
vehicleId: quote.vehicleId,
quoteId: quote.id,
status: ServiceOrderStatus.APPROVED,
laborTotal: quote.laborTotal,
partsTotal: quote.partsTotal,
discountAmount: quote.discountAmount,
discountPercent: quote.discountPercent,
tax: quote.tax,
grandTotal: quote.grandTotal,
customerNotes: quote.notes,
createdBy: userId,
receivedAt: new Date(),
});
const savedOrder = await this.orderRepository.save(order);
// Update quote
quote.status = QuoteStatus.CONVERTED;
quote.convertedOrderId = savedOrder.id;
await this.quoteRepository.save(quote);
return savedOrder;
}
/**
* Apply discount to quote
*/
async applyDiscount(tenantId: string, id: string, dto: ApplyDiscountDto): Promise<Quote | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
if (dto.discountPercent !== undefined) {
quote.discountPercent = dto.discountPercent;
const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal);
quote.discountAmount = subtotal * (dto.discountPercent / 100);
} else if (dto.discountAmount !== undefined) {
quote.discountAmount = dto.discountAmount;
const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal);
quote.discountPercent = subtotal > 0 ? (dto.discountAmount / subtotal) * 100 : 0;
}
if (dto.discountReason) {
quote.discountReason = dto.discountReason;
}
// Recalculate totals
const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal);
const taxableAmount = subtotal - Number(quote.discountAmount);
quote.tax = taxableAmount * 0.16; // 16% IVA
quote.grandTotal = taxableAmount + quote.tax;
return this.quoteRepository.save(quote);
}
/**
* Check and update expired quotes
*/
async updateExpiredQuotes(tenantId: string): Promise<number> {
const result = await this.quoteRepository
.createQueryBuilder()
.update(Quote)
.set({ status: QuoteStatus.EXPIRED })
.where('tenant_id = :tenantId', { tenantId })
.andWhere('status IN (:...statuses)', {
statuses: [QuoteStatus.DRAFT, QuoteStatus.SENT, QuoteStatus.VIEWED],
})
.andWhere('expires_at < :now', { now: new Date() })
.execute();
return result.affected || 0;
}
/**
* Get quote statistics
*/
async getStats(tenantId: string): Promise<{
total: number;
pending: number;
approved: number;
rejected: number;
converted: number;
conversionRate: number;
averageValue: number;
}> {
const [total, pending, approved, rejected, converted, valueResult] = await Promise.all([
this.quoteRepository.count({ where: { tenantId } }),
this.quoteRepository.count({
where: { tenantId, status: QuoteStatus.SENT },
}),
this.quoteRepository.count({
where: { tenantId, status: QuoteStatus.APPROVED },
}),
this.quoteRepository.count({
where: { tenantId, status: QuoteStatus.REJECTED },
}),
this.quoteRepository.count({
where: { tenantId, status: QuoteStatus.CONVERTED },
}),
this.quoteRepository
.createQueryBuilder('quote')
.select('AVG(quote.grand_total)', 'avg')
.where('quote.tenant_id = :tenantId', { tenantId })
.getRawOne(),
]);
const totalResponded = approved + rejected + converted;
const conversionRate = totalResponded > 0 ? ((approved + converted) / totalResponded) * 100 : 0;
return {
total,
pending,
approved,
rejected,
converted,
conversionRate,
averageValue: parseFloat(valueResult?.avg) || 0,
};
}
}