# ПРАВИЛА GRAPHQL СХЕМЫ СИСТЕМЫ SFERA ## 🎯 ОБЩИЕ ПРИНЦИПЫ СХЕМЫ ### 1. ТИПОБЕЗОПАСНОСТЬ - **Строгая типизация**: Все поля должны иметь четко определенный тип - **Обязательные поля**: Использование `!` для критичных данных - **Nullable поля**: Явное указание опциональности без `!` ### 2. КОНСИСТЕНТНОСТЬ ИМЕНОВАНИЯ ```typescript // ✅ Правильное именование type Organization { id: ID! // Всегда ID! для идентификаторов name: String // Nullable для опциональных данных createdAt: DateTime! // Обязательные временные метки } // ❌ Неправильное именование type organization { ... } // Должно быть PascalCase type User { user_id: String // Должно быть camelCase: userId } ``` ## 📋 ОСНОВНЫЕ ENUMS СИСТЕМЫ ### OrganizationType (Типы организаций) ```graphql enum OrganizationType { FULFILLMENT # Фулфилмент-центры SELLER # Селлеры (продавцы) LOGIST # Логистические компании WHOLESALE # Поставщики (оптовики) } ``` **Правила использования:** - ✅ Обязательное поле в модели Organization - ✅ Определяет доступные функции в UI - ✅ Используется для фильтрации в поиске контрагентов - ❌ Нельзя изменить тип существующей организации ### SupplyOrderStatus (Статусы поставок) ```graphql enum SupplyOrderStatus { PENDING # Ожидает одобрения поставщика SUPPLIER_APPROVED # Поставщик одобрил, ждет логистику LOGISTICS_CONFIRMED # Логистика подтвердила, ждет отправки SHIPPED # Отправлено поставщиком DELIVERED # Доставлено и принято CANCELLED # Отменено любым участником # Legacy статусы (обратная совместимость): CONFIRMED # → SUPPLIER_APPROVED IN_TRANSIT # → SHIPPED } ``` **Правила переходов:** - ✅ Только последовательные переходы - ✅ Любой статус → CANCELLED - ❌ Возврат к предыдущим статусам - ❌ Пропуск промежуточных статусов ### CounterpartyRequestStatus (Статусы заявок на партнерство) ```graphql enum CounterpartyRequestStatus { PENDING # Отправлена, ждет ответа ACCEPTED # Принята - партнерство активно REJECTED # Отклонена CANCELLED # Отменена отправителем } ``` ### MarketplaceType (Поддерживаемые маркетплейсы) ```graphql enum MarketplaceType { WILDBERRIES # WB API интеграция OZON # Ozon API интеграция } ``` ## 🔍 ПРАВИЛА QUERY ОПЕРАЦИЙ ### 1. ПОИСК И ФИЛЬТРАЦИЯ ```graphql # ✅ Правильная структура поиска searchOrganizations( type: OrganizationType # Фильтр по типу организации search: String # Текстовый поиск по имени/ИНН ): [Organization!]! # ✅ Пагинация для больших списков messages( counterpartyId: ID! limit: Int # Лимит записей offset: Int # Смещение ): [Message!]! ``` **Реальная реализация поиска организаций:** ```typescript // Из src/graphql/resolvers.ts searchOrganizations: async (_: unknown, args: { type?: string; search?: string }, context: Context) => { if (!context.user) { throw new GraphQLError('Требуется авторизация', { extensions: { code: 'UNAUTHENTICATED' }, }) } // Получаем текущую организацию пользователя const currentUser = await prisma.user.findUnique({ where: { id: context.user.id }, include: { organization: true }, }) if (!currentUser?.organization) { throw new GraphQLError('У пользователя нет организации') } // Строим фильтры поиска const where: Prisma.OrganizationWhereInput = { id: { not: currentUser.organization.id }, // Исключаем себя из результатов } // Фильтр по типу организации if (args.type) { where.type = args.type as OrganizationType } // Текстовый поиск по имени/ИНН if (args.search) { where.OR = [ { name: { contains: args.search, mode: 'insensitive' } }, { fullName: { contains: args.search, mode: 'insensitive' } }, { inn: { contains: args.search, mode: 'insensitive' } }, ] } return await prisma.organization.findMany({ where, take: 20, // Лимит результатов для производительности orderBy: { createdAt: 'desc' }, }) } ``` ### 2. ПРАВА ДОСТУПА В QUERIES ```graphql # ✅ Доступ к своим данным mySupplies: [Supply!]! # Только мои расходники myServices: [Service!]! # Только мои услуги myCounterparties: [Organization!]! # Только мои контрагенты # ✅ Доступ к данным контрагентов (с проверкой партнерства) counterpartyServices(organizationId: ID!): [Service!]! organizationProducts(organizationId: ID!): [Product!]! # ✅ Публичные данные (без ограничений) allProducts: [Product!]! categories: [Category!]! ``` ### 3. АГРЕГИРОВАННЫЕ ДАННЫЕ ```graphql # ✅ Счетчики для dashboard type PendingSuppliesCount { incomingSupplierOrders: Int! # Для поставщиков logisticsOrders: Int! # Для логистики ourSupplyOrders: Int! # Для фулфилмента sellerSupplyOrders: Int! # Заказы от селлеров } # ✅ Иерархические данные type WarehouseDataResponse { partners: [WarehousePartner!]! # 3-уровневая структура } ``` **Реальная реализация счетчиков (из pendingSuppliesCount):** ```typescript // Динамические счетчики в зависимости от типа организации pendingSuppliesCount: async (_: unknown, __: unknown, context: Context) => { const currentUser = await prisma.user.findUnique({ where: { id: context.user.id }, include: { organization: true }, }) // 🔔 ВХОДЯЩИЕ ЗАКАЗЫ ДЛЯ ПОСТАВЩИКОВ (WHOLESALE) const incomingSupplierOrders = await prisma.supplyOrder.count({ where: { partnerId: currentUser.organization.id, // Мы - поставщик status: 'PENDING', // Ожидает подтверждения от поставщика }, }) // 🚚 ЛОГИСТИЧЕСКИЕ ЗАЯВКИ ДЛЯ ЛОГИСТИКИ (LOGIST) const logisticsOrders = await prisma.supplyOrder.count({ where: { logisticsPartnerId: currentUser.organization.id, // Мы - назначенная логистика status: { in: [ 'CONFIRMED', // Legacy: подтверждено ФФ 'SUPPLIER_APPROVED', // Подтверждено поставщиком 'LOGISTICS_CONFIRMED', // Подтверждено логистикой - нужно забрать товар ], }, }, }) // 🏭 ЗАКАЗЫ ДЛЯ ФУЛФИЛМЕНТА const ourSupplyOrders = await prisma.supplyOrder.count({ where: { organizationId: currentUser.organization.id, // Наши собственные заказы status: { notIn: ['DELIVERED', 'CANCELLED'] }, }, }) const sellerSupplyOrders = await prisma.supplyOrder.count({ where: { fulfillmentCenterId: currentUser.organization.id, // Мы - получатели status: { notIn: ['DELIVERED', 'CANCELLED'] }, }, }) // Определяем приоритетный счетчик по типу организации let pendingSupplyOrders = 0 if (currentUser.organization.type === 'FULFILLMENT') { pendingSupplyOrders = ourSupplyOrders + sellerSupplyOrders } else if (currentUser.organization.type === 'WHOLESALE') { pendingSupplyOrders = incomingSupplierOrders } else if (currentUser.organization.type === 'LOGIST') { pendingSupplyOrders = logisticsOrders } return { incomingSupplierOrders, logisticsOrders, ourSupplyOrders, sellerSupplyOrders, pendingSupplyOrders, // Главный счетчик для UI } } ``` ## 🔄 ПРАВИЛА MUTATION ОПЕРАЦИЙ ### 1. СТРУКТУРА INPUT ТИПОВ ```graphql # ✅ Консистентное именование Input типов input CreateEmployeeInput { name: String! position: String! salary: Float # Обязательные поля с !, опциональные без } input UpdateEmployeeInput { name: String # В Update все поля опциональны position: String salary: Float } ``` ### 2. RESPONSE ТИПЫ ДЛЯ МУТАЦИЙ ```graphql # ✅ Стандартная структура Response type EmployeeResponse { success: Boolean! message: String employee: Employee # Данные при успехе errors: [String!] # Ошибки валидации } ``` ### 3. ПРАВИЛА АВТОРИЗАЦИИ В МУТАЦИЯХ ```graphql # ✅ Мутации требующие авторизации createSupplyOrder(input: SupplyOrderInput!): SupplyOrderResponse! # ✅ Мутации для конкретных ролей updateProductInWarehouse(...): Product! # Только FULFILLMENT approveSupplyOrder(...): SupplyOrder! # Только WHOLESALE # ✅ Административные мутации adminLogin(username: String!, password: String!): AdminAuthResponse! ``` **Реальная реализация createSupplyOrder с валидацией:** ```typescript // Из src/graphql/resolvers.ts createSupplyOrder: async ( _: unknown, args: { input: { partnerId: string deliveryDate: string fulfillmentCenterId?: string // ID фулфилмент-центра для доставки logisticsPartnerId?: string // ID логистической компании items: Array<{ productId: string quantity: number recipe?: { services?: string[] fulfillmentConsumables?: string[] sellerConsumables?: string[] marketplaceCardId?: string } }> } }, context: Context, ) => { if (!context.user) { throw new GraphQLError('Требуется авторизация', { extensions: { code: 'UNAUTHENTICATED' }, }) } const currentUser = await prisma.user.findUnique({ where: { id: context.user.id }, include: { organization: true }, }) if (!currentUser?.organization) { throw new GraphQLError('У пользователя нет организации') } // Проверка прав доступа по типу организации const allowedTypes = ['FULFILLMENT', 'SELLER', 'LOGIST'] if (!allowedTypes.includes(currentUser.organization.type)) { throw new GraphQLError('Заказы поставок недоступны для данного типа организации') } // Проверка существования поставщика const partner = await prisma.organization.findUnique({ where: { id: args.input.partnerId }, }) if (!partner || partner.type !== 'WHOLESALE') { throw new GraphQLError('Поставщик не найден или некорректный тип') } // Вычисляем общую стоимость и количество товаров let totalAmount = 0 let totalItems = 0 for (const item of args.input.items) { const product = await prisma.product.findUnique({ where: { id: item.productId }, }) if (!product) { throw new GraphQLError(`Товар с ID ${item.productId} не найден`) } totalAmount += Number(product.price) * item.quantity totalItems += item.quantity } // Создаем заказ поставки const supplyOrder = await prisma.supplyOrder.create({ data: { organizationId: currentUser.organization.id, partnerId: args.input.partnerId, fulfillmentCenterId: args.input.fulfillmentCenterId, logisticsPartnerId: args.input.logisticsPartnerId, deliveryDate: new Date(args.input.deliveryDate), totalAmount: totalAmount, totalItems: totalItems, status: 'PENDING', // Начальный статус // Создаем позиции заказа items: { create: args.input.items.map((item) => ({ productId: item.productId, quantity: item.quantity, price: product.price, totalPrice: Number(product.price) * item.quantity, // Сохраняем рецептуру если есть services: item.recipe?.services || [], fulfillmentConsumables: item.recipe?.fulfillmentConsumables || [], sellerConsumables: item.recipe?.sellerConsumables || [], marketplaceCardId: item.recipe?.marketplaceCardId, })), }, }, include: { partner: true, items: true, }, }) return { success: true, message: 'Заказ поставки успешно создан', order: supplyOrder, } } ``` ## 📊 ПРАВИЛА ТИПОВ ДАННЫХ ### 1. ОСНОВНЫЕ СКАЛЯРЫ ```graphql scalar DateTime # ISO 8601 формат scalar JSON # Гибкие данные (phones, emails, etc.) # ✅ Использование type Organization { createdAt: DateTime! # Всегда обязательно phones: JSON # Массив телефонов validationData: JSON # Данные валидации API } ``` **Реальная реализация custom скаляров:** ```typescript // DateTime скаляр для работы с датами (из src/graphql/resolvers.ts) const DateTimeScalar = new GraphQLScalarType({ name: 'DateTime', description: 'DateTime custom scalar type', // Сериализация: Date → ISO string (для клиента) serialize(value: unknown) { if (value instanceof Date) { return value.toISOString() // 2025-08-21T15:30:00.000Z } return value }, // Парсинг: ISO string → Date (от клиента) parseValue(value: unknown) { if (typeof value === 'string') { return new Date(value) // Парсим ISO строку в Date } throw new GraphQLError('Invalid DateTime format') }, // Парсинг литералов в запросах parseLiteral(ast) { if (ast.kind === Kind.STRING) { return new Date(ast.value) } throw new GraphQLError('Invalid DateTime literal') }, }) // JSON скаляр для гибких данных const JSONScalar = new GraphQLScalarType({ name: 'JSON', description: 'JSON custom scalar type', serialize(value: unknown) { return value // JSON как есть }, parseValue(value: unknown) { return value // Принимаем любые JSON данные }, parseLiteral(ast) { switch (ast.kind) { case Kind.STRING: case Kind.BOOLEAN: return ast.value case Kind.INT: case Kind.FLOAT: return Number(ast.value) case Kind.OBJECT: // Рекурсивный парсинг объектов const obj: Record = {} ast.fields.forEach((field) => { obj[field.name.value] = this.parseLiteral(field.value) }) return obj case Kind.LIST: return ast.values.map((value) => this.parseLiteral(value)) case Kind.NULL: return null default: throw new GraphQLError(`Unexpected kind: ${ast.kind}`) } }, }) ``` ### 2. СТРУКТУРА ОСНОВНЫХ ТИПОВ #### User (Пользователь) ```graphql type User { id: ID! phone: String! # Уникальный идентификатор avatar: String # Аватар (опционально) managerName: String # Имя менеджера organization: Organization # Связь с организацией createdAt: DateTime! updatedAt: DateTime! } ``` #### Organization (Организация) ```graphql type Organization { # Обязательные поля id: ID! inn: String! type: OrganizationType! # Реквизиты (могут быть пустыми) name: String fullName: String address: String # Связанные данные users: [User!]! apiKeys: [ApiKey!]! services: [Service!]! supplies: [Supply!]! # Партнерство isCounterparty: Boolean hasOutgoingRequest: Boolean hasIncomingRequest: Boolean # Реферальная система referralCode: String referralPoints: Int! # Marketplace данные market: String # Физический рынок (для WHOLESALE) # Временные метки createdAt: DateTime! updatedAt: DateTime! } ``` ## 🏪 СПЕЦИФИЧНЫЕ ПРАВИЛА ДЛЯ ПОСТАВЩИКОВ (WHOLESALE) ### ЗАПРОСЫ ПОСТАВЩИКОВ: ```graphql # Получение товаров поставщика query GetMyProducts { myProducts { id name article price quantity organization { id name market # Физический рынок поставщика } } } # Получение входящих заказов поставщика query GetSupplierOrders($status: SupplyOrderStatus) { supplyOrders(where: { partnerId: $myOrgId, status: $status }) { id status totalAmount deliveryDate organization { name inn } # Заказчик fulfillmentCenter { name address } # Получатель items { id quantity price totalPrice product { id name article } } } } # Получение партнеров поставщика query GetMyCounterparties($type: OrganizationType) { myCounterparties(type: $type) { id name type market fullName inn isCounterparty hasOutgoingRequest hasIncomingRequest } } ``` ### МУТАЦИИ ПОСТАВЩИКОВ: ```graphql # Одобрение заказа поставщиком с дополнительными параметрами поставки mutation SupplierApproveOrder( $orderId: ID! $packagesCount: Int $volume: Float $readyDate: DateTime $notes: String ) { supplierApproveOrder( id: $orderId packagesCount: $packagesCount # Параметр поставки: количество грузовых мест volume: $volume # Параметр поставки: объем груза readyDate: $readyDate # Параметр поставки: дата готовности к отгрузке notes: $notes # Параметр поставки: дополнительная информация ) { success message order { id status # PENDING → SUPPLIER_APPROVED deliveryDate # Основной параметр поставки totalAmount # Ключевой параметр поставки - общая стоимость totalItems # Параметр поставки - количество товаров organization { id name } packagesCount # Параметр поставки (опционально) volume # Параметр поставки (опционально) readyDate # null если не указано notes # null если не указано } } } # Отклонение заказа поставщиком mutation SupplierRejectOrder($orderId: ID!, $reason: String) { supplierRejectOrder(id: $orderId, reason: $reason) { success message order { id status # PENDING → CANCELLED } } } # Отгрузка товара поставщиком mutation SupplierShipOrder($orderId: ID!) { supplierShipOrder(id: $orderId) { success message order { id status # LOGISTICS_CONFIRMED → SHIPPED organization { id name } logisticsPartner { id name } } } } # Создание товара поставщиком mutation CreateProduct($input: ProductInput!) { createProduct(input: $input) { success message product { id article name price organization { id name } } } } ``` ### ПРАВИЛА АВТОРИЗАЦИИ ПОСТАВЩИКОВ: ```typescript // Resolver-level security для поставщиков const wholesaleResolvers = { // Проверка что пользователь - поставщик validateWholesaleAccess: (context) => { if (context.user.organization.type !== 'WHOLESALE') { throw new GraphQLError('Access denied: Wholesale access required') } }, // Фильтрация заказов для поставщика getSupplierOrders: async (parent, args, context) => { // Поставщик видит только заказы где он является поставщиком return await prisma.supplyOrder.findMany({ where: { partnerId: context.user.organization.id, // Мы - поставщик ...args.where, }, }) }, // Проверка доступа к товарам validateProductAccess: async (productId, context) => { const product = await prisma.product.findFirst({ where: { id: productId, organizationId: context.user.organizationId, // Только свои товары }, }) if (!product) { throw new GraphQLError('Product not found or access denied') } return product }, } ``` ### КРИТИЧЕСКИЕ ПРАВИЛА ПАРТНЕРСТВА: ```typescript // ✅ ПРАВИЛЬНО: Поставщики берутся ТОЛЬКО из партнеров const getWholesalePartners = ` query GetMyCounterparties { myCounterparties(type: WHOLESALE) { id, name, fullName, inn, market isCounterparty # Должно быть true } } `; // ❌ НЕПРАВИЛЬНО: Прямой запрос всех поставщиков const wrongSupplierQuery = ` query GetAllSuppliers { organizations(type: WHOLESALE) { # Неправильно - нет проверки партнерства id, name } } `; // Правильная фильтрация в резолвере: const correctPartnershipFilter = ` // Показываем только организации-партнеры const counterparties = await prisma.counterparty.findMany({ where: { initiatorId: currentUser.organization.id, status: 'ACCEPTED', partner: { type: 'WHOLESALE' } }, include: { partner: true } }) `; # Временные метки (обязательно) createdAt: DateTime! updatedAt: DateTime! } ``` ## 🔐 ПРАВИЛА БЕЗОПАСНОСТИ В СХЕМЕ ### 1. КОНТРОЛЬ ДОСТУПА К ДАННЫМ ```graphql # ✅ Поля требующие проверки принадлежности type Organization { apiKeys: [ApiKey!]! # Только владелец users: [User!]! # Только владелец # Публичная информация name: String type: OrganizationType! } ``` ### 2. ВАЛИДАЦИЯ ВХОДНЫХ ДАННЫХ ```graphql # ✅ Обязательные поля для критических операций input SellerRegistrationInput { phone: String! # Обязательно wbApiKey: String # Опционально при регистрации referralCode: String # Опционально } input FulfillmentRegistrationInput { phone: String! # Обязательно inn: String! # Обязательно для бизнеса type: OrganizationType! # Обязательно } ``` ### 3. ЗАЩИТА ОТ РАСКРЫТИЯ ИНФОРМАЦИИ ```graphql # ✅ API ключи не возвращаются полностью type ApiKey { id: ID! marketplace: MarketplaceType! isActive: Boolean! # apiKey: String - НЕ ВОЗВРАЩАЕТСЯ в queries } ``` ## 🎯 ПРАВИЛА РАСШИРЕНИЯ СХЕМЫ ### 1. ДОБАВЛЕНИЕ НОВЫХ ТИПОВ ОРГАНИЗАЦИЙ ```graphql # При добавлении нового типа в OrganizationType: enum OrganizationType { FULFILLMENT SELLER LOGIST WHOLESALE # NEW_TYPE # Добавлять в конец для совместимости } # Обязательно добавить: # 1. Соответствующие queries для нового типа # 2. Мutations регистрации # 3. Права доступа в resolvers # 4. UI компоненты ``` ### 2. НОВЫЕ СТАТУСЫ В WORKFLOW ```graphql # При изменении workflow: enum SupplyOrderStatus { # Существующие статусы НЕ УДАЛЯТЬ # Новые добавлять в конец # Обновлять правила переходов в resolvers } ``` ### 3. ВЕРСИОНИРОВАНИЕ API ```graphql # ✅ Добавление новых полей (обратно совместимо) type Organization { # Существующие поля name: String # Новые поля (nullable для совместимости) newField: String } # ❌ Изменение существующих полей (ломает совместимость) type Organization { # Было: name: String # Стало: name: String! - ЛОМАЕТ старые клиенты } ``` ## 📈 ПРАВИЛА ПРОИЗВОДИТЕЛЬНОСТИ ### 1. ИЗБЕГАТЬ N+1 ПРОБЛЕМ ```graphql # ✅ Использовать включения в одном запросе type Organization { users: [User!]! # Загружается через include в Prisma services: [Service!]! # Загружается через include } # ❌ Отдельные запросы для связанных данных query { organizations { id } } # Затем отдельно для каждой организации запрос users ``` ### 2. ОГРАНИЧЕНИЯ НА МАССОВЫЕ ОПЕРАЦИИ ```graphql # ✅ Лимиты по умолчанию messages( counterpartyId: ID! limit: Int = 50 # Разумный лимит по умолчанию offset: Int = 0 ): [Message!]! # ✅ Максимальные лимиты в resolvers # limit: Math.min(args.limit || 50, 1000) ``` --- _Извлечено из анализа: GraphQL typedefs, resolvers, patterns_ _Дата создания: 2025-08-21_ _Основано на коде: src/graphql/typedefs.ts, src/graphql/resolvers.ts_