erp-construccion-backend/src/modules/crm/leads.service.ts
rckrdmrd f3515d4f38 [SYNC] feat: Sincronizar módulos de erp-core (parcial)
Módulos copiados:
- partners/ (20 archivos)
- sales/ (19 archivos)
- crm/ (11 archivos)
- inventory/ (32 archivos nuevos)
- financial/taxes.service.ts

Infraestructura copiada:
- shared/errors/
- shared/middleware/
- shared/types/
- shared/utils/

Entidades core copiadas:
- country, currency, discount-rule, payment-term
- product-category, sequence, state, uom

Dependencias instaladas:
- zod
- winston

Estado: PARCIAL - Build no pasa por incompatibilidades
de imports. Ver SYNC-ERPC-CORE-STATUS.md para detalles.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 11:24:38 -06:00

450 lines
14 KiB
TypeScript

import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost';
export type LeadSource = 'website' | 'phone' | 'email' | 'referral' | 'social_media' | 'advertising' | 'event' | 'other';
export interface Lead {
id: string;
tenant_id: string;
company_id: string;
company_name?: string;
name: string;
ref?: string;
contact_name?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
company_prospect_name?: string;
job_position?: string;
industry?: string;
employee_count?: string;
annual_revenue?: number;
street?: string;
city?: string;
state?: string;
zip?: string;
country?: string;
stage_id?: string;
stage_name?: string;
status: LeadStatus;
user_id?: string;
user_name?: string;
sales_team_id?: string;
source?: LeadSource;
priority: number;
probability: number;
expected_revenue?: number;
date_open?: Date;
date_closed?: Date;
date_deadline?: Date;
date_last_activity?: Date;
partner_id?: string;
opportunity_id?: string;
lost_reason_id?: string;
lost_reason_name?: string;
lost_notes?: string;
description?: string;
notes?: string;
tags?: string[];
created_at: Date;
}
export interface CreateLeadDto {
company_id: string;
name: string;
ref?: string;
contact_name?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
company_prospect_name?: string;
job_position?: string;
industry?: string;
employee_count?: string;
annual_revenue?: number;
street?: string;
city?: string;
state?: string;
zip?: string;
country?: string;
stage_id?: string;
user_id?: string;
sales_team_id?: string;
source?: LeadSource;
priority?: number;
probability?: number;
expected_revenue?: number;
date_deadline?: string;
description?: string;
notes?: string;
tags?: string[];
}
export interface UpdateLeadDto {
name?: string;
ref?: string | null;
contact_name?: string | null;
email?: string | null;
phone?: string | null;
mobile?: string | null;
website?: string | null;
company_prospect_name?: string | null;
job_position?: string | null;
industry?: string | null;
employee_count?: string | null;
annual_revenue?: number | null;
street?: string | null;
city?: string | null;
state?: string | null;
zip?: string | null;
country?: string | null;
stage_id?: string | null;
user_id?: string | null;
sales_team_id?: string | null;
source?: LeadSource | null;
priority?: number;
probability?: number;
expected_revenue?: number | null;
date_deadline?: string | null;
description?: string | null;
notes?: string | null;
tags?: string[] | null;
}
export interface LeadFilters {
company_id?: string;
status?: LeadStatus;
stage_id?: string;
user_id?: string;
source?: LeadSource;
priority?: number;
search?: string;
page?: number;
limit?: number;
}
class LeadsService {
async findAll(tenantId: string, filters: LeadFilters = {}): Promise<{ data: Lead[]; total: number }> {
const { company_id, status, stage_id, user_id, source, priority, search, page = 1, limit = 20 } = filters;
const offset = (page - 1) * limit;
let whereClause = 'WHERE l.tenant_id = $1';
const params: any[] = [tenantId];
let paramIndex = 2;
if (company_id) {
whereClause += ` AND l.company_id = $${paramIndex++}`;
params.push(company_id);
}
if (status) {
whereClause += ` AND l.status = $${paramIndex++}`;
params.push(status);
}
if (stage_id) {
whereClause += ` AND l.stage_id = $${paramIndex++}`;
params.push(stage_id);
}
if (user_id) {
whereClause += ` AND l.user_id = $${paramIndex++}`;
params.push(user_id);
}
if (source) {
whereClause += ` AND l.source = $${paramIndex++}`;
params.push(source);
}
if (priority !== undefined) {
whereClause += ` AND l.priority = $${paramIndex++}`;
params.push(priority);
}
if (search) {
whereClause += ` AND (l.name ILIKE $${paramIndex} OR l.contact_name ILIKE $${paramIndex} OR l.email ILIKE $${paramIndex} OR l.company_name ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM crm.leads l ${whereClause}`,
params
);
params.push(limit, offset);
const data = await query<Lead>(
`SELECT l.*,
c.name as company_org_name,
ls.name as stage_name,
u.email as user_email,
lr.name as lost_reason_name
FROM crm.leads l
LEFT JOIN auth.companies c ON l.company_id = c.id
LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id
LEFT JOIN auth.users u ON l.user_id = u.id
LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id
${whereClause}
ORDER BY l.priority DESC, l.created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
params
);
return {
data,
total: parseInt(countResult?.count || '0', 10),
};
}
async findById(id: string, tenantId: string): Promise<Lead> {
const lead = await queryOne<Lead>(
`SELECT l.*,
c.name as company_org_name,
ls.name as stage_name,
u.email as user_email,
lr.name as lost_reason_name
FROM crm.leads l
LEFT JOIN auth.companies c ON l.company_id = c.id
LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id
LEFT JOIN auth.users u ON l.user_id = u.id
LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id
WHERE l.id = $1 AND l.tenant_id = $2`,
[id, tenantId]
);
if (!lead) {
throw new NotFoundError('Lead no encontrado');
}
return lead;
}
async create(dto: CreateLeadDto, tenantId: string, userId: string): Promise<Lead> {
const lead = await queryOne<Lead>(
`INSERT INTO crm.leads (
tenant_id, company_id, name, ref, contact_name, email, phone, mobile, website,
company_name, job_position, industry, employee_count, annual_revenue,
street, city, state, zip, country, stage_id, user_id, sales_team_id, source,
priority, probability, expected_revenue, date_deadline, description, notes, tags,
date_open, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, CURRENT_TIMESTAMP, $31)
RETURNING *`,
[
tenantId, dto.company_id, dto.name, dto.ref, dto.contact_name, dto.email, dto.phone,
dto.mobile, dto.website, dto.company_prospect_name, dto.job_position, dto.industry,
dto.employee_count, dto.annual_revenue, dto.street, dto.city, dto.state, dto.zip,
dto.country, dto.stage_id, dto.user_id, dto.sales_team_id, dto.source,
dto.priority || 0, dto.probability || 0, dto.expected_revenue, dto.date_deadline,
dto.description, dto.notes, dto.tags, userId
]
);
return this.findById(lead!.id, tenantId);
}
async update(id: string, dto: UpdateLeadDto, tenantId: string, userId: string): Promise<Lead> {
const existing = await this.findById(id, tenantId);
if (existing.status === 'converted' || existing.status === 'lost') {
throw new ValidationError('No se puede editar un lead convertido o perdido');
}
const updateFields: string[] = [];
const values: any[] = [];
let paramIndex = 1;
const fieldsToUpdate = [
'name', 'ref', 'contact_name', 'email', 'phone', 'mobile', 'website',
'company_prospect_name', 'job_position', 'industry', 'employee_count', 'annual_revenue',
'street', 'city', 'state', 'zip', 'country', 'stage_id', 'user_id', 'sales_team_id',
'source', 'priority', 'probability', 'expected_revenue', 'date_deadline',
'description', 'notes', 'tags'
];
for (const field of fieldsToUpdate) {
const key = field === 'company_prospect_name' ? 'company_name' : field;
if ((dto as any)[field] !== undefined) {
updateFields.push(`${key} = $${paramIndex++}`);
values.push((dto as any)[field]);
}
}
if (updateFields.length === 0) {
return existing;
}
updateFields.push(`date_last_activity = CURRENT_TIMESTAMP`);
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(userId);
values.push(id, tenantId);
await query(
`UPDATE crm.leads SET ${updateFields.join(', ')}
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
values
);
return this.findById(id, tenantId);
}
async moveStage(id: string, stageId: string, tenantId: string, userId: string): Promise<Lead> {
const lead = await this.findById(id, tenantId);
if (lead.status === 'converted' || lead.status === 'lost') {
throw new ValidationError('No se puede mover un lead convertido o perdido');
}
await query(
`UPDATE crm.leads SET
stage_id = $1,
date_last_activity = CURRENT_TIMESTAMP,
updated_by = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $3 AND tenant_id = $4`,
[stageId, userId, id, tenantId]
);
return this.findById(id, tenantId);
}
async convert(id: string, tenantId: string, userId: string): Promise<{ lead: Lead; opportunity_id: string }> {
const lead = await this.findById(id, tenantId);
if (lead.status === 'converted') {
throw new ValidationError('El lead ya fue convertido');
}
if (lead.status === 'lost') {
throw new ValidationError('No se puede convertir un lead perdido');
}
const client = await getClient();
try {
await client.query('BEGIN');
// Create or get partner
let partnerId = lead.partner_id;
if (!partnerId && lead.email) {
// Check if partner exists with same email
const existingPartner = await client.query(
`SELECT id FROM core.partners WHERE email = $1 AND tenant_id = $2`,
[lead.email, tenantId]
);
if (existingPartner.rows.length > 0) {
partnerId = existingPartner.rows[0].id;
} else {
// Create new partner
const partnerResult = await client.query(
`INSERT INTO core.partners (tenant_id, name, email, phone, mobile, is_customer, created_by)
VALUES ($1, $2, $3, $4, $5, TRUE, $6)
RETURNING id`,
[tenantId, lead.contact_name || lead.name, lead.email, lead.phone, lead.mobile, userId]
);
partnerId = partnerResult.rows[0].id;
}
}
if (!partnerId) {
throw new ValidationError('El lead debe tener un email o partner asociado para convertirse');
}
// Get default opportunity stage
const stageResult = await client.query(
`SELECT id FROM crm.opportunity_stages WHERE tenant_id = $1 ORDER BY sequence LIMIT 1`,
[tenantId]
);
const stageId = stageResult.rows[0]?.id || null;
// Create opportunity
const opportunityResult = await client.query(
`INSERT INTO crm.opportunities (
tenant_id, company_id, name, partner_id, contact_name, email, phone,
stage_id, user_id, sales_team_id, source, priority, probability,
expected_revenue, lead_id, description, notes, tags, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
RETURNING id`,
[
tenantId, lead.company_id, lead.name, partnerId, lead.contact_name, lead.email,
lead.phone, stageId, lead.user_id, lead.sales_team_id, lead.source, lead.priority,
lead.probability, lead.expected_revenue, id, lead.description, lead.notes, lead.tags, userId
]
);
const opportunityId = opportunityResult.rows[0].id;
// Update lead
await client.query(
`UPDATE crm.leads SET
status = 'converted',
partner_id = $1,
opportunity_id = $2,
date_closed = CURRENT_TIMESTAMP,
updated_by = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $4`,
[partnerId, opportunityId, userId, id]
);
await client.query('COMMIT');
const updatedLead = await this.findById(id, tenantId);
return { lead: updatedLead, opportunity_id: opportunityId };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async markLost(id: string, lostReasonId: string, notes: string | undefined, tenantId: string, userId: string): Promise<Lead> {
const lead = await this.findById(id, tenantId);
if (lead.status === 'converted') {
throw new ValidationError('No se puede marcar como perdido un lead convertido');
}
if (lead.status === 'lost') {
throw new ValidationError('El lead ya esta marcado como perdido');
}
await query(
`UPDATE crm.leads SET
status = 'lost',
lost_reason_id = $1,
lost_notes = $2,
date_closed = CURRENT_TIMESTAMP,
updated_by = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $4 AND tenant_id = $5`,
[lostReasonId, notes, userId, id, tenantId]
);
return this.findById(id, tenantId);
}
async delete(id: string, tenantId: string): Promise<void> {
const lead = await this.findById(id, tenantId);
if (lead.opportunity_id) {
throw new ConflictError('No se puede eliminar un lead que tiene una oportunidad asociada');
}
await query(`DELETE FROM crm.leads WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
}
}
export const leadsService = new LeadsService();