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';
|
channel: 'pos' | 'web' | 'mobile' | 'b2b' | 'manual';
|
||||||
shippingAddress?: string;
|
shippingAddress?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
dealId?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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 { 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);
|
|
||||||
if (prevPath !== location.pathname) {
|
|
||||||
setPrevPath(location.pathname);
|
|
||||||
const p = location.pathname;
|
const p = location.pathname;
|
||||||
if (p.includes('/crm/deals')) setActiveTab('deals');
|
if (p !== '/crm' && p !== '/crm/') {
|
||||||
else if (p.includes('/crm/leads')) setActiveTab('leads');
|
if (p.includes('/crm/deals') && activeTab !== 'deals') setActiveTab('deals');
|
||||||
else if (p.includes('/crm/companies')) setActiveTab('companies');
|
else if (p.includes('/crm/leads') && activeTab !== 'leads') setActiveTab('leads');
|
||||||
else if (p.includes('/crm/import')) setActiveTab('import');
|
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');
|
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[];
|
||||||
orderNumber,
|
if (customItems && customItems.length > 0) {
|
||||||
customerType: wonDeal.contactId ? 'contact' : 'company',
|
orderItems = customItems;
|
||||||
customerId: wonDeal.contactId || wonDeal.companyId || 'cnt_default',
|
} else if (wonDeal.items && wonDeal.items.length > 0) {
|
||||||
customerName,
|
orderItems = wonDeal.items.map((item) => ({
|
||||||
status: 'draft',
|
id: item.id || `item_${ulid()}`,
|
||||||
paymentStatus: 'pending',
|
productId: item.productId,
|
||||||
fulfillmentStatus: 'unfulfilled',
|
sku: item.sku || `SKU-${item.productId.slice(-4)}`,
|
||||||
items: [{
|
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()}`,
|
id: `item_${ulid()}`,
|
||||||
productId: 'prd_coffee_beans',
|
productId: prod.id,
|
||||||
sku: 'COF-100',
|
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,
|
name: orderTitle || wonDeal.title,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitPrice: wonDeal.amount,
|
unitPrice: wonDeal.amount,
|
||||||
discountMinor: 0,
|
discountMinor: 0,
|
||||||
totalPrice: wonDeal.amount,
|
totalPrice: wonDeal.amount,
|
||||||
}],
|
},
|
||||||
subtotal: 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 || (customer?.id ?? company?.id ?? 'cust_crm'),
|
||||||
|
customerName,
|
||||||
|
status: 'draft',
|
||||||
|
paymentStatus: 'unpaid',
|
||||||
|
fulfillmentStatus: 'unfulfilled',
|
||||||
|
items: orderItems,
|
||||||
|
subtotal: orderTotal,
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
Склад отгрузки:
|
Склад отгрузки:
|
||||||
|
|
|
||||||
|
|
@ -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 }>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 existingContact = lead.convertedContactId
|
||||||
|
? await this.contacts.getById(lead.convertedContactId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (existingDeal) {
|
||||||
|
return { deal: existingDeal, contact: existingContact || undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(' ') || 'Клиент';
|
const lastName = parts.slice(1).join(' ') || 'Клиент';
|
||||||
|
|
||||||
const contact = await this.contacts.create({
|
contact = await this.contacts.create({
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
phone: lead.phone,
|
phone: lead.phone,
|
||||||
email: lead.email,
|
email: lead.email,
|
||||||
roles: ['client'],
|
companyId,
|
||||||
|
roles: ['client', 'lead'],
|
||||||
|
assignedTo: lead.assignedTo || lead.assignedUserId,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
});
|
});
|
||||||
|
} else if (companyId && !contact.companyId) {
|
||||||
|
await this.contacts.update(contact.id, { companyId });
|
||||||
|
contact = (await this.contacts.getById(contact.id)) || contact;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Create deal
|
// 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
Loading…
Reference in a new issue