From 42ac2f478acf7437e160ab953cda5ef778dd311b Mon Sep 17 00:00:00 2001 From: ochenstarik Date: Fri, 11 Sep 2026 15:17:16 +0700 Subject: [PATCH] feat(crm): end-to-end business integrity, idempotent conversion, and bidirectional order linking --- src/entities/commerce/order.ts | 1 + src/entities/crm/contact.ts | 3 +- src/entities/crm/deal.ts | 15 ++ src/entities/crm/lead.ts | 2 + .../__tests__/crmEndToEndIntegrity.test.tsx | 216 ++++++++++++++++++ src/features/crm/CrmModule.tsx | 111 ++++++--- .../crm/components/CrmEntityModals.tsx | 15 +- .../crm/components/DealWonOrderModal.tsx | 31 ++- src/services/contracts/crm.ts | 2 +- src/services/mock/crmService.ts | 124 ++++++++-- tsconfig.app.tsbuildinfo | 2 +- 11 files changed, 467 insertions(+), 55 deletions(-) create mode 100644 src/features/__tests__/crmEndToEndIntegrity.test.tsx diff --git a/src/entities/commerce/order.ts b/src/entities/commerce/order.ts index 3aab363..ecc44c1 100644 --- a/src/entities/commerce/order.ts +++ b/src/entities/commerce/order.ts @@ -45,4 +45,5 @@ export interface Order extends BaseEntity { channel: 'pos' | 'web' | 'mobile' | 'b2b' | 'manual'; shippingAddress?: string; notes?: string; + dealId?: string; } diff --git a/src/entities/crm/contact.ts b/src/entities/crm/contact.ts index 50b9b8a..cc9893e 100644 --- a/src/entities/crm/contact.ts +++ b/src/entities/crm/contact.ts @@ -6,7 +6,7 @@ export interface Contact extends BaseEntity { firstName: string; lastName: string; middleName?: string; - roles: ContactRole[]; + roles: (ContactRole | string)[]; companyId?: string; // Reference to Company jobTitle?: string; email?: string; @@ -14,5 +14,6 @@ export interface Contact extends BaseEntity { telegram?: string; avatarUrl?: string; notes?: string; + assignedTo?: string; status: 'active' | 'archived' | 'blocked'; } diff --git a/src/entities/crm/deal.ts b/src/entities/crm/deal.ts index 0dc991b..b3230d0 100644 --- a/src/entities/crm/deal.ts +++ b/src/entities/crm/deal.ts @@ -2,6 +2,18 @@ import { BaseEntity, MoneyAmount } from '../types'; 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 { title: string; pipelineId: string; @@ -13,5 +25,8 @@ export interface Deal extends BaseEntity { contactId?: string; companyId?: string; assignedUserId?: string; + assignedTo?: string; lostReason?: string; + orderId?: string; + items?: DealItem[]; } diff --git a/src/entities/crm/lead.ts b/src/entities/crm/lead.ts index 01f8a67..b19461e 100644 --- a/src/entities/crm/lead.ts +++ b/src/entities/crm/lead.ts @@ -6,12 +6,14 @@ export interface Lead extends BaseEntity { title: string; contactName: string; companyName?: string; + companyId?: string; phone?: string; email?: string; source: 'website' | 'phone' | 'referral' | 'telegram' | 'ads' | 'pos' | 'other'; status: LeadStatus; estimatedValue?: MoneyAmount; assignedUserId?: string; + assignedTo?: string; score?: number; // 0-100 convertedContactId?: string; convertedDealId?: string; diff --git a/src/features/__tests__/crmEndToEndIntegrity.test.tsx b/src/features/__tests__/crmEndToEndIntegrity.test.tsx new file mode 100644 index 0000000..2cf0890 --- /dev/null +++ b/src/features/__tests__/crmEndToEndIntegrity.test.tsx @@ -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); + }); +}); diff --git a/src/features/crm/CrmModule.tsx b/src/features/crm/CrmModule.tsx index 1cdd622..4bfe65a 100644 --- a/src/features/crm/CrmModule.tsx +++ b/src/features/crm/CrmModule.tsx @@ -8,6 +8,8 @@ import { Contact } from '../../entities/crm/contact'; import { Company } from '../../entities/crm/company'; import { Deal, DealStage } from '../../entities/crm/deal'; import { Lead } from '../../entities/crm/lead'; +import { OrderItem } from '../../entities/commerce/order'; +import { MoneyAmount } from '../../entities/types'; import { MaturityBadge } from '../../shared/ui/MaturityBadge'; import { useToast } from '../../shared/ui/useToast'; import { CsvImportWizard } from './CsvImportWizard'; @@ -41,7 +43,7 @@ export const CrmModule: React.FC = () => { createActivity, } = useCrm(); - const { orders, createOrder } = useOrders(); + const { orders, products, createOrder } = useOrders(); const contactMeta = useMetadata('contact'); const companyMeta = useMetadata('company'); @@ -74,15 +76,13 @@ export const CrmModule: React.FC = () => { const [selectedContact, setSelectedContact] = useState(null); const [selectedCompany, setSelectedCompany] = useState(null); - // Sync tab with URL on external route changes - const [prevPath, setPrevPath] = useState(location.pathname); - if (prevPath !== location.pathname) { - setPrevPath(location.pathname); - const p = location.pathname; - if (p.includes('/crm/deals')) setActiveTab('deals'); - else if (p.includes('/crm/leads')) setActiveTab('leads'); - else if (p.includes('/crm/companies')) setActiveTab('companies'); - else if (p.includes('/crm/import')) setActiveTab('import'); + // Synchronize active tab with URL path if navigated externally + const p = location.pathname; + if (p !== '/crm' && p !== '/crm/') { + if (p.includes('/crm/deals') && activeTab !== 'deals') setActiveTab('deals'); + else if (p.includes('/crm/leads') && activeTab !== 'leads') setActiveTab('leads'); + else if (p.includes('/crm/companies') && activeTab !== 'companies') setActiveTab('companies'); + else if (p.includes('/crm/import') && activeTab !== 'import') setActiveTab('import'); else if (p.includes('/crm/contacts') || p === '/crm') setActiveTab('contacts'); } @@ -101,40 +101,95 @@ export const CrmModule: React.FC = () => { }; // 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; try { const customer = contacts.find((c) => c.id === wonDeal.contactId); 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)}`; - 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, customerType: wonDeal.contactId ? 'contact' : 'company', - customerId: wonDeal.contactId || wonDeal.companyId || 'cnt_default', + customerId: wonDeal.contactId || wonDeal.companyId || (customer?.id ?? company?.id ?? 'cust_crm'), customerName, status: 'draft', - paymentStatus: 'pending', + paymentStatus: 'unpaid', fulfillmentStatus: 'unfulfilled', - items: [{ - id: `item_${ulid()}`, - productId: 'prd_coffee_beans', - sku: 'COF-100', - name: orderTitle || wonDeal.title, - quantity: 1, - unitPrice: wonDeal.amount, - discountMinor: 0, - totalPrice: wonDeal.amount, - }], - subtotal: wonDeal.amount, + items: orderItems, + subtotal: orderTotal, discountMinor: 0, - taxMinor: Math.round(wonDeal.amount.minor * 0.2), + taxMinor: Math.round(orderTotal.minor * 0.2), shippingMinor: 0, - total: wonDeal.amount, + total: orderTotal, + warehouseId: orderWarehouse, channel: 'b2b', + dealId: wonDeal.id, }); + // Two-way link: deal -> order + await updateDeal(wonDeal.id, { orderId: createdOrder.id }); + showToast(`Связанный Заказ ${orderNumber} успешно создан в Коммерции! (Склад: ${orderWarehouse})`, 'success'); setIsDealWonModalOpen(false); setWonDeal(null); diff --git a/src/features/crm/components/CrmEntityModals.tsx b/src/features/crm/components/CrmEntityModals.tsx index 65e72f3..e39f9c2 100644 --- a/src/features/crm/components/CrmEntityModals.tsx +++ b/src/features/crm/components/CrmEntityModals.tsx @@ -101,14 +101,25 @@ export const CrmEntityModals: React.FC = ({ layout={contactMeta.layouts?.[0] || null} mode="create" 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({ firstName: String(values.firstName || 'Новый'), lastName: String(values.lastName || 'Контакт'), + middleName: values.middleName ? String(values.middleName) : undefined, phone: String(values.phone || ''), email: String(values.email || ''), jobTitle: String(values.jobTitle || ''), - roles: ['client'], - status: 'active', + companyId: values.companyId ? String(values.companyId) : undefined, + 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, }); onCloseContact(); diff --git a/src/features/crm/components/DealWonOrderModal.tsx b/src/features/crm/components/DealWonOrderModal.tsx index 4002749..4f1c132 100644 --- a/src/features/crm/components/DealWonOrderModal.tsx +++ b/src/features/crm/components/DealWonOrderModal.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { Deal } from '../../../entities/crm/deal'; +import { OrderItem } from '../../../entities/commerce/order'; import { Modal } from '../../../shared/ui/Modal'; import { Button } from '../../../shared/ui/Button'; import { formatCurrencyMinor } from '../../../shared/lib/currency'; @@ -9,7 +10,11 @@ export interface DealWonOrderModalProps { isOpen: boolean; wonDeal: Deal | null; onClose: () => void; - onConfirmOrder: (params: { orderTitle: string; orderWarehouse: string }) => Promise | void; + onConfirmOrder: (params: { + orderTitle: string; + orderWarehouse: string; + items?: OrderItem[]; + }) => Promise | void; } export const DealWonOrderModal: React.FC = ({ @@ -42,7 +47,7 @@ export const DealWonOrderModal: React.FC = ({
Поздравляем с успехом! Сделка «{wonDeal?.title}» переведена в статус «Выиграна». - Система предлагает автоматически сформировать коммерческий Заказ. + Система предлагает автоматически сформировать коммерческий Заказ с двусторонней связью.
@@ -60,13 +65,33 @@ export const DealWonOrderModal: React.FC = ({
{wonDeal ? formatCurrencyMinor(wonDeal.amount.minor) : '0 ₽'}
+ {wonDeal?.items && wonDeal.items.length > 0 && ( +
+ +
+ {wonDeal.items.map((item, idx) => ( +
+ + {item.name} × {item.quantity} шт. + + + {formatCurrencyMinor(item.totalPrice?.minor ?? item.unitPrice.minor * item.quantity)} + +
+ ))} +
+
+ )} +