
- Добавлены колонки Объём и Грузовые места между Цена товаров и Статус - Реализованы инпуты для ввода volume и packagesCount в статусе PENDING для роли WHOLESALE - Добавлена мутация UPDATE_SUPPLY_PARAMETERS с проверками безопасности - Скрыта строка Поставщик для роли WHOLESALE (поставщик знает свои данные) - Исправлено выравнивание таблицы при скрытии уровня поставщика - Реорганизованы документы: legacy-rules/, docs/, docs-and-reports/ ВНИМАНИЕ: Компонент multilevel-supplies-table.tsx (1697 строк) нарушает правило модульной архитектуры (>800 строк требует рефакторинга) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
929 lines
27 KiB
Markdown
929 lines
27 KiB
Markdown
# ПРАВИЛА 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<string, unknown> = {}
|
||
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_
|