import { query, queryOne, getClient } from '../../config/database.js'; import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; import { taxesService } from '../financial/taxes.service.js'; export interface QuotationLine { id: string; quotation_id: string; product_id?: string; product_name?: string; description: string; quantity: number; uom_id: string; uom_name?: string; price_unit: number; discount: number; tax_ids: string[]; amount_untaxed: number; amount_tax: number; amount_total: number; } export interface Quotation { id: string; tenant_id: string; company_id: string; company_name?: string; name: string; partner_id: string; partner_name?: string; quotation_date: Date; validity_date: Date; currency_id: string; currency_code?: string; pricelist_id?: string; pricelist_name?: string; user_id?: string; user_name?: string; sales_team_id?: string; sales_team_name?: string; amount_untaxed: number; amount_tax: number; amount_total: number; status: 'draft' | 'sent' | 'confirmed' | 'cancelled' | 'expired'; sale_order_id?: string; notes?: string; terms_conditions?: string; lines?: QuotationLine[]; created_at: Date; } export interface CreateQuotationDto { company_id: string; partner_id: string; quotation_date?: string; validity_date: string; currency_id: string; pricelist_id?: string; sales_team_id?: string; notes?: string; terms_conditions?: string; } export interface UpdateQuotationDto { partner_id?: string; quotation_date?: string; validity_date?: string; currency_id?: string; pricelist_id?: string | null; sales_team_id?: string | null; notes?: string | null; terms_conditions?: string | null; } export interface CreateQuotationLineDto { product_id?: string; description: string; quantity: number; uom_id: string; price_unit: number; discount?: number; tax_ids?: string[]; } export interface UpdateQuotationLineDto { description?: string; quantity?: number; uom_id?: string; price_unit?: number; discount?: number; tax_ids?: string[]; } export interface QuotationFilters { company_id?: string; partner_id?: string; status?: string; date_from?: string; date_to?: string; search?: string; page?: number; limit?: number; } class QuotationsService { async findAll(tenantId: string, filters: QuotationFilters = {}): Promise<{ data: Quotation[]; total: number }> { const { company_id, partner_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters; const offset = (page - 1) * limit; let whereClause = 'WHERE q.tenant_id = $1'; const params: any[] = [tenantId]; let paramIndex = 2; if (company_id) { whereClause += ` AND q.company_id = $${paramIndex++}`; params.push(company_id); } if (partner_id) { whereClause += ` AND q.partner_id = $${paramIndex++}`; params.push(partner_id); } if (status) { whereClause += ` AND q.status = $${paramIndex++}`; params.push(status); } if (date_from) { whereClause += ` AND q.quotation_date >= $${paramIndex++}`; params.push(date_from); } if (date_to) { whereClause += ` AND q.quotation_date <= $${paramIndex++}`; params.push(date_to); } if (search) { whereClause += ` AND (q.name ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`; params.push(`%${search}%`); paramIndex++; } const countResult = await queryOne<{ count: string }>( `SELECT COUNT(*) as count FROM sales.quotations q LEFT JOIN core.partners p ON q.partner_id = p.id ${whereClause}`, params ); params.push(limit, offset); const data = await query( `SELECT q.*, c.name as company_name, p.name as partner_name, cu.code as currency_code, pl.name as pricelist_name, u.name as user_name, st.name as sales_team_name FROM sales.quotations q LEFT JOIN auth.companies c ON q.company_id = c.id LEFT JOIN core.partners p ON q.partner_id = p.id LEFT JOIN core.currencies cu ON q.currency_id = cu.id LEFT JOIN sales.pricelists pl ON q.pricelist_id = pl.id LEFT JOIN auth.users u ON q.user_id = u.id LEFT JOIN sales.sales_teams st ON q.sales_team_id = st.id ${whereClause} ORDER BY q.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, params ); return { data, total: parseInt(countResult?.count || '0', 10), }; } async findById(id: string, tenantId: string): Promise { const quotation = await queryOne( `SELECT q.*, c.name as company_name, p.name as partner_name, cu.code as currency_code, pl.name as pricelist_name, u.name as user_name, st.name as sales_team_name FROM sales.quotations q LEFT JOIN auth.companies c ON q.company_id = c.id LEFT JOIN core.partners p ON q.partner_id = p.id LEFT JOIN core.currencies cu ON q.currency_id = cu.id LEFT JOIN sales.pricelists pl ON q.pricelist_id = pl.id LEFT JOIN auth.users u ON q.user_id = u.id LEFT JOIN sales.sales_teams st ON q.sales_team_id = st.id WHERE q.id = $1 AND q.tenant_id = $2`, [id, tenantId] ); if (!quotation) { throw new NotFoundError('Cotización no encontrada'); } // Get lines const lines = await query( `SELECT ql.*, pr.name as product_name, um.name as uom_name FROM sales.quotation_lines ql LEFT JOIN inventory.products pr ON ql.product_id = pr.id LEFT JOIN core.uom um ON ql.uom_id = um.id WHERE ql.quotation_id = $1 ORDER BY ql.created_at`, [id] ); quotation.lines = lines; return quotation; } async create(dto: CreateQuotationDto, tenantId: string, userId: string): Promise { // Generate sequence number const seqResult = await queryOne<{ next_num: number }>( `SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num FROM sales.quotations WHERE tenant_id = $1 AND name LIKE 'QUO-%'`, [tenantId] ); const quotationNumber = `QUO-${String(seqResult?.next_num || 1).padStart(6, '0')}`; const quotationDate = dto.quotation_date || new Date().toISOString().split('T')[0]; const quotation = await queryOne( `INSERT INTO sales.quotations ( tenant_id, company_id, name, partner_id, quotation_date, validity_date, currency_id, pricelist_id, user_id, sales_team_id, notes, terms_conditions, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`, [ tenantId, dto.company_id, quotationNumber, dto.partner_id, quotationDate, dto.validity_date, dto.currency_id, dto.pricelist_id, userId, dto.sales_team_id, dto.notes, dto.terms_conditions, userId ] ); return quotation!; } async update(id: string, dto: UpdateQuotationDto, tenantId: string, userId: string): Promise { const existing = await this.findById(id, tenantId); if (existing.status !== 'draft') { throw new ValidationError('Solo se pueden editar cotizaciones en estado borrador'); } const updateFields: string[] = []; const values: any[] = []; let paramIndex = 1; if (dto.partner_id !== undefined) { updateFields.push(`partner_id = $${paramIndex++}`); values.push(dto.partner_id); } if (dto.quotation_date !== undefined) { updateFields.push(`quotation_date = $${paramIndex++}`); values.push(dto.quotation_date); } if (dto.validity_date !== undefined) { updateFields.push(`validity_date = $${paramIndex++}`); values.push(dto.validity_date); } if (dto.currency_id !== undefined) { updateFields.push(`currency_id = $${paramIndex++}`); values.push(dto.currency_id); } if (dto.pricelist_id !== undefined) { updateFields.push(`pricelist_id = $${paramIndex++}`); values.push(dto.pricelist_id); } if (dto.sales_team_id !== undefined) { updateFields.push(`sales_team_id = $${paramIndex++}`); values.push(dto.sales_team_id); } if (dto.notes !== undefined) { updateFields.push(`notes = $${paramIndex++}`); values.push(dto.notes); } if (dto.terms_conditions !== undefined) { updateFields.push(`terms_conditions = $${paramIndex++}`); values.push(dto.terms_conditions); } if (updateFields.length === 0) { return existing; } updateFields.push(`updated_by = $${paramIndex++}`); values.push(userId); updateFields.push(`updated_at = CURRENT_TIMESTAMP`); values.push(id, tenantId); await query( `UPDATE sales.quotations SET ${updateFields.join(', ')} WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, values ); return this.findById(id, tenantId); } async delete(id: string, tenantId: string): Promise { const existing = await this.findById(id, tenantId); if (existing.status !== 'draft') { throw new ValidationError('Solo se pueden eliminar cotizaciones en estado borrador'); } await query( `DELETE FROM sales.quotations WHERE id = $1 AND tenant_id = $2`, [id, tenantId] ); } async addLine(quotationId: string, dto: CreateQuotationLineDto, tenantId: string, userId: string): Promise { const quotation = await this.findById(quotationId, tenantId); if (quotation.status !== 'draft') { throw new ValidationError('Solo se pueden agregar líneas a cotizaciones en estado borrador'); } // Calculate amounts with taxes using taxesService const taxResult = await taxesService.calculateTaxes( { quantity: dto.quantity, priceUnit: dto.price_unit, discount: dto.discount || 0, taxIds: dto.tax_ids || [], }, tenantId, 'sales' ); const amountUntaxed = taxResult.amountUntaxed; const amountTax = taxResult.amountTax; const amountTotal = taxResult.amountTotal; const line = await queryOne( `INSERT INTO sales.quotation_lines ( quotation_id, tenant_id, product_id, description, quantity, uom_id, price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ quotationId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id, dto.price_unit, dto.discount || 0, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal ] ); // Update quotation totals await this.updateTotals(quotationId); return line!; } async updateLine(quotationId: string, lineId: string, dto: UpdateQuotationLineDto, tenantId: string): Promise { const quotation = await this.findById(quotationId, tenantId); if (quotation.status !== 'draft') { throw new ValidationError('Solo se pueden editar líneas de cotizaciones en estado borrador'); } const existingLine = quotation.lines?.find(l => l.id === lineId); if (!existingLine) { throw new NotFoundError('Línea de cotización no encontrada'); } const updateFields: string[] = []; const values: any[] = []; let paramIndex = 1; const quantity = dto.quantity ?? existingLine.quantity; const priceUnit = dto.price_unit ?? existingLine.price_unit; const discount = dto.discount ?? existingLine.discount; if (dto.description !== undefined) { updateFields.push(`description = $${paramIndex++}`); values.push(dto.description); } if (dto.quantity !== undefined) { updateFields.push(`quantity = $${paramIndex++}`); values.push(dto.quantity); } if (dto.uom_id !== undefined) { updateFields.push(`uom_id = $${paramIndex++}`); values.push(dto.uom_id); } if (dto.price_unit !== undefined) { updateFields.push(`price_unit = $${paramIndex++}`); values.push(dto.price_unit); } if (dto.discount !== undefined) { updateFields.push(`discount = $${paramIndex++}`); values.push(dto.discount); } if (dto.tax_ids !== undefined) { updateFields.push(`tax_ids = $${paramIndex++}`); values.push(dto.tax_ids); } // Recalculate amounts const subtotal = quantity * priceUnit; const discountAmount = subtotal * discount / 100; const amountUntaxed = subtotal - discountAmount; const amountTax = 0; // TODO: Calculate taxes const amountTotal = amountUntaxed + amountTax; updateFields.push(`amount_untaxed = $${paramIndex++}`); values.push(amountUntaxed); updateFields.push(`amount_tax = $${paramIndex++}`); values.push(amountTax); updateFields.push(`amount_total = $${paramIndex++}`); values.push(amountTotal); values.push(lineId, quotationId); await query( `UPDATE sales.quotation_lines SET ${updateFields.join(', ')} WHERE id = $${paramIndex++} AND quotation_id = $${paramIndex}`, values ); // Update quotation totals await this.updateTotals(quotationId); const updated = await queryOne( `SELECT * FROM sales.quotation_lines WHERE id = $1`, [lineId] ); return updated!; } async removeLine(quotationId: string, lineId: string, tenantId: string): Promise { const quotation = await this.findById(quotationId, tenantId); if (quotation.status !== 'draft') { throw new ValidationError('Solo se pueden eliminar líneas de cotizaciones en estado borrador'); } await query( `DELETE FROM sales.quotation_lines WHERE id = $1 AND quotation_id = $2`, [lineId, quotationId] ); // Update quotation totals await this.updateTotals(quotationId); } async send(id: string, tenantId: string, userId: string): Promise { const quotation = await this.findById(id, tenantId); if (quotation.status !== 'draft') { throw new ValidationError('Solo se pueden enviar cotizaciones en estado borrador'); } if (!quotation.lines || quotation.lines.length === 0) { throw new ValidationError('La cotización debe tener al menos una línea'); } await query( `UPDATE sales.quotations SET status = 'sent', updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND tenant_id = $3`, [userId, id, tenantId] ); // TODO: Send email notification return this.findById(id, tenantId); } async confirm(id: string, tenantId: string, userId: string): Promise<{ quotation: Quotation; orderId: string }> { const quotation = await this.findById(id, tenantId); if (!['draft', 'sent'].includes(quotation.status)) { throw new ValidationError('Solo se pueden confirmar cotizaciones en estado borrador o enviado'); } if (!quotation.lines || quotation.lines.length === 0) { throw new ValidationError('La cotización debe tener al menos una línea'); } const client = await getClient(); try { await client.query('BEGIN'); // Generate order sequence number const seqResult = await client.query( `SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 4) AS INTEGER)), 0) + 1 as next_num FROM sales.sales_orders WHERE tenant_id = $1 AND name LIKE 'SO-%'`, [tenantId] ); const orderNumber = `SO-${String(seqResult.rows[0]?.next_num || 1).padStart(6, '0')}`; // Create sales order const orderResult = await client.query( `INSERT INTO sales.sales_orders ( tenant_id, company_id, name, partner_id, order_date, currency_id, pricelist_id, user_id, sales_team_id, amount_untaxed, amount_tax, amount_total, notes, terms_conditions, created_by ) SELECT tenant_id, company_id, $1, partner_id, CURRENT_DATE, currency_id, pricelist_id, user_id, sales_team_id, amount_untaxed, amount_tax, amount_total, notes, terms_conditions, $2 FROM sales.quotations WHERE id = $3 RETURNING id`, [orderNumber, userId, id] ); const orderId = orderResult.rows[0].id; // Copy lines to order (include tenant_id for multi-tenant security) await client.query( `INSERT INTO sales.sales_order_lines ( order_id, tenant_id, product_id, description, quantity, uom_id, price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total ) SELECT $1, $3, product_id, description, quantity, uom_id, price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total FROM sales.quotation_lines WHERE quotation_id = $2 AND tenant_id = $3`, [orderId, id, tenantId] ); // Update quotation status await client.query( `UPDATE sales.quotations SET status = 'confirmed', sale_order_id = $1, updated_by = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`, [orderId, userId, id] ); await client.query('COMMIT'); return { quotation: await this.findById(id, tenantId), orderId }; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } async cancel(id: string, tenantId: string, userId: string): Promise { const quotation = await this.findById(id, tenantId); if (quotation.status === 'confirmed') { throw new ValidationError('No se pueden cancelar cotizaciones confirmadas'); } if (quotation.status === 'cancelled') { throw new ValidationError('La cotización ya está cancelada'); } await query( `UPDATE sales.quotations SET status = 'cancelled', updated_by = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND tenant_id = $3`, [userId, id, tenantId] ); return this.findById(id, tenantId); } private async updateTotals(quotationId: string): Promise { await query( `UPDATE sales.quotations SET amount_untaxed = COALESCE((SELECT SUM(amount_untaxed) FROM sales.quotation_lines WHERE quotation_id = $1), 0), amount_tax = COALESCE((SELECT SUM(amount_tax) FROM sales.quotation_lines WHERE quotation_id = $1), 0), amount_total = COALESCE((SELECT SUM(amount_total) FROM sales.quotation_lines WHERE quotation_id = $1), 0) WHERE id = $1`, [quotationId] ); } } export const quotationsService = new QuotationsService();