# СИСТЕМА ПАРТНЕРСТВА ## 📋 ОБЗОР Система партнерства в SFERA реализует механизм установления деловых отношений между различными типами организаций через систему запросов и автоматическую интеграцию после принятия. ## 🔧 АРХИТЕКТУРА СИСТЕМЫ ### Сущности партнерства ```typescript // Запрос на партнерство (Prisma модель) model CounterpartyRequest { id String @id @default(cuid()) fromId String // Кто отправляет запрос toId String // Кому отправляется запрос status RequestStatus message String? // Сообщение к запросу createdAt DateTime @default(now()) updatedAt DateTime @updatedAt from Organization @relation("RequestFrom", fields: [fromId], references: [id]) to Organization @relation("RequestTo", fields: [toId], references: [id]) } enum RequestStatus { PENDING // Ожидает ответа ACCEPTED // Принят REJECTED // Отклонен CANCELLED // Отменен отправителем } ``` ## 🎯 ЖИЗНЕННЫЙ ЦИКЛ ЗАПРОСА ПАРТНЕРСТВА ### 1. Отправка запроса ```typescript // Мутация: sendCounterpartyRequest const sendCounterpartyRequest = async (parent, { counterpartyId, message }, { user, prisma }) => { // 1. Проверяем существование получателя const targetOrganization = await prisma.organization.findUnique({ where: { id: counterpartyId }, }) if (!targetOrganization) { throw new Error('Организация не найдена') } // 2. Проверяем, что не отправляем запрос самому себе if (user.organizationId === counterpartyId) { throw new Error('Нельзя отправить запрос самому себе') } // 3. Проверяем существующие запросы const existingRequest = await prisma.counterpartyRequest.findFirst({ where: { OR: [ { fromId: user.organizationId, toId: counterpartyId }, { fromId: counterpartyId, toId: user.organizationId }, ], status: { in: ['PENDING', 'ACCEPTED'] }, }, }) if (existingRequest) { if (existingRequest.status === 'ACCEPTED') { throw new Error('Партнерство уже установлено') } else { throw new Error('Запрос уже отправлен') } } // 4. Создаем новый запрос return await prisma.counterpartyRequest.create({ data: { fromId: user.organizationId, toId: counterpartyId, status: 'PENDING', message: message || null, }, include: { from: true, to: true, }, }) } ``` ### 2. Обработка запроса ```typescript // Мутация: respondToCounterpartyRequest const respondToCounterpartyRequest = async (parent, { requestId, accept }, { user, prisma }) => { const request = await prisma.counterpartyRequest.findUnique({ where: { id: requestId }, include: { from: true, to: true }, }) if (!request) { throw new Error('Запрос не найден') } // Проверяем права на ответ if (request.toId !== user.organizationId) { throw new Error('Нет прав для ответа на этот запрос') } if (request.status !== 'PENDING') { throw new Error('Запрос уже обработан') } const newStatus = accept ? 'ACCEPTED' : 'REJECTED' // Обновляем статус запроса const updatedRequest = await prisma.counterpartyRequest.update({ where: { id: requestId }, data: { status: newStatus }, include: { from: true, to: true }, }) // Если принят - устанавливаем партнерство if (accept) { await establishPartnership(request.from, request.to, prisma) } return updatedRequest } ``` ### 3. Отмена запроса ```typescript // Мутация: cancelCounterpartyRequest const cancelCounterpartyRequest = async (parent, { requestId }, { user, prisma }) => { const request = await prisma.counterpartyRequest.findUnique({ where: { id: requestId }, }) if (!request) { throw new Error('Запрос не найден') } // Только отправитель может отменить if (request.fromId !== user.organizationId) { throw new Error('Нет прав для отмены запроса') } if (request.status !== 'PENDING') { throw new Error('Можно отменить только ожидающие запросы') } return await prisma.counterpartyRequest.update({ where: { id: requestId }, data: { status: 'CANCELLED' }, }) } ``` ## 🤝 УСТАНОВЛЕНИЕ ПАРТНЕРСТВА ### Автоматическое создание связей ```typescript const establishPartnership = async (org1, org2, prisma) => { // Создаем взаимные связи партнерства await prisma.organizationPartner.createMany({ data: [ { organizationId: org1.id, partnerId: org2.id, }, { organizationId: org2.id, partnerId: org1.id, }, ], }) // Специальная логика для FULFILLMENT + SELLER if (shouldCreateWarehouseEntry(org1, org2)) { const [fulfillment, seller] = identifyRoles(org1, org2) await createWarehouseEntry(seller, fulfillment, prisma) } } const shouldCreateWarehouseEntry = (org1, org2) => { const types = [org1.type, org2.type].sort() return types[0] === 'FULFILLMENT' && types[1] === 'SELLER' } const identifyRoles = (org1, org2) => { if (org1.type === 'FULFILLMENT') return [org1, org2] return [org2, org1] } ``` ### Создание записи склада ```typescript const createWarehouseEntry = async (seller, fulfillment, prisma) => { // Извлекаем название магазина из ИП формата let storeName = seller.name if (seller.fullName && seller.name?.includes('ИП')) { const match = seller.fullName.match(/\(([^)]+)\)/) if (match && match[1]) { storeName = match[1] } } const warehouseEntry = { id: `warehouse_${seller.id}_${Date.now()}`, storeName: storeName || seller.fullName || seller.name, storeOwner: seller.inn || seller.fullName || seller.name, storeImage: seller.logoUrl || null, storeQuantity: 0, partnershipDate: new Date(), products: [], } // Сохраняем в JSON поле склада фулфилмента await prisma.organization.update({ where: { id: fulfillment.id }, data: { warehouseData: { ...fulfillment.warehouseData, stores: [...(fulfillment.warehouseData?.stores || []), warehouseEntry], }, }, }) } ``` ## 🎁 РЕФЕРАЛЬНАЯ СИСТЕМА ### Генерация реферального кода ```typescript const generateReferralCode = (organizationName, organizationId) => { // Берем первые 3 буквы названия (только кириллица/латиница) const cleanName = organizationName.replace(/[^а-яё\w]/gi, '') const prefix = cleanName.substring(0, 3).toUpperCase() // Добавляем последние 4 символа ID const suffix = organizationId.slice(-4).toUpperCase() return `${prefix}${suffix}` } ``` ### Автопартнерство по реферальным кодам ```typescript // При регистрации через реферальный код const handleReferralRegistration = async (newOrganization, referralCode, prisma) => { if (!referralCode) return // Находим организацию по реферальному коду const referrer = await findByReferralCode(referralCode, prisma) if (!referrer) return // Автоматически устанавливаем партнерство await establishPartnership(newOrganization, referrer, prisma) // Создаем транзакцию AUTO_PARTNERSHIP await prisma.transaction.create({ data: { id: `txn_auto_partnership_${Date.now()}`, organizationId: referrer.id, type: 'AUTO_PARTNERSHIP', amount: 100, // Бонус за привлечение партнера description: `Автопартнерство с ${newOrganization.name}`, relatedEntityId: newOrganization.id, status: 'COMPLETED', createdAt: new Date(), balanceAfter: referrer.balance + 100, }, }) // Обновляем баланс реферера await prisma.organization.update({ where: { id: referrer.id }, data: { balance: { increment: 100 } }, }) } ``` ## 🔍 ЗАПРОСЫ И ФИЛЬТРАЦИЯ ### Получение запросов партнерства ```typescript // Query: counterpartyRequests const counterpartyRequests = async (parent, args, { user, prisma }) => { const { type = 'received', status } = args const where = { [type === 'sent' ? 'fromId' : 'toId']: user.organizationId, } if (status) { where.status = status } return await prisma.counterpartyRequest.findMany({ where, include: { from: true, to: true, }, orderBy: { createdAt: 'desc' }, }) } ``` ### Поиск потенциальных партнеров ```typescript const searchOrganizations = async (parent, { query, type, page = 1, limit = 20 }, { user, prisma }) => { // Исключаем свою организацию и уже существующих партнеров const excludeIds = [user.organizationId] const existingPartners = await prisma.organizationPartner.findMany({ where: { organizationId: user.organizationId }, select: { partnerId: true }, }) excludeIds.push(...existingPartners.map((p) => p.partnerId)) const where = { id: { notIn: excludeIds }, OR: [ { name: { contains: query, mode: 'insensitive' } }, { fullName: { contains: query, mode: 'insensitive' } }, { inn: { contains: query, mode: 'insensitive' } }, ], } if (type) { where.type = type } return await prisma.organization.findMany({ where, skip: (page - 1) * limit, take: limit, orderBy: { createdAt: 'desc' }, }) } ``` ## 📊 СТАТИСТИКА ПАРТНЕРСТВА ### Счетчики для UI ```typescript const getPartnershipStats = async (organizationId, prisma) => { // Количество активных партнеров const partnersCount = await prisma.organizationPartner.count({ where: { organizationId }, }) // Входящие запросы на рассмотрении const pendingRequests = await prisma.counterpartyRequest.count({ where: { toId: organizationId, status: 'PENDING', }, }) // Отправленные запросы в ожидании const sentRequests = await prisma.counterpartyRequest.count({ where: { fromId: organizationId, status: 'PENDING', }, }) return { partnersCount, pendingRequests, sentRequests, } } ``` ## 🎨 ИНТЕГРАЦИЯ С UI ### Уведомления в реальном времени ```typescript // Подписка на изменения запросов партнерства const counterpartyRequestUpdated = { subscribe: withFilter( () => pubsub.asyncIterator('COUNTERPARTY_REQUEST_UPDATED'), (payload, variables, context) => { // Уведомляем только заинтересованные организации return ( payload.counterpartyRequestUpdated.toId === context.user.organizationId || payload.counterpartyRequestUpdated.fromId === context.user.organizationId ) }, ), } ``` ### Компонент управления партнерством ```typescript // Пример использования в React компоненте const PartnershipManager = () => { const { data: requests } = useQuery(GET_COUNTERPARTY_REQUESTS) const [sendRequest] = useMutation(SEND_COUNTERPARTY_REQUEST) const [respondToRequest] = useMutation(RESPOND_TO_COUNTERPARTY_REQUEST) // Логика отправки запроса const handleSendRequest = async (partnerId: string, message?: string) => { await sendRequest({ variables: { counterpartyId: partnerId, message }, }) } // Логика ответа на запрос const handleResponse = async (requestId: string, accept: boolean) => { await respondToRequest({ variables: { requestId, accept }, }) } } ``` ## 🔒 ПРАВИЛА БЕЗОПАСНОСТИ ### Проверки доступа 1. **Отправка запроса**: только аутентифицированные пользователи 2. **Ответ на запрос**: только получатель может ответить 3. **Отмена запроса**: только отправитель может отменить 4. **Предотвращение дублирования**: проверка существующих запросов 5. **Самоисключение**: нельзя отправить запрос самому себе ### Валидация данных 1. **Существование организации**: проверка перед отправкой запроса 2. **Статус запроса**: можно отвечать только на PENDING запросы 3. **Права доступа**: проверка принадлежности к организации 4. **Целостность данных**: атомарные операции при установлении партнерства ## 📈 МЕТРИКИ И АНАЛИТИКА ### Ключевые показатели - **Коэффициент принятия**: процент принятых запросов - **Время ответа**: среднее время обработки запросов - **Активность партнерства**: количество операций между партнерами - **Эффективность рефералов**: процент автопартнерств от общего числа ### Отчеты - **Топ реферальных организаций**: по количеству привлеченных партнеров - **География партнерства**: распределение по регионам - **Тренды установления партнерства**: динамика по времени - **Конверсия запросов**: от отправки до установления связи