erp-core-backend/src/modules/sales/quotations.service.ts
2025-12-12 14:39:29 -06:00

589 lines
18 KiB
TypeScript

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<Quotation>(
`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<Quotation> {
const quotation = await queryOne<Quotation>(
`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<QuotationLine>(
`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<Quotation> {
// 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<Quotation>(
`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<Quotation> {
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<void> {
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<QuotationLine> {
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<QuotationLine>(
`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<QuotationLine> {
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<QuotationLine>(
`SELECT * FROM sales.quotation_lines WHERE id = $1`,
[lineId]
);
return updated!;
}
async removeLine(quotationId: string, lineId: string, tenantId: string): Promise<void> {
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<Quotation> {
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<Quotation> {
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<void> {
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();