Some checks failed
ERP Core CI / Backend Lint (push) Has been cancelled
ERP Core CI / Backend Unit Tests (push) Has been cancelled
ERP Core CI / Backend Integration Tests (push) Has been cancelled
ERP Core CI / Frontend Lint (push) Has been cancelled
ERP Core CI / Frontend Unit Tests (push) Has been cancelled
ERP Core CI / Frontend E2E Tests (push) Has been cancelled
ERP Core CI / Database DDL Validation (push) Has been cancelled
ERP Core CI / Backend Build (push) Has been cancelled
ERP Core CI / Frontend Build (push) Has been cancelled
ERP Core CI / CI Success (push) Has been cancelled
Performance Tests / Lighthouse CI (push) Has been cancelled
Performance Tests / Bundle Size Analysis (push) Has been cancelled
Performance Tests / k6 Load Tests (push) Has been cancelled
Performance Tests / Performance Summary (push) Has been cancelled
- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8 - Actualizaciones en modulos CRM y OpenAPI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
846 lines
26 KiB
TypeScript
846 lines
26 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';
|
|
import { emailService } from '../../shared/services/email.service.js';
|
|
import { logger } from '../../shared/utils/logger.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 using taxesService
|
|
const taxIds = dto.tax_ids ?? existingLine.tax_ids;
|
|
const taxResult = await taxesService.calculateTaxes(
|
|
{
|
|
quantity,
|
|
priceUnit,
|
|
discount,
|
|
taxIds,
|
|
},
|
|
tenantId,
|
|
'sales'
|
|
);
|
|
const amountUntaxed = taxResult.amountUntaxed;
|
|
const amountTax = taxResult.amountTax;
|
|
const amountTotal = taxResult.amountTotal;
|
|
|
|
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]
|
|
);
|
|
|
|
// Send email notification to partner
|
|
await this.sendQuotationEmail(quotation, tenantId);
|
|
|
|
return this.findById(id, tenantId);
|
|
}
|
|
|
|
/**
|
|
* Send quotation email to partner
|
|
*/
|
|
private async sendQuotationEmail(quotation: Quotation, tenantId: string): Promise<void> {
|
|
try {
|
|
// Get partner email
|
|
const partner = await queryOne<{ name: string; email: string | null }>(
|
|
`SELECT name, email FROM core.partners WHERE id = $1 AND tenant_id = $2`,
|
|
[quotation.partner_id, tenantId]
|
|
);
|
|
|
|
if (!partner?.email) {
|
|
logger.warn('Partner has no email, skipping quotation notification', {
|
|
quotationId: quotation.id,
|
|
partnerId: quotation.partner_id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Get company info for the email
|
|
const company = await queryOne<{ name: string }>(
|
|
`SELECT name FROM auth.companies WHERE id = $1`,
|
|
[quotation.company_id]
|
|
);
|
|
|
|
const html = this.generateQuotationEmailTemplate({
|
|
quotationName: quotation.name,
|
|
partnerName: partner.name,
|
|
companyName: company?.name || 'ERP Core',
|
|
validityDate: quotation.validity_date,
|
|
amountTotal: quotation.amount_total,
|
|
currencyCode: quotation.currency_code || 'MXN',
|
|
lines: quotation.lines || [],
|
|
});
|
|
|
|
const result = await emailService.send({
|
|
to: partner.email,
|
|
subject: `Cotización ${quotation.name} - ${company?.name || 'ERP Core'}`,
|
|
html,
|
|
text: `Estimado/a ${partner.name},\n\nLe enviamos la cotización ${quotation.name} por un total de ${quotation.currency_code || 'MXN'} ${quotation.amount_total.toLocaleString()}.\n\nEsta cotización es válida hasta ${new Date(quotation.validity_date).toLocaleDateString('es-MX')}.\n\nSaludos,\n${company?.name || 'ERP Core'}`,
|
|
});
|
|
|
|
if (result.success) {
|
|
logger.info('Quotation email sent successfully', {
|
|
quotationId: quotation.id,
|
|
partnerEmail: partner.email,
|
|
messageId: result.messageId,
|
|
});
|
|
} else {
|
|
logger.error('Failed to send quotation email', {
|
|
quotationId: quotation.id,
|
|
error: result.error,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// Don't fail the send operation if email fails
|
|
logger.error('Error sending quotation email', {
|
|
quotationId: quotation.id,
|
|
error: (error as Error).message,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate HTML template for quotation email
|
|
*/
|
|
private generateQuotationEmailTemplate(params: {
|
|
quotationName: string;
|
|
partnerName: string;
|
|
companyName: string;
|
|
validityDate: Date;
|
|
amountTotal: number;
|
|
currencyCode: string;
|
|
lines: QuotationLine[];
|
|
}): string {
|
|
const { quotationName, partnerName, companyName, validityDate, amountTotal, currencyCode, lines } = params;
|
|
const supportEmail = process.env.SUPPORT_EMAIL || 'ventas@erp-core.local';
|
|
|
|
const linesHtml = lines.map(line => `
|
|
<tr>
|
|
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb;">${line.description}</td>
|
|
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${line.quantity}</td>
|
|
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${currencyCode} ${line.price_unit.toLocaleString()}</td>
|
|
<td style="padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${currencyCode} ${line.amount_total.toLocaleString()}</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Cotización ${quotationName}</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
max-width: 700px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
background-color: #f5f5f5;
|
|
}
|
|
.container {
|
|
background-color: #ffffff;
|
|
border-radius: 8px;
|
|
padding: 40px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
.header {
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
padding-bottom: 20px;
|
|
border-bottom: 2px solid #2563eb;
|
|
}
|
|
.logo {
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
color: #2563eb;
|
|
}
|
|
h1 {
|
|
color: #1f2937;
|
|
font-size: 22px;
|
|
margin-bottom: 20px;
|
|
}
|
|
p {
|
|
margin-bottom: 16px;
|
|
color: #4b5563;
|
|
}
|
|
.quote-info {
|
|
background-color: #f9fafb;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin: 20px 0;
|
|
}
|
|
.quote-info-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 8px;
|
|
}
|
|
.quote-info-label {
|
|
color: #6b7280;
|
|
font-weight: 500;
|
|
}
|
|
.quote-info-value {
|
|
color: #1f2937;
|
|
font-weight: 600;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 20px 0;
|
|
}
|
|
th {
|
|
background-color: #f3f4f6;
|
|
padding: 12px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
color: #374151;
|
|
border-bottom: 2px solid #e5e7eb;
|
|
}
|
|
th:last-child, th:nth-child(3), th:nth-child(2) {
|
|
text-align: right;
|
|
}
|
|
th:nth-child(2) {
|
|
text-align: center;
|
|
}
|
|
.total-row {
|
|
background-color: #2563eb;
|
|
color: white;
|
|
}
|
|
.total-row td {
|
|
padding: 14px 12px;
|
|
font-weight: 600;
|
|
border-bottom: none;
|
|
}
|
|
.validity {
|
|
background-color: #fef3c7;
|
|
border-left: 4px solid #f59e0b;
|
|
padding: 12px 16px;
|
|
margin: 20px 0;
|
|
border-radius: 0 4px 4px 0;
|
|
}
|
|
.footer {
|
|
margin-top: 40px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #e5e7eb;
|
|
font-size: 14px;
|
|
color: #6b7280;
|
|
text-align: center;
|
|
}
|
|
.contact-link {
|
|
color: #2563eb;
|
|
text-decoration: none;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<div class="logo">${companyName}</div>
|
|
</div>
|
|
|
|
<h1>Cotización ${quotationName}</h1>
|
|
|
|
<p>Estimado/a <strong>${partnerName}</strong>,</p>
|
|
|
|
<p>Le enviamos nuestra cotización según lo solicitado. A continuación encontrará el detalle de los productos y/o servicios cotizados:</p>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Descripción</th>
|
|
<th>Cantidad</th>
|
|
<th>Precio Unit.</th>
|
|
<th>Subtotal</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${linesHtml}
|
|
<tr class="total-row">
|
|
<td colspan="3" style="text-align: right;">TOTAL:</td>
|
|
<td style="text-align: right;">${currencyCode} ${amountTotal.toLocaleString()}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="validity">
|
|
<strong>Vigencia:</strong> Esta cotización es válida hasta el ${new Date(validityDate).toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })}.
|
|
</div>
|
|
|
|
<p>Si tiene alguna pregunta o desea proceder con el pedido, no dude en contactarnos.</p>
|
|
|
|
<div class="footer">
|
|
<p>Atentamente,<br><strong>${companyName}</strong></p>
|
|
<p>Para cualquier consulta, contáctenos en <a href="mailto:${supportEmail}" class="contact-link">${supportEmail}</a></p>
|
|
<p>© ${new Date().getFullYear()} ${companyName}. Todos los derechos reservados.</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`.trim();
|
|
}
|
|
|
|
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();
|