feat(crm): end-to-end business integrity, idempotent conversion, and bidirectional order linking
This commit is contained in:
parent
21fabde520
commit
42ac2f478a
11 changed files with 467 additions and 55 deletions
|
|
@ -45,4 +45,5 @@ export interface Order extends BaseEntity {
|
|||
channel: 'pos' | 'web' | 'mobile' | 'b2b' | 'manual';
|
||||
shippingAddress?: string;
|
||||
notes?: string;
|
||||
dealId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
216
src/features/__tests__/crmEndToEndIntegrity.test.tsx
Normal file
216
src/features/__tests__/crmEndToEndIntegrity.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Contact | null>(null);
|
||||
const [selectedCompany, setSelectedCompany] = useState<Company | null>(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);
|
||||
|
|
|
|||
|
|
@ -101,14 +101,25 @@ export const CrmEntityModals: React.FC<CrmEntityModalsProps> = ({
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -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> | void;
|
||||
onConfirmOrder: (params: {
|
||||
orderTitle: string;
|
||||
orderWarehouse: string;
|
||||
items?: OrderItem[];
|
||||
}) => Promise<void> | void;
|
||||
}
|
||||
|
||||
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="p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-800 dark:text-emerald-300">
|
||||
<strong>Поздравляем с успехом!</strong> Сделка «{wonDeal?.title}» переведена в статус «Выиграна».
|
||||
Система предлагает автоматически сформировать коммерческий Заказ.
|
||||
Система предлагает автоматически сформировать коммерческий Заказ с двусторонней связью.
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
|
@ -60,13 +65,33 @@ export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
|
|||
|
||||
<div>
|
||||
<label className="block font-semibold text-krona-primary mb-1">
|
||||
Сумма заказа (RUB):
|
||||
Сумма заказа:
|
||||
</label>
|
||||
<div className="font-bold text-sm text-krona-green py-1">
|
||||
{wonDeal ? formatCurrencyMinor(wonDeal.amount.minor) : '0 ₽'}
|
||||
</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>
|
||||
<label className="block font-semibold text-krona-primary mb-1">
|
||||
Склад отгрузки:
|
||||
|
|
|
|||
|
|
@ -41,5 +41,5 @@ export interface ICrmService {
|
|||
activities: IRepository<Activity>;
|
||||
notes: IRepository<Note>;
|
||||
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 }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
const dealsRes = await this.deals.list({
|
||||
filter: entityType === 'contact' ? { contactId: entityId } : { companyId: entityId },
|
||||
|
|
@ -68,7 +68,29 @@ export class MockCrmService implements ICrmService {
|
|||
type: 'deal_created',
|
||||
title: `Сделка: ${d.title}`,
|
||||
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));
|
||||
}
|
||||
|
||||
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);
|
||||
if (!lead) {
|
||||
throw new Error(`Лид с ID ${leadId} не найден.`);
|
||||
}
|
||||
|
||||
// 1. Create or match contact
|
||||
const parts = (lead.contactName || 'Клиент').split(' ');
|
||||
const firstName = parts[0];
|
||||
const lastName = parts.slice(1).join(' ') || 'Клиент';
|
||||
// 1. Idempotency: if already converted, return existing records without duplicating
|
||||
if (lead.status === 'converted' && lead.convertedDealId) {
|
||||
const existingDeal = await this.deals.getById(lead.convertedDealId);
|
||||
const existingContact = lead.convertedContactId
|
||||
? await this.contacts.getById(lead.convertedContactId)
|
||||
: undefined;
|
||||
|
||||
const contact = await this.contacts.create({
|
||||
firstName,
|
||||
lastName,
|
||||
phone: lead.phone,
|
||||
email: lead.email,
|
||||
roles: ['client'],
|
||||
status: 'active',
|
||||
});
|
||||
if (existingDeal) {
|
||||
return { deal: existingDeal, contact: existingContact || undefined };
|
||||
}
|
||||
}
|
||||
|
||||
// 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({
|
||||
title: dealTitle || lead.title,
|
||||
title: dealTitle?.trim() || lead.title || `Сделка: ${lead.contactName}`,
|
||||
pipelineId: 'pipe_default',
|
||||
stage: 'proposal',
|
||||
amount: lead.estimatedValue || { minor: 1000000, currency: 'RUB' },
|
||||
probability: 60,
|
||||
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, {
|
||||
status: 'converted',
|
||||
convertedContactId: contact.id,
|
||||
convertedDealId: deal.id,
|
||||
companyId: companyId || lead.companyId,
|
||||
});
|
||||
|
||||
// 4. Add initial note to timeline
|
||||
// 6. Record note in deal timeline
|
||||
await this.notes.create({
|
||||
targetEntityType: 'deal',
|
||||
targetEntityId: deal.id,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue