402 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|