216 lines
8.3 KiB
TypeScript
216 lines
8.3 KiB
TypeScript
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);
|
||
});
|
||
});
|