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>
450 lines
14 KiB
TypeScript
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();
|