feat(crm): end-to-end business integrity, idempotent conversion, and bidirectional order linking

This commit is contained in:
ochenstarik 2026-09-11 15:17:16 +07:00
parent 21fabde520
commit 42ac2f478a
11 changed files with 467 additions and 55 deletions

View file

@ -45,4 +45,5 @@ export interface Order extends BaseEntity {
channel: 'pos' | 'web' | 'mobile' | 'b2b' | 'manual'; channel: 'pos' | 'web' | 'mobile' | 'b2b' | 'manual';
shippingAddress?: string; shippingAddress?: string;
notes?: string; notes?: string;
dealId?: string;
} }

View file

@ -6,7 +6,7 @@ export interface Contact extends BaseEntity {
firstName: string; firstName: string;
lastName: string; lastName: string;
middleName?: string; middleName?: string;
roles: ContactRole[]; roles: (ContactRole | string)[];
companyId?: string; // Reference to Company companyId?: string; // Reference to Company
jobTitle?: string; jobTitle?: string;
email?: string; email?: string;
@ -14,5 +14,6 @@ export interface Contact extends BaseEntity {
telegram?: string; telegram?: string;
avatarUrl?: string; avatarUrl?: string;
notes?: string; notes?: string;
assignedTo?: string;
status: 'active' | 'archived' | 'blocked'; status: 'active' | 'archived' | 'blocked';
} }

View file

@ -2,6 +2,18 @@ import { BaseEntity, MoneyAmount } from '../types';
export type DealStage = 'prospecting' | 'discovery' | 'proposal' | 'negotiation' | 'contract' | 'won' | 'lost'; export type DealStage = 'prospecting' | 'discovery' | 'proposal' | 'negotiation' | 'contract' | 'won' | 'lost';
export interface DealItem {
id: string;
productId: string;
variantId?: string;
sku?: string;
name: string;
quantity: number;
unitPrice: MoneyAmount;
discountMinor?: number;
totalPrice: MoneyAmount;
}
export interface Deal extends BaseEntity { export interface Deal extends BaseEntity {
title: string; title: string;
pipelineId: string; pipelineId: string;
@ -13,5 +25,8 @@ export interface Deal extends BaseEntity {
contactId?: string; contactId?: string;
companyId?: string; companyId?: string;
assignedUserId?: string; assignedUserId?: string;
assignedTo?: string;
lostReason?: string; lostReason?: string;
orderId?: string;
items?: DealItem[];
} }

View file

@ -6,12 +6,14 @@ export interface Lead extends BaseEntity {
title: string; title: string;
contactName: string; contactName: string;
companyName?: string; companyName?: string;
companyId?: string;
phone?: string; phone?: string;
email?: string; email?: string;
source: 'website' | 'phone' | 'referral' | 'telegram' | 'ads' | 'pos' | 'other'; source: 'website' | 'phone' | 'referral' | 'telegram' | 'ads' | 'pos' | 'other';
status: LeadStatus; status: LeadStatus;
estimatedValue?: MoneyAmount; estimatedValue?: MoneyAmount;
assignedUserId?: string; assignedUserId?: string;
assignedTo?: string;
score?: number; // 0-100 score?: number; // 0-100
convertedContactId?: string; convertedContactId?: string;
convertedDealId?: string; convertedDealId?: string;

View file

@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDatabase } from '../../services/storage/indexedDb';
import { getServices } from '../../services/mock';
import { OrderItem } from '../../entities/commerce/order';
describe('CRM End-to-End Business Scenario Integrity Suite (F08)', () => {
const { crm, commerce } = getServices();
beforeEach(async () => {
await resetDatabase();
});
it('1. Executes full end-to-end flow: Lead -> Conversion -> Contact + Company + Deal -> Won -> Order with specification and two-way link', async () => {
// 1. Create a product in commerce
const product = await commerce.products.create({
sku: 'KRN-BARISTA-01',
name: 'Кофемашина Krona Pro Barista Dual',
categoryId: 'cat_equipment',
price: { minor: 25000000, currency: 'RUB' },
costPrice: { minor: 15000000, currency: 'RUB' },
unit: 'шт',
isService: false,
trackInventory: true,
status: 'active',
});
// 2. Create an incoming Lead
const lead = await crm.leads.create({
title: 'Поставка оборудования для кофейни «Арома»',
contactName: 'Михаил Соколов',
companyName: 'ООО Арома Групп',
phone: '+7 (926) 444-55-66',
email: 'mikhail@aroma-group.ru',
source: 'website',
status: 'new',
estimatedValue: { minor: 50000000, currency: 'RUB' },
assignedTo: 'Менеджер Елена',
score: 85,
});
expect(lead.status).toBe('new');
// 3. Convert Lead to Deal
const conversionResult = await crm.convertLeadToDeal(
lead.id,
'Сделка: Оснащение кофейни Арома Групп'
);
const { deal, contact } = conversionResult;
expect(deal).toBeDefined();
expect(contact).toBeDefined();
// Verify Contact fields and company linkage
expect(contact!.firstName).toBe('Михаил');
expect(contact!.lastName).toBe('Соколов');
expect(contact!.email).toBe('mikhail@aroma-group.ru');
expect(contact!.phone).toBe('+7 (926) 444-55-66');
expect(contact!.assignedTo).toBe('Менеджер Елена');
expect(contact!.roles).toContain('client');
expect(contact!.companyId).toBeDefined();
// Verify Company was auto-created from companyName
const company = await crm.companies.getById(contact!.companyId!);
expect(company).toBeDefined();
expect(company!.name).toBe('ООО Арома Групп');
// Verify Deal details
expect(deal.title).toBe('Сделка: Оснащение кофейни Арома Групп');
expect(deal.stage).toBe('proposal');
expect(deal.contactId).toBe(contact!.id);
expect(deal.companyId).toBe(company!.id);
expect(deal.amount.minor).toBe(50000000);
expect(deal.assignedTo).toBe('Менеджер Елена');
// Verify Lead state is converted with references
const updatedLead = await crm.leads.getById(lead.id);
expect(updatedLead!.status).toBe('converted');
expect(updatedLead!.convertedContactId).toBe(contact!.id);
expect(updatedLead!.convertedDealId).toBe(deal.id);
// 4. Progress Deal through stages to 'won' and attach real specification items
await crm.deals.update(deal.id, {
stage: 'won',
items: [
{
id: 'deal_item_1',
productId: product.id,
sku: product.sku,
name: product.name,
quantity: 2,
unitPrice: { minor: 25000000, currency: 'RUB' },
totalPrice: { minor: 50000000, currency: 'RUB' },
},
],
});
const wonDeal = (await crm.deals.getById(deal.id))!;
expect(wonDeal.stage).toBe('won');
expect(wonDeal.items).toHaveLength(1);
// 5. Create Order from Won Deal with real specification (no placeholders like 'prd_coffee_beans' or 'COF-100')
const orderItems: OrderItem[] = wonDeal.items!.map((it) => ({
id: `ord_item_${it.id}`,
productId: it.productId,
sku: it.sku || 'SKU-UNKNOWN',
name: it.name,
quantity: it.quantity,
unitPrice: it.unitPrice,
discountMinor: 0,
totalPrice: it.totalPrice,
}));
const order = await commerce.orders.create({
orderNumber: 'ORD-CRM-TEST-001',
customerType: 'company',
customerId: wonDeal.companyId!,
customerName: company!.name,
status: 'draft',
paymentStatus: 'unpaid',
fulfillmentStatus: 'unfulfilled',
items: orderItems,
subtotal: wonDeal.amount,
discountMinor: 0,
taxMinor: Math.round(wonDeal.amount.minor * 0.2),
shippingMinor: 0,
total: wonDeal.amount,
channel: 'b2b',
warehouseId: 'wh_main',
dealId: wonDeal.id, // Two-way link: Order -> Deal
});
// Update Deal with Order reference (Two-way link: Deal -> Order)
await crm.deals.update(wonDeal.id, { orderId: order.id });
// 6. Verify Two-Way Link
const finalizedOrder = await commerce.orders.getById(order.id);
const finalizedDeal = await crm.deals.getById(wonDeal.id);
expect(finalizedOrder!.dealId).toBe(wonDeal.id);
expect(finalizedDeal!.orderId).toBe(order.id);
// Verify that NO placeholder values were used
expect(finalizedOrder!.items[0].productId).toBe(product.id);
expect(finalizedOrder!.items[0].productId).not.toBe('prd_coffee_beans');
expect(finalizedOrder!.items[0].sku).toBe('KRN-BARISTA-01');
expect(finalizedOrder!.items[0].sku).not.toBe('COF-100');
expect(finalizedOrder!.items[0].unitPrice.minor).toBe(25000000);
// 7. Verify Timeline includes linked deal and order events
const timeline = await crm.getTimeline('deal', wonDeal.id);
expect(timeline.some((t) => t.type === 'order_created')).toBe(true);
});
it('2. Idempotency: Repeating convertLeadToDeal on an already converted lead returns existing deal and contact without duplication', async () => {
const lead = await crm.leads.create({
title: 'Лид для проверки повторной конвертации',
contactName: 'Ольга Николаева',
companyName: 'ЗАО Технопром',
phone: '+7 (911) 777-88-99',
email: 'olga@technoprom.ru',
source: 'referral',
status: 'new',
});
const firstRun = await crm.convertLeadToDeal(lead.id, 'Первая конвертация');
expect(firstRun.deal).toBeDefined();
expect(firstRun.contact).toBeDefined();
const dealsBefore = await crm.deals.list({ pageSize: 100 });
const contactsBefore = await crm.contacts.list({ pageSize: 100 });
// Second call on already converted lead
const secondRun = await crm.convertLeadToDeal(lead.id, 'Повторная попытка конвертации');
expect(secondRun.deal.id).toBe(firstRun.deal.id);
expect(secondRun.contact?.id).toBe(firstRun.contact?.id);
const dealsAfter = await crm.deals.list({ pageSize: 100 });
const contactsAfter = await crm.contacts.list({ pageSize: 100 });
// No duplicate records created!
expect(dealsAfter.items.length).toBe(dealsBefore.items.length);
expect(contactsAfter.items.length).toBe(contactsBefore.items.length);
});
it('3. Existing Contact Matching: Converts new lead for an existing contact by email or phone without creating duplicate contact', async () => {
// Pre-existing contact
const existingContact = await crm.contacts.create({
firstName: 'Сергей',
lastName: 'Васильев',
phone: '+7 (905) 123-45-67',
email: 'sergey.vasiliev@corp.ru',
roles: ['client'],
status: 'active',
});
// New lead with matching email and formatted phone
const newLead = await crm.leads.create({
title: 'Второй запрос от существующего клиента',
contactName: 'Сергей Васильев',
phone: '+7-905-123-45-67',
email: 'SERGEY.VASILIEV@CORP.RU', // case-insensitive check
source: 'phone',
status: 'new',
});
const conversion = await crm.convertLeadToDeal(newLead.id, 'Вторая сделка с Сергеем');
expect(conversion.contact?.id).toBe(existingContact.id);
const allContacts = await crm.contacts.list({ pageSize: 100 });
const matchingContacts = allContacts.items.filter(
(c) => c.email?.toLowerCase() === 'sergey.vasiliev@corp.ru'
);
expect(matchingContacts).toHaveLength(1);
});
});

View file

@ -8,6 +8,8 @@ import { Contact } from '../../entities/crm/contact';
import { Company } from '../../entities/crm/company'; import { Company } from '../../entities/crm/company';
import { Deal, DealStage } from '../../entities/crm/deal'; import { Deal, DealStage } from '../../entities/crm/deal';
import { Lead } from '../../entities/crm/lead'; import { Lead } from '../../entities/crm/lead';
import { OrderItem } from '../../entities/commerce/order';
import { MoneyAmount } from '../../entities/types';
import { MaturityBadge } from '../../shared/ui/MaturityBadge'; import { MaturityBadge } from '../../shared/ui/MaturityBadge';
import { useToast } from '../../shared/ui/useToast'; import { useToast } from '../../shared/ui/useToast';
import { CsvImportWizard } from './CsvImportWizard'; import { CsvImportWizard } from './CsvImportWizard';
@ -41,7 +43,7 @@ export const CrmModule: React.FC = () => {
createActivity, createActivity,
} = useCrm(); } = useCrm();
const { orders, createOrder } = useOrders(); const { orders, products, createOrder } = useOrders();
const contactMeta = useMetadata('contact'); const contactMeta = useMetadata('contact');
const companyMeta = useMetadata('company'); const companyMeta = useMetadata('company');
@ -74,15 +76,13 @@ export const CrmModule: React.FC = () => {
const [selectedContact, setSelectedContact] = useState<Contact | null>(null); const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
const [selectedCompany, setSelectedCompany] = useState<Company | null>(null); const [selectedCompany, setSelectedCompany] = useState<Company | null>(null);
// Sync tab with URL on external route changes // Synchronize active tab with URL path if navigated externally
const [prevPath, setPrevPath] = useState(location.pathname); const p = location.pathname;
if (prevPath !== location.pathname) { if (p !== '/crm' && p !== '/crm/') {
setPrevPath(location.pathname); if (p.includes('/crm/deals') && activeTab !== 'deals') setActiveTab('deals');
const p = location.pathname; else if (p.includes('/crm/leads') && activeTab !== 'leads') setActiveTab('leads');
if (p.includes('/crm/deals')) setActiveTab('deals'); else if (p.includes('/crm/companies') && activeTab !== 'companies') setActiveTab('companies');
else if (p.includes('/crm/leads')) setActiveTab('leads'); else if (p.includes('/crm/import') && activeTab !== 'import') setActiveTab('import');
else if (p.includes('/crm/companies')) setActiveTab('companies');
else if (p.includes('/crm/import')) setActiveTab('import');
else if (p.includes('/crm/contacts') || p === '/crm') setActiveTab('contacts'); else if (p.includes('/crm/contacts') || p === '/crm') setActiveTab('contacts');
} }
@ -101,40 +101,95 @@ export const CrmModule: React.FC = () => {
}; };
// Confirm Deal Won -> Order creation // Confirm Deal Won -> Order creation
const handleConfirmOrderCreation = async ({ orderTitle, orderWarehouse }: { orderTitle: string; orderWarehouse: string }) => { const handleConfirmOrderCreation = async ({
orderTitle,
orderWarehouse,
items: customItems,
}: {
orderTitle: string;
orderWarehouse: string;
items?: OrderItem[];
}) => {
if (!wonDeal) return; if (!wonDeal) return;
try { try {
const customer = contacts.find((c) => c.id === wonDeal.contactId); const customer = contacts.find((c) => c.id === wonDeal.contactId);
const company = companies.find((c) => c.id === wonDeal.companyId); const company = companies.find((c) => c.id === wonDeal.companyId);
const customerName = customer ? `${customer.firstName} ${customer.lastName}` : (company?.name || 'Клиент'); const customerName = customer
? `${customer.firstName} ${customer.lastName}`.trim()
: company?.name || 'Клиент';
const orderNumber = `ORD-${ulid().slice(-6)}`; const orderNumber = `ORD-${ulid().slice(-6)}`;
await createOrder({ let orderItems: OrderItem[];
if (customItems && customItems.length > 0) {
orderItems = customItems;
} else if (wonDeal.items && wonDeal.items.length > 0) {
orderItems = wonDeal.items.map((item) => ({
id: item.id || `item_${ulid()}`,
productId: item.productId,
sku: item.sku || `SKU-${item.productId.slice(-4)}`,
name: item.name,
quantity: item.quantity,
unitPrice: item.unitPrice,
discountMinor: item.discountMinor || 0,
totalPrice: item.totalPrice || {
minor: item.unitPrice.minor * item.quantity,
currency: item.unitPrice.currency,
},
}));
} else if (products && products.length > 0) {
const prod = products[0];
orderItems = [
{
id: `item_${ulid()}`,
productId: prod.id,
sku: prod.sku,
name: prod.name,
quantity: 1,
unitPrice: wonDeal.amount,
discountMinor: 0,
totalPrice: wonDeal.amount,
},
];
} else {
orderItems = [
{
id: `item_${ulid()}`,
productId: `deal_prod_${wonDeal.id}`,
sku: `SPEC-${wonDeal.id.slice(-6)}`,
name: orderTitle || wonDeal.title,
quantity: 1,
unitPrice: wonDeal.amount,
discountMinor: 0,
totalPrice: wonDeal.amount,
},
];
}
const totalMinor = orderItems.reduce((acc, it) => acc + it.totalPrice.minor, 0);
const orderTotal: MoneyAmount = { minor: totalMinor, currency: wonDeal.amount.currency || 'RUB' };
const createdOrder = await createOrder({
orderNumber, orderNumber,
customerType: wonDeal.contactId ? 'contact' : 'company', customerType: wonDeal.contactId ? 'contact' : 'company',
customerId: wonDeal.contactId || wonDeal.companyId || 'cnt_default', customerId: wonDeal.contactId || wonDeal.companyId || (customer?.id ?? company?.id ?? 'cust_crm'),
customerName, customerName,
status: 'draft', status: 'draft',
paymentStatus: 'pending', paymentStatus: 'unpaid',
fulfillmentStatus: 'unfulfilled', fulfillmentStatus: 'unfulfilled',
items: [{ items: orderItems,
id: `item_${ulid()}`, subtotal: orderTotal,
productId: 'prd_coffee_beans',
sku: 'COF-100',
name: orderTitle || wonDeal.title,
quantity: 1,
unitPrice: wonDeal.amount,
discountMinor: 0,
totalPrice: wonDeal.amount,
}],
subtotal: wonDeal.amount,
discountMinor: 0, discountMinor: 0,
taxMinor: Math.round(wonDeal.amount.minor * 0.2), taxMinor: Math.round(orderTotal.minor * 0.2),
shippingMinor: 0, shippingMinor: 0,
total: wonDeal.amount, total: orderTotal,
warehouseId: orderWarehouse,
channel: 'b2b', channel: 'b2b',
dealId: wonDeal.id,
}); });
// Two-way link: deal -> order
await updateDeal(wonDeal.id, { orderId: createdOrder.id });
showToast(`Связанный Заказ ${orderNumber} успешно создан в Коммерции! (Склад: ${orderWarehouse})`, 'success'); showToast(`Связанный Заказ ${orderNumber} успешно создан в Коммерции! (Склад: ${orderWarehouse})`, 'success');
setIsDealWonModalOpen(false); setIsDealWonModalOpen(false);
setWonDeal(null); setWonDeal(null);

View file

@ -101,14 +101,25 @@ export const CrmEntityModals: React.FC<CrmEntityModalsProps> = ({
layout={contactMeta.layouts?.[0] || null} layout={contactMeta.layouts?.[0] || null}
mode="create" mode="create"
onSave={async (values) => { onSave={async (values) => {
const rolesList = Array.isArray(values.roles)
? (values.roles as string[])
: typeof values.roles === 'string'
? values.roles.split(',').map((r) => r.trim()).filter(Boolean)
: ['client'];
await onCreateContact({ await onCreateContact({
firstName: String(values.firstName || 'Новый'), firstName: String(values.firstName || 'Новый'),
lastName: String(values.lastName || 'Контакт'), lastName: String(values.lastName || 'Контакт'),
middleName: values.middleName ? String(values.middleName) : undefined,
phone: String(values.phone || ''), phone: String(values.phone || ''),
email: String(values.email || ''), email: String(values.email || ''),
jobTitle: String(values.jobTitle || ''), jobTitle: String(values.jobTitle || ''),
roles: ['client'], companyId: values.companyId ? String(values.companyId) : undefined,
status: 'active', roles: rolesList.length > 0 ? (rolesList as Contact['roles']) : ['client'],
telegram: values.telegram ? String(values.telegram) : undefined,
notes: values.notes ? String(values.notes) : undefined,
assignedTo: values.assignedTo ? String(values.assignedTo) : undefined,
status: (values.status as Contact['status']) || 'active',
customFields: values, customFields: values,
}); });
onCloseContact(); onCloseContact();

View file

@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Deal } from '../../../entities/crm/deal'; import { Deal } from '../../../entities/crm/deal';
import { OrderItem } from '../../../entities/commerce/order';
import { Modal } from '../../../shared/ui/Modal'; import { Modal } from '../../../shared/ui/Modal';
import { Button } from '../../../shared/ui/Button'; import { Button } from '../../../shared/ui/Button';
import { formatCurrencyMinor } from '../../../shared/lib/currency'; import { formatCurrencyMinor } from '../../../shared/lib/currency';
@ -9,7 +10,11 @@ export interface DealWonOrderModalProps {
isOpen: boolean; isOpen: boolean;
wonDeal: Deal | null; wonDeal: Deal | null;
onClose: () => void; onClose: () => void;
onConfirmOrder: (params: { orderTitle: string; orderWarehouse: string }) => Promise<void> | void; onConfirmOrder: (params: {
orderTitle: string;
orderWarehouse: string;
items?: OrderItem[];
}) => Promise<void> | void;
} }
export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({ export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
@ -42,7 +47,7 @@ export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
<div className="space-y-4 text-xs"> <div className="space-y-4 text-xs">
<div className="p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-800 dark:text-emerald-300"> <div className="p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-800 dark:text-emerald-300">
<strong>Поздравляем с успехом!</strong> Сделка «{wonDeal?.title}» переведена в статус «Выиграна». <strong>Поздравляем с успехом!</strong> Сделка «{wonDeal?.title}» переведена в статус «Выиграна».
Система предлагает автоматически сформировать коммерческий Заказ. Система предлагает автоматически сформировать коммерческий Заказ с двусторонней связью.
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@ -60,13 +65,33 @@ export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
<div> <div>
<label className="block font-semibold text-krona-primary mb-1"> <label className="block font-semibold text-krona-primary mb-1">
Сумма заказа (RUB): Сумма заказа:
</label> </label>
<div className="font-bold text-sm text-krona-green py-1"> <div className="font-bold text-sm text-krona-green py-1">
{wonDeal ? formatCurrencyMinor(wonDeal.amount.minor) : '0 ₽'} {wonDeal ? formatCurrencyMinor(wonDeal.amount.minor) : '0 ₽'}
</div> </div>
</div> </div>
{wonDeal?.items && wonDeal.items.length > 0 && (
<div>
<label className="block font-semibold text-krona-primary mb-1">
Спецификация сделки ({wonDeal.items.length} поз.):
</label>
<div className="border border-krona-border rounded-lg p-2 bg-krona-workspace/50 space-y-1.5 max-h-36 overflow-y-auto">
{wonDeal.items.map((item, idx) => (
<div key={item.id || idx} className="flex justify-between items-center text-xs">
<span className="text-krona-primary">
{item.name} <span className="text-krona-secondary">× {item.quantity} шт.</span>
</span>
<span className="font-semibold text-krona-primary">
{formatCurrencyMinor(item.totalPrice?.minor ?? item.unitPrice.minor * item.quantity)}
</span>
</div>
))}
</div>
</div>
)}
<div> <div>
<label className="block font-semibold text-krona-primary mb-1"> <label className="block font-semibold text-krona-primary mb-1">
Склад отгрузки: Склад отгрузки:

View file

@ -41,5 +41,5 @@ export interface ICrmService {
activities: IRepository<Activity>; activities: IRepository<Activity>;
notes: IRepository<Note>; notes: IRepository<Note>;
getTimeline(entityType: string, entityId: string): Promise<TimelineItem[]>; getTimeline(entityType: string, entityId: string): Promise<TimelineItem[]>;
convertLeadToDeal(leadId: string, dealTitle: string): Promise<{ deal: Deal; contact?: Contact }>; convertLeadToDeal(leadId: string, dealTitle?: string): Promise<{ deal: Deal; contact?: Contact }>;
} }

View file

@ -56,7 +56,7 @@ export class MockCrmService implements ICrmService {
}); });
} }
// 3. If contact or company, fetch linked deals // 3. If contact, company, or deal, fetch linked deals and orders
if (entityType === 'contact' || entityType === 'company') { if (entityType === 'contact' || entityType === 'company') {
const dealsRes = await this.deals.list({ const dealsRes = await this.deals.list({
filter: entityType === 'contact' ? { contactId: entityId } : { companyId: entityId }, filter: entityType === 'contact' ? { contactId: entityId } : { companyId: entityId },
@ -68,7 +68,29 @@ export class MockCrmService implements ICrmService {
type: 'deal_created', type: 'deal_created',
title: `Сделка: ${d.title}`, title: `Сделка: ${d.title}`,
description: `Этап: ${d.stage}, сумма: ${(d.amount.minor / 100).toLocaleString('ru-RU')} ₽`, description: `Этап: ${d.stage}, сумма: ${(d.amount.minor / 100).toLocaleString('ru-RU')} ₽`,
actor: 'Отдел продаж', actor: d.assignedTo || 'Отдел продаж',
});
if (d.orderId) {
timeline.push({
id: `ord_${d.orderId}`,
timestamp: d.audit?.updatedAt || d.audit?.createdAt || new Date().toISOString(),
type: 'order_created',
title: `Заказ по сделке: ${d.title}`,
description: `Связанный заказ ID: ${d.orderId}`,
actor: 'Коммерция',
});
}
}
} else if (entityType === 'deal') {
const deal = await this.deals.getById(entityId);
if (deal && deal.orderId) {
timeline.push({
id: `ord_${deal.orderId}`,
timestamp: deal.audit?.updatedAt || deal.audit?.createdAt || new Date().toISOString(),
type: 'order_created',
title: `Создан связанный заказ`,
description: `Заказ ID: ${deal.orderId} успешно сформирован в Коммерции`,
actor: 'Коммерция',
}); });
} }
} }
@ -77,44 +99,108 @@ export class MockCrmService implements ICrmService {
return timeline.sort((a, b) => (b.timestamp > a.timestamp ? 1 : -1)); return timeline.sort((a, b) => (b.timestamp > a.timestamp ? 1 : -1));
} }
async convertLeadToDeal(leadId: string, dealTitle: string): Promise<{ deal: Deal; contact?: Contact }> { async convertLeadToDeal(
leadId: string,
dealTitle?: string
): Promise<{ deal: Deal; contact?: Contact }> {
const lead = await this.leads.getById(leadId); const lead = await this.leads.getById(leadId);
if (!lead) { if (!lead) {
throw new Error(`Лид с ID ${leadId} не найден.`); throw new Error(`Лид с ID ${leadId} не найден.`);
} }
// 1. Create or match contact // 1. Idempotency: if already converted, return existing records without duplicating
const parts = (lead.contactName || 'Клиент').split(' '); if (lead.status === 'converted' && lead.convertedDealId) {
const firstName = parts[0]; const existingDeal = await this.deals.getById(lead.convertedDealId);
const lastName = parts.slice(1).join(' ') || 'Клиент'; const existingContact = lead.convertedContactId
? await this.contacts.getById(lead.convertedContactId)
: undefined;
const contact = await this.contacts.create({ if (existingDeal) {
firstName, return { deal: existingDeal, contact: existingContact || undefined };
lastName, }
phone: lead.phone, }
email: lead.email,
roles: ['client'],
status: 'active',
});
// 2. Create deal // 2. Resolve company: match existing by name or create
let companyId = lead.companyId;
if (!companyId && lead.companyName && lead.companyName.trim()) {
const companiesList = await this.companies.list({ pageSize: 1000 });
const foundCompany = companiesList.items.find(
(c) => c.name.trim().toLowerCase() === lead.companyName!.trim().toLowerCase()
);
if (foundCompany) {
companyId = foundCompany.id;
} else {
const newCompany = await this.companies.create({
name: lead.companyName.trim(),
status: 'active',
roles: ['client'],
});
companyId = newCompany.id;
}
}
// 3. Resolve contact: match by clean email or clean phone first
let contact: Contact | null = null;
const cleanEmail = lead.email ? lead.email.trim().toLowerCase() : '';
const cleanPhone = lead.phone ? lead.phone.replace(/\D/g, '') : '';
if (cleanEmail || (cleanPhone && cleanPhone.length >= 6)) {
const contactsList = await this.contacts.list({ pageSize: 1000 });
contact =
contactsList.items.find((c) => {
if (cleanEmail && c.email && c.email.trim().toLowerCase() === cleanEmail) {
return true;
}
if (cleanPhone && c.phone) {
const cp = c.phone.replace(/\D/g, '');
if (cp && cp === cleanPhone) return true;
}
return false;
}) || null;
}
if (!contact) {
const parts = (lead.contactName || 'Клиент').trim().split(/\s+/);
const firstName = parts[0] || 'Клиент';
const lastName = parts.slice(1).join(' ') || 'Клиент';
contact = await this.contacts.create({
firstName,
lastName,
phone: lead.phone,
email: lead.email,
companyId,
roles: ['client', 'lead'],
assignedTo: lead.assignedTo || lead.assignedUserId,
status: 'active',
});
} else if (companyId && !contact.companyId) {
await this.contacts.update(contact.id, { companyId });
contact = (await this.contacts.getById(contact.id)) || contact;
}
// 4. Create deal
const deal = await this.deals.create({ const deal = await this.deals.create({
title: dealTitle || lead.title, title: dealTitle?.trim() || lead.title || `Сделка: ${lead.contactName}`,
pipelineId: 'pipe_default', pipelineId: 'pipe_default',
stage: 'proposal', stage: 'proposal',
amount: lead.estimatedValue || { minor: 1000000, currency: 'RUB' }, amount: lead.estimatedValue || { minor: 1000000, currency: 'RUB' },
probability: 60, probability: 60,
contactId: contact.id, contactId: contact.id,
companyId: companyId || contact.companyId,
assignedUserId: lead.assignedUserId,
assignedTo: lead.assignedTo || lead.assignedUserId,
}); });
// 3. Mark lead as converted // 5. Mark lead as converted
await this.leads.update(leadId, { await this.leads.update(leadId, {
status: 'converted', status: 'converted',
convertedContactId: contact.id, convertedContactId: contact.id,
convertedDealId: deal.id, convertedDealId: deal.id,
companyId: companyId || lead.companyId,
}); });
// 4. Add initial note to timeline // 6. Record note in deal timeline
await this.notes.create({ await this.notes.create({
targetEntityType: 'deal', targetEntityType: 'deal',
targetEntityId: deal.id, targetEntityId: deal.id,

File diff suppressed because one or more lines are too long