# ЯДРО БИЗНЕС-ПРАВИЛ СИСТЕМЫ SFERA ## 🎯 ОСНОВНЫЕ ПРИНЦИПЫ СИСТЕМЫ ### 1. ПРИНЦИП ДОСТУПА К ДАННЫМ **Правило изоляции организаций:** - ✅ **FULFILLMENT**: Полный доступ к своим операциям и складам - ✅ **SELLER**: Доступ только к своим данным, НЕТ доступа к чужим данным - ✅ **WHOLESALE**: Доступ к своим товарам и заказам - ✅ **LOGIST**: Доступ к назначенным маршрутам доставки **Правило видимости:** ```typescript // В resolvers.ts найдено правило: const hasAccess = organization.users.some((user) => user.id === context.user!.id) if (!hasAccess) { throw new GraphQLError('Нет доступа к этой организации') } ``` ### 2. ПРИНЦИП ПАРТНЕРСТВА **Система заявок на партнерство:** - Статусы: `PENDING` → `ACCEPTED` | `REJECTED` | `CANCELLED` - Автоматическое создание складских записей при принятии партнерства - Контрагенты видят только товары/услуги партнеров **Автоматическое партнерство:** ```typescript // Из кода: автоматическое создание записей склада (реальная реализация) const autoCreateWarehouseEntry = async (sellerId: string, fulfillmentId: string) => { console.warn(`🏗️ AUTO WAREHOUSE ENTRY: Creating for seller ${sellerId} with fulfillment ${fulfillmentId}`) // Получаем данные селлера const sellerOrg = await prisma.organization.findUnique({ where: { id: sellerId }, }) if (!sellerOrg) { throw new Error(`Селлер с ID ${sellerId} не найден`) } // ЛОГИКА ОПРЕДЕЛЕНИЯ НАЗВАНИЯ МАГАЗИНА let storeName = sellerOrg.name if (sellerOrg.fullName && sellerOrg.name?.includes('ИП')) { // Извлекаем название из скобок: "ИП Антипова Д. В. (Renrel)" -> "Renrel" const match = sellerOrg.fullName.match(/\(([^)]+)\)/) if (match && match[1]) { storeName = match[1] } } // Создаем структуру данных для склада const warehouseEntry = { id: `warehouse_${sellerId}_${Date.now()}`, storeName: storeName || sellerOrg.fullName || sellerOrg.name, storeOwner: sellerOrg.inn || sellerOrg.fullName || sellerOrg.name, storeImage: sellerOrg.logoUrl || null, storeQuantity: 0, partnershipDate: new Date(), products: [], } return warehouseEntry } ``` ### 3. ПРИНЦИП ТИПИЗАЦИИ РАСХОДНИКОВ **Два независимых типа расходников:** #### FULFILLMENT_CONSUMABLES (Расходники фулфилмента) - **Назначение**: Операционные нужды фулфилмента - **Владелец**: Фулфилмент - **Заказчик**: Фулфилмент заказывает у поставщиков - **Использование**: Внутренние операции фулфилмента #### SELLER_CONSUMABLES (Расходники селлеров) - **Назначение**: Расходники селлеров на хранении - **Владелец**: Селлер - **Место хранения**: Склад фулфилмента - **Использование**: В рецептурах продуктов селлера ## 🔄 WORKFLOW ПРАВИЛА ### СИСТЕМА СТАТУСОВ ПОСТАВОК **8-статусная система (из GraphQL enum):** ```typescript enum SupplyOrderStatus { PENDING // Ожидает одобрения поставщика SUPPLIER_APPROVED // Поставщик одобрил, ожидает логистику LOGISTICS_CONFIRMED // Логистика подтвердила, ожидает отправки SHIPPED // Отправлено поставщиком, в пути DELIVERED // Доставлено и принято фулфилментом CANCELLED // Отменено (любой участник может отменить) // Legacy статусы (для обратной совместимости): CONFIRMED // Устаревший IN_TRANSIT // Устаревший } ``` **Правила переходов статусов:** - `PENDING` → `SUPPLIER_APPROVED` (действие поставщика) - `SUPPLIER_APPROVED` → `LOGISTICS_CONFIRMED` (действие логистики) - `LOGISTICS_CONFIRMED` → `SHIPPED` (действие поставщика) - `SHIPPED` → `DELIVERED` (действие фулфилмента) - Любой статус → `CANCELLED` (любой участник) ### ПРАВИЛА РОЛЕЙ В ПОСТАВКАХ **Из кода resolvers.ts найдены правила доступа:** #### Для ПОСТАВЩИКОВ (WHOLESALE): ```typescript // Входящие заказы для поставщиков - требуют подтверждения const incomingSupplierOrders = await prisma.supplyOrder.count({ where: { partnerId: currentUser.organization.id, // Мы - поставщик status: 'PENDING', // Ожидает подтверждения от поставщика }, }) ``` #### Для ЛОГИСТИКИ (LOGIST): ```typescript // Логистические заявки для логистики - требуют действий (реальный код) const logisticsOrders = await prisma.supplyOrder.count({ where: { logisticsPartnerId: currentUser.organization.id, // Мы - назначенная логистика status: { in: [ 'CONFIRMED', // Legacy: Подтверждено фулфилментом - нужно подтвердить логистикой 'SUPPLIER_APPROVED', // Подтверждено поставщиком - нужно подтвердить логистикой 'LOGISTICS_CONFIRMED', // Подтверждено логистикой - нужно забрать товар у поставщика ], }, }, }) ``` #### Для ФУЛФИЛМЕНТА: ```typescript // Фулфилмент получает счетчики по типу организации (реальный код) if (currentUser.organization.type === 'FULFILLMENT') { pendingSupplyOrders = ourSupplyOrders + sellerSupplyOrders // Комбинированный счетчик // ourSupplyOrders: собственные заказы расходников ФФ // sellerSupplyOrders: заказы товаров от селлеров (где ФФ - получатель) } else if (currentUser.organization.type === 'WHOLESALE') { pendingSupplyOrders = incomingSupplierOrders // Входящие заказы для подтверждения } else if (currentUser.organization.type === 'LOGIST') { pendingSupplyOrders = logisticsOrders // Логистические задачи } ``` ## 📊 ПРАВИЛА РЕЦЕПТУР ПРОДУКТОВ **Структура рецептуры (из GraphQL schema):** ```typescript type ProductRecipe { services: [Service!]! // Услуги фулфилмента fulfillmentConsumables: [Supply!]! // Расходники фулфилмента sellerConsumables: [Supply!]! // Расходники селлера marketplaceCardId: String // Связь с карточкой маркетплейса } ``` **Экономические правила рецептур:** - Когда селлер выбирает расходники фулфилмента → формируется экономика: - В кабинете селлера: расход на расходники фулфилмента - В кабинете фулфилмента: доход от продажи расходников селлеру ## 🔐 ПРАВИЛА БЕЗОПАСНОСТИ ### JWT Токены - Срок действия: 30 дней - Payload: `{ userId, phone }` - Обязательная проверка принадлежности к организации ### Валидация доступа к данным ```typescript // Проверка принадлежности пользователя к организации const currentUser = await prisma.user.findUnique({ where: { id: context.user.id }, include: { organization: true }, }) if (!currentUser?.organization) { throw new GraphQLError('У пользователя нет организации') } // Проверка доступа к конкретной организации (из реального кода) const hasAccess = organization.users.some((user) => user.id === context.user!.id) if (!hasAccess) { throw new GraphQLError('Нет доступа к этой организации', { extensions: { code: 'FORBIDDEN' }, }) } ``` ### Правила доступа по типам организаций (примеры из кода): ```typescript // Только фулфилмент может управлять услугами if (currentUser.organization.type !== 'FULFILLMENT') { throw new GraphQLError('Услуги доступны только для фулфилмент центров') } // Только поставщики могут управлять каталогом товаров if (currentUser.organization.type !== 'WHOLESALE') { throw new GraphQLError('Товары доступны только для поставщиков') } // Фулфилмент имеет доступ к складским операциям if (currentUser.organization.type !== 'FULFILLMENT') { throw new GraphQLError('Товары склада доступны только для фулфилмент центров') } // Обновление цен расходников - только для ФФ if (currentUser.organization.type !== 'FULFILLMENT') { throw new GraphQLError('Обновление цен расходников доступно только для фулфилмент центров') } ``` ## 🎛️ ПРАВИЛА ИНТЕГРАЦИЙ С МАРКЕТПЛЕЙСАМИ ### API Ключи - Поддержка: Wildberries, Ozon - Валидация при добавлении - Безопасное хранение в БД ### Кеширование данных - Складские данные WB: кеш с TTL - Статистика продаж: кеш по периодам - Обновление по требованию ## 🔄 ПРАВИЛА РЕФЕРАЛЬНОЙ СИСТЕМЫ ### Генерация реферальных кодов (из реального кода): ```typescript // Алгоритм генерации уникального реферального кода const generateReferralCode = async (): Promise => { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // Исключены похожие символы let attempts = 0 const maxAttempts = 10 while (attempts < maxAttempts) { let code = '' for (let i = 0; i < 10; i++) { // 10-символьный код code += chars.charAt(Math.floor(Math.random() * chars.length)) } // Проверяем уникальность в БД const existing = await prisma.organization.findUnique({ where: { referralCode: code }, }) if (!existing) { return code } attempts++ } // Fallback если не удалось сгенерировать return `REF${Date.now()}${Math.random().toString(36).substr(2, 5).toUpperCase()}` } ``` ### Автоматические начисления: ```typescript // При регистрации по реферальной ссылке (реальный код из resolvers.ts:2930-2965) if (referralCode) { const referrer = await prisma.organization.findUnique({ where: { referralCode: referralCode }, }) if (referrer) { // Создаем реферальную транзакцию (100 сфер) await prisma.referralTransaction.create({ data: { referrerId: referrer.id, referralId: organization.id, points: 100, type: 'REGISTRATION', description: `Регистрация ${type.toLowerCase()} организации по реферальной ссылке`, }, }) // Увеличиваем счетчик сфер у реферера await prisma.organization.update({ where: { id: referrer.id }, data: { referralPoints: { increment: 100 } }, }) // Устанавливаем связь реферала await prisma.organization.update({ where: { id: organization.id }, data: { referredById: referrer.id }, }) } } // Партнерские коды (дополнительная система) if (partnerCode) { const partner = await prisma.organization.findUnique({ where: { referralCode: partnerCode }, }) if (partner) { // Создаем партнерскую транзакцию (100 сфер) await prisma.referralTransaction.create({ data: { referrerId: partner.id, referralId: organization.id, points: 100, type: 'AUTO_PARTNERSHIP', description: `Автопартнерство с ${type.toLowerCase()} организацией`, }, }) // Обновляем баланс партнера await prisma.organization.update({ where: { id: partner.id }, data: { referralPoints: { increment: 100 } }, }) } } ``` ### Типы начислений (из реальных транзакций): - `REGISTRATION` - регистрация по реферальной ссылке (100 баллов) - `AUTO_PARTNERSHIP` - автоматическое деловое партнерство (100 баллов) - `FIRST_ORDER` - первый заказ реферала - `MONTHLY_BONUS` - ежемесячные бонусы за активность ## 💰 ПРАВИЛА ЭКОНОМИЧЕСКОЙ МОДЕЛИ ### Система баланса организаций ```typescript // Структура баланса в Organization model { balance: number, // Основной баланс в рублях referralPoints: number, // Реферальные баллы ("сферы") creditLimit?: number, // Кредитный лимит paymentMethods: Json // Методы оплаты } ``` ### Автоматические транзакции ```typescript // Пример создания транзакции с обновлением баланса (из реального кода) const createBalanceTransaction = async ( organizationId: string, amount: number, type: TransactionType, description: string, relatedEntityId?: string, ) => { const org = await prisma.organization.findUnique({ where: { id: organizationId }, }) const newBalance = org.balance + amount // Атомарная операция: создание транзакции + обновление баланса await prisma.$transaction([ prisma.transaction.create({ data: { id: `txn_${type.toLowerCase()}_${Date.now()}`, organizationId, type, amount, description, relatedEntityId, status: 'COMPLETED', createdAt: new Date(), balanceAfter: newBalance, }, }), prisma.organization.update({ where: { id: organizationId }, data: { balance: newBalance }, }), ]) } ``` ## 📋 ПРАВИЛА ВАЛИДАЦИИ ДОСТУПА ПО РОЛЯМ ### Системы проверки прав (расширенные примеры): ```typescript // 1. Проверка принадлежности к организации (базовая) const validateUserOrganizationAccess = async (userId: string, organizationId: string) => { const organization = await prisma.organization.findUnique({ where: { id: organizationId }, include: { users: true }, }) const hasAccess = organization.users.some((user) => user.id === userId) if (!hasAccess) { throw new GraphQLError('Нет доступа к этой организации', { extensions: { code: 'FORBIDDEN' }, }) } return organization } // 2. Проверка доступа по типу операции (из реального кода) const validateOperationAccess = (userOrgType: string, operation: string) => { const accessRules = { FULFILLMENT: [ 'manage_services', // Управление услугами ФФ 'manage_consumables', // Управление расходниками ФФ 'view_warehouse', // Просмотр склада 'manage_warehouse', // Управление складом 'receive_orders', // Прием заказов от селлеров 'update_consumable_prices', // Обновление цен расходников ], SELLER: [ 'view_own_supplies', // Просмотр своих поставок 'create_supply_orders', // Создание заказов поставок 'manage_recipes', // Управление рецептурами продуктов 'view_partner_services', // Просмотр услуг партнеров-ФФ ], WHOLESALE: [ 'manage_products', // Управление каталогом товаров 'approve_orders', // Подтверждение заказов от селлеров 'update_product_prices', // Обновление цен товаров 'view_incoming_orders', // Просмотр входящих заказов ], LOGIST: [ 'view_assigned_routes', // Просмотр назначенных маршрутов 'confirm_logistics', // Подтверждение логистики 'update_delivery_status', // Обновление статуса доставки 'manage_routes', // Управление маршрутами ], } if (!accessRules[userOrgType]?.includes(operation)) { throw new GraphQLError(`Операция ${operation} недоступна для ${userOrgType}`) } } // 3. Проверка доступа к данным партнеров const validatePartnerAccess = async (userOrgId: string, targetOrgId: string) => { const partnership = await prisma.organizationPartner.findFirst({ where: { organizationId: userOrgId, partnerId: targetOrgId, }, }) if (!partnership) { throw new GraphQLError('Доступ разрешен только к данным партнеров') } } ``` ## 🔄 ПРАВИЛА СТАТУСНЫХ ПЕРЕХОДОВ (ДЕТАЛИЗАЦИЯ) ### Бизнес-логика переходов статусов: ```typescript // Правила изменения статуса поставки (из реального workflow) const validateStatusTransition = ( currentStatus: SupplyOrderStatus, newStatus: SupplyOrderStatus, userOrgType: string, userOrgId: string, order: SupplyOrder ) => { const allowedTransitions = { 'PENDING': { 'SUPPLIER_APPROVED': { allowedBy: ['WHOLESALE'], condition: (order) => order.partnerId === userOrgId // Только поставщик-получатель заказа }, 'CANCELLED': { allowedBy: ['SELLER', 'FULFILLMENT', 'WHOLESALE'], // Любой участник может отменить condition: () => true } }, 'SUPPLIER_APPROVED': { 'LOGISTICS_CONFIRMED': { allowedBy: ['LOGIST'], condition: (order) => order.logisticsPartnerId === userOrgId // Только назначенная логистика }, 'CANCELLED': { allowedBy: ['SELLER', 'FULFILLMENT', 'WHOLESALE', 'LOGIST'], condition: () => true } }, 'LOGISTICS_CONFIRMED': { 'SHIPPED': { allowedBy: ['WHOLESALE'], condition: (order) => order.partnerId === userOrgId // Только поставщик отправляет }, 'CANCELLED': { allowedBy: ['SELLER', 'FULFILLMENT', 'WHOLESALE', 'LOGIST'], condition: () => true } }, 'SHIPPED': { 'DELIVERED': { allowedBy: ['FULFILLMENT'], condition: (order) => order.fulfillmentCenterId === userOrgId // Только получающий ФФ } } } const transition = allowedTransitions[currentStatus]?.[newStatus] if (!transition) { throw new GraphQLError(`Переход ${currentStatus} → ${newStatus} недопустим`) } if (!transition.allowedBy.includes(userOrgType)) { throw new GraphQLError(`Организация типа ${userOrgType} не может выполнить переход ${currentStatus} → ${newStatus}`) } if (!transition.condition(order)) { throw new GraphQLError('Условия для перехода статуса не выполнены') } } --- *Извлечено из анализа: GraphQL resolvers, Prisma models, бизнес-логика* *Дата: 2025-08-21* ```