/** * 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; private orderRepository: Repository; 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 { 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 { 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 { return this.quoteRepository.findOne({ where: { id, tenantId }, }); } /** * Find quote by number */ async findByNumber(tenantId: string, quoteNumber: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, }; } }