
- ✅ Добавлено поле nameForSeller в FulfillmentConsumable для кастомизации названий - ✅ Добавлено поле inventoryId для связи между каталогом и складом - ✅ Реализована автосинхронизация FulfillmentConsumableInventory → FulfillmentConsumable - ✅ Обновлен UI с колонкой "Название для селлера" в /fulfillment/services/consumables - ✅ Исправлены GraphQL запросы (удалено поле description, добавлены новые поля) - ✅ Создан скрипт sync-inventory-to-catalog.ts для миграции существующих данных - ✅ Добавлена техническая документация архитектуры системы инвентаря - ✅ Создан отчет о статусе миграции V1→V2 с детальным планом 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1087 lines
32 KiB
Markdown
1087 lines
32 KiB
Markdown
# 🏗️ ТЕХНИЧЕСКАЯ ДОКУМЕНТАЦИЯ: V2 АРХИТЕКТУРА УСЛУГ
|
||
|
||
> **Статус**: ✅ **PRODUCTION READY**
|
||
> **Версия**: V2.0
|
||
> **Дата создания**: 03.09.2025
|
||
> **Область**: Модульная архитектура услуг фулфилмента
|
||
|
||
---
|
||
|
||
## 🎯 ОБЗОР V2 АРХИТЕКТУРЫ УСЛУГ
|
||
|
||
### ФИЛОСОФИЯ V2:
|
||
> **"Один домен - одна модель - одна ответственность"**
|
||
|
||
V2 архитектура услуг основана на принципе доменной специализации, где каждый тип данных имеет собственную оптимизированную структуру, резолверы и логику обработки.
|
||
|
||
### ОСНОВНЫЕ ПРИНЦИПЫ:
|
||
|
||
1. **Доменная изоляция** - каждый тип услуг в отдельной таблице
|
||
2. **Специализированные резолверы** - отдельные файлы для каждого домена
|
||
3. **Безопасность по умолчанию** - автоматическая изоляция по fulfillmentId
|
||
4. **Масштабируемость** - независимое развитие каждого типа
|
||
|
||
---
|
||
|
||
## 🗄️ V2 МОДЕЛИ ДАННЫХ
|
||
|
||
### 1. FULFILLMENT SERVICE (Услуги)
|
||
|
||
**Назначение**: Управление услугами, предоставляемыми фулфилментом
|
||
|
||
```prisma
|
||
model FulfillmentService {
|
||
id String @id @default(cuid())
|
||
fulfillmentId String // Изоляция по домену
|
||
name String
|
||
description String?
|
||
price Decimal @db.Decimal(10, 2)
|
||
unit String @default("шт")
|
||
isActive Boolean @default(true)
|
||
imageUrl String? // Поддержка изображений
|
||
sortOrder Int @default(0)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// Связи
|
||
fulfillment Organization @relation("FulfillmentServicesV2", fields: [fulfillmentId], references: [id])
|
||
|
||
// Индексы производительности
|
||
@@index([fulfillmentId, isActive])
|
||
@@map("fulfillment_services_v2")
|
||
}
|
||
```
|
||
|
||
**Ключевые особенности:**
|
||
- Decimal для точных денежных расчетов
|
||
- Поддержка изображений услуг
|
||
- Сортировка для UI
|
||
- Мягкое удаление через isActive
|
||
|
||
### 2. FULFILLMENT CONSUMABLE (Расходники)
|
||
|
||
**Назначение**: Управление расходными материалами фулфилмента
|
||
|
||
```prisma
|
||
model FulfillmentConsumable {
|
||
id String @id @default(cuid())
|
||
fulfillmentId String // Доменная привязка
|
||
warehouseConsumableId String // Связь со складом
|
||
name String
|
||
description String?
|
||
pricePerUnit Decimal? @db.Decimal(10, 2)
|
||
unit String @default("шт")
|
||
imageUrl String?
|
||
warehouseStock Int @default(0)
|
||
isAvailable Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// Связи
|
||
fulfillment Organization @relation("FulfillmentConsumablesV2", fields: [fulfillmentId], references: [id])
|
||
warehouseConsumable WarehouseConsumable @relation("FulfillmentWarehouseConsumables", fields: [warehouseConsumableId], references: [id])
|
||
|
||
// Индексы
|
||
@@index([fulfillmentId, isAvailable])
|
||
@@index([warehouseConsumableId])
|
||
@@unique([fulfillmentId, warehouseConsumableId])
|
||
@@map("fulfillment_consumables_v2")
|
||
}
|
||
```
|
||
|
||
**Ключевые особенности:**
|
||
- Связь с складскими остатками
|
||
- Автоматическая синхронизация наличия
|
||
- Уникальность в рамках фулфилмента
|
||
- Опциональная цена (может устанавливаться позже)
|
||
|
||
### 3. FULFILLMENT LOGISTICS (Логистика)
|
||
|
||
**Назначение**: Управление логистическими маршрутами
|
||
|
||
```prisma
|
||
model FulfillmentLogistics {
|
||
id String @id @default(cuid())
|
||
fulfillmentId String // Изоляция домена
|
||
fromLocation String
|
||
toLocation String
|
||
priceUnder1m3 Decimal @db.Decimal(10, 2)
|
||
priceOver1m3 Decimal @db.Decimal(10, 2)
|
||
estimatedDays Int @default(1)
|
||
description String?
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// Связи
|
||
fulfillment Organization @relation("FulfillmentLogisticsV2", fields: [fulfillmentId], references: [id])
|
||
|
||
// Индексы
|
||
@@index([fulfillmentId, isActive])
|
||
@@index([fromLocation, toLocation])
|
||
@@map("fulfillment_logistics_v2")
|
||
}
|
||
```
|
||
|
||
**Ключевые особенности:**
|
||
- Объемное ценообразование (до/свыше 1м³)
|
||
- Оценка времени доставки
|
||
- Географическая индексация
|
||
- Поддержка множественных маршрутов
|
||
|
||
---
|
||
|
||
## 🔌 V2 GRAPHQL API
|
||
|
||
### СХЕМА ТИПОВ
|
||
|
||
```graphql
|
||
# ОСНОВНЫЕ ТИПЫ
|
||
type FulfillmentService {
|
||
id: ID!
|
||
fulfillmentId: String!
|
||
name: String!
|
||
description: String
|
||
price: Float!
|
||
unit: String!
|
||
isActive: Boolean!
|
||
imageUrl: String
|
||
sortOrder: Int!
|
||
createdAt: DateTime!
|
||
updatedAt: DateTime!
|
||
fulfillment: Organization!
|
||
}
|
||
|
||
type FulfillmentConsumable {
|
||
id: ID!
|
||
fulfillmentId: String!
|
||
warehouseConsumableId: String!
|
||
name: String!
|
||
description: String
|
||
pricePerUnit: Float
|
||
unit: String!
|
||
imageUrl: String
|
||
warehouseStock: Int!
|
||
isAvailable: Boolean!
|
||
createdAt: DateTime!
|
||
updatedAt: DateTime!
|
||
fulfillment: Organization!
|
||
warehouseConsumable: WarehouseConsumable!
|
||
}
|
||
|
||
type FulfillmentLogistics {
|
||
id: ID!
|
||
fulfillmentId: String!
|
||
fromLocation: String!
|
||
toLocation: String!
|
||
priceUnder1m3: Float!
|
||
priceOver1m3: Float!
|
||
estimatedDays: Int!
|
||
description: String
|
||
isActive: Boolean!
|
||
createdAt: DateTime!
|
||
updatedAt: DateTime!
|
||
fulfillment: Organization!
|
||
}
|
||
```
|
||
|
||
### V2 QUERIES
|
||
|
||
```graphql
|
||
type Query {
|
||
# УСЛУГИ ФУЛФИЛМЕНТА
|
||
myFulfillmentServices: [FulfillmentService!]!
|
||
fulfillmentServicesByFulfillment(fulfillmentId: ID!): [FulfillmentService!]!
|
||
|
||
# РАСХОДНИКИ ФУЛФИЛМЕНТА
|
||
myFulfillmentConsumables: [FulfillmentConsumable!]!
|
||
fulfillmentConsumablesByFulfillment(fulfillmentId: ID!): [FulfillmentConsumable!]!
|
||
|
||
# ЛОГИСТИКА ФУЛФИЛМЕНТА
|
||
myFulfillmentLogistics: [FulfillmentLogistics!]!
|
||
fulfillmentLogisticsByFulfillment(fulfillmentId: ID!): [FulfillmentLogistics!]!
|
||
}
|
||
```
|
||
|
||
### V2 MUTATIONS
|
||
|
||
```graphql
|
||
type Mutation {
|
||
# УСЛУГИ - CRUD
|
||
createFulfillmentService(input: CreateFulfillmentServiceInput!): FulfillmentServiceResponse!
|
||
updateFulfillmentService(input: UpdateFulfillmentServiceInput!): FulfillmentServiceResponse!
|
||
deleteFulfillmentService(id: ID!): Boolean!
|
||
|
||
# РАСХОДНИКИ - CRUD
|
||
createFulfillmentConsumable(input: CreateFulfillmentConsumableInput!): FulfillmentConsumableResponse!
|
||
updateFulfillmentConsumable(input: UpdateFulfillmentConsumableInput!): FulfillmentConsumableResponse!
|
||
deleteFulfillmentConsumable(id: ID!): Boolean!
|
||
|
||
# ЛОГИСТИКА - CRUD
|
||
createFulfillmentLogistics(input: CreateFulfillmentLogisticsInput!): FulfillmentLogisticsResponse!
|
||
updateFulfillmentLogistics(input: UpdateFulfillmentLogisticsInput!): FulfillmentLogisticsResponse!
|
||
deleteFulfillmentLogistics(id: ID!): Boolean!
|
||
}
|
||
```
|
||
|
||
### INPUT ТИПЫ
|
||
|
||
```graphql
|
||
# СОЗДАНИЕ УСЛУГИ
|
||
input CreateFulfillmentServiceInput {
|
||
name: String!
|
||
description: String
|
||
price: Float!
|
||
unit: String!
|
||
imageUrl: String
|
||
sortOrder: Int
|
||
}
|
||
|
||
# ОБНОВЛЕНИЕ УСЛУГИ
|
||
input UpdateFulfillmentServiceInput {
|
||
id: ID!
|
||
name: String
|
||
description: String
|
||
price: Float
|
||
unit: String
|
||
isActive: Boolean
|
||
imageUrl: String
|
||
sortOrder: Int
|
||
}
|
||
|
||
# СОЗДАНИЕ РАСХОДНИКА
|
||
input CreateFulfillmentConsumableInput {
|
||
warehouseConsumableId: String!
|
||
pricePerUnit: Float
|
||
}
|
||
|
||
# ОБНОВЛЕНИЕ РАСХОДНИКА
|
||
input UpdateFulfillmentConsumableInput {
|
||
id: ID!
|
||
pricePerUnit: Float
|
||
}
|
||
|
||
# СОЗДАНИЕ ЛОГИСТИКИ
|
||
input CreateFulfillmentLogisticsInput {
|
||
fromLocation: String!
|
||
toLocation: String!
|
||
priceUnder1m3: Float!
|
||
priceOver1m3: Float!
|
||
estimatedDays: Int!
|
||
description: String
|
||
}
|
||
|
||
# ОБНОВЛЕНИЕ ЛОГИСТИКИ
|
||
input UpdateFulfillmentLogisticsInput {
|
||
id: ID!
|
||
fromLocation: String
|
||
toLocation: String
|
||
priceUnder1m3: Float
|
||
priceOver1m3: Float
|
||
estimatedDays: Int
|
||
description: String
|
||
isActive: Boolean
|
||
}
|
||
```
|
||
|
||
### RESPONSE ТИПЫ
|
||
|
||
```graphql
|
||
# УНИВЕРСАЛЬНЫЕ ОТВЕТЫ
|
||
type FulfillmentServiceResponse {
|
||
success: Boolean!
|
||
message: String!
|
||
service: FulfillmentService
|
||
}
|
||
|
||
type FulfillmentConsumableResponse {
|
||
success: Boolean!
|
||
message: String!
|
||
consumable: FulfillmentConsumable
|
||
}
|
||
|
||
type FulfillmentLogisticsResponse {
|
||
success: Boolean!
|
||
message: String!
|
||
logistics: FulfillmentLogistics
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔧 V2 РЕЗОЛВЕРЫ IMPLEMENTATION
|
||
|
||
### АРХИТЕКТУРА РЕЗОЛВЕРОВ
|
||
|
||
```typescript
|
||
// Файл: /src/graphql/resolvers/fulfillment-services-v2.ts
|
||
|
||
// СТРУКТУРА:
|
||
export const fulfillmentServicesQueries = {
|
||
myFulfillmentServices: [Query resolver],
|
||
myFulfillmentConsumables: [Query resolver],
|
||
myFulfillmentLogistics: [Query resolver],
|
||
fulfillmentServicesByFulfillment: [Query resolver],
|
||
fulfillmentConsumablesByFulfillment: [Query resolver],
|
||
fulfillmentLogisticsByFulfillment: [Query resolver],
|
||
}
|
||
|
||
export const fulfillmentServicesMutations = {
|
||
createFulfillmentService: [Mutation resolver],
|
||
updateFulfillmentService: [Mutation resolver],
|
||
deleteFulfillmentService: [Mutation resolver],
|
||
createFulfillmentConsumable: [Mutation resolver],
|
||
updateFulfillmentConsumable: [Mutation resolver],
|
||
deleteFulfillmentConsumable: [Mutation resolver],
|
||
createFulfillmentLogistics: [Mutation resolver],
|
||
updateFulfillmentLogistics: [Mutation resolver],
|
||
deleteFulfillmentLogistics: [Mutation resolver],
|
||
}
|
||
```
|
||
|
||
### ПРИМЕР QUERY РЕЗОЛВЕРА
|
||
|
||
```typescript
|
||
// УСЛУГИ ФУЛФИЛМЕНТА (собственные)
|
||
myFulfillmentServices: async (_: unknown, __: unknown, context: Context) => {
|
||
try {
|
||
const { user } = context
|
||
if (!user?.organization?.id) {
|
||
throw new Error('Организация не найдена')
|
||
}
|
||
|
||
// ДОМЕННАЯ БЕЗОПАСНОСТЬ: только свои услуги
|
||
const services = await prisma.fulfillmentService.findMany({
|
||
where: {
|
||
fulfillmentId: user.organization.id,
|
||
isActive: true,
|
||
},
|
||
include: {
|
||
fulfillment: true,
|
||
},
|
||
orderBy: [
|
||
{ sortOrder: 'asc' },
|
||
{ name: 'asc' },
|
||
],
|
||
})
|
||
|
||
return services
|
||
} catch (error) {
|
||
console.error('Error fetching fulfillment services:', error)
|
||
throw error
|
||
}
|
||
},
|
||
```
|
||
|
||
### ПРИМЕР MUTATION РЕЗОЛВЕРА
|
||
|
||
```typescript
|
||
// СОЗДАНИЕ УСЛУГИ
|
||
createFulfillmentService: async (
|
||
_: unknown,
|
||
{ input }: { input: CreateFulfillmentServiceInput },
|
||
context: Context,
|
||
) => {
|
||
try {
|
||
const { user } = context
|
||
if (!user?.organization?.id) {
|
||
throw new Error('Организация не найдена')
|
||
}
|
||
|
||
// ВАЛИДАЦИЯ ВХОДНЫХ ДАННЫХ
|
||
if (!input.name?.trim()) {
|
||
return {
|
||
success: false,
|
||
message: 'Название услуги обязательно',
|
||
service: null,
|
||
}
|
||
}
|
||
|
||
if (!input.price || input.price <= 0) {
|
||
return {
|
||
success: false,
|
||
message: 'Цена должна быть больше нуля',
|
||
service: null,
|
||
}
|
||
}
|
||
|
||
// СОЗДАНИЕ С ДОМЕННОЙ ИЗОЛЯЦИЕЙ
|
||
const service = await prisma.fulfillmentService.create({
|
||
data: {
|
||
...input,
|
||
fulfillmentId: user.organization.id, // Автоматическая привязка
|
||
},
|
||
include: {
|
||
fulfillment: true,
|
||
},
|
||
})
|
||
|
||
return {
|
||
success: true,
|
||
message: 'Услуга успешно создана',
|
||
service,
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating fulfillment service:', error)
|
||
return {
|
||
success: false,
|
||
message: 'Ошибка при создании услуги',
|
||
service: null,
|
||
}
|
||
}
|
||
},
|
||
```
|
||
|
||
### БЕЗОПАСНОСТЬ РЕЗОЛВЕРОВ
|
||
|
||
#### ДОМЕННАЯ ИЗОЛЯЦИЯ:
|
||
```typescript
|
||
// ВСЕГДА ФИЛЬТРОВАТЬ ПО fulfillmentId
|
||
const services = await prisma.fulfillmentService.findMany({
|
||
where: {
|
||
fulfillmentId: user.organization.id, // ← КРИТИЧЕСКАЯ БЕЗОПАСНОСТЬ
|
||
isActive: true,
|
||
},
|
||
})
|
||
```
|
||
|
||
#### ВАЛИДАЦИЯ ДАННЫХ:
|
||
```typescript
|
||
// ПРОВЕРКА ОБЯЗАТЕЛЬНЫХ ПОЛЕЙ
|
||
if (!input.name?.trim()) {
|
||
return { success: false, message: 'Название обязательно' }
|
||
}
|
||
|
||
// ПРОВЕРКА БИЗНЕС-ЛОГИКИ
|
||
if (!input.price || input.price <= 0) {
|
||
return { success: false, message: 'Цена должна быть больше нуля' }
|
||
}
|
||
```
|
||
|
||
#### ОБРАБОТКА ОШИБОК:
|
||
```typescript
|
||
try {
|
||
// Логика резолвера
|
||
} catch (error) {
|
||
console.error('Error in resolver:', error)
|
||
return {
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера',
|
||
[entity]: null,
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🎨 V2 FRONTEND КОМПОНЕНТЫ
|
||
|
||
### АРХИТЕКТУРА КОМПОНЕНТОВ
|
||
|
||
```
|
||
/src/components/services/
|
||
├── services-dashboard.tsx # Главный dashboard с табами
|
||
├── services-tab.tsx # Управление услугами
|
||
├── supplies-tab.tsx # Управление расходниками
|
||
└── logistics-tab.tsx # Управление логистикой
|
||
```
|
||
|
||
### ПАТТЕРН КОМПОНЕНТА V2
|
||
|
||
```typescript
|
||
// Пример: services-tab.tsx
|
||
|
||
'use client'
|
||
|
||
import { useQuery, useMutation } from '@apollo/client'
|
||
import {
|
||
GET_MY_FULFILLMENT_SERVICES_V2,
|
||
CREATE_FULFILLMENT_SERVICE,
|
||
UPDATE_FULFILLMENT_SERVICE,
|
||
DELETE_FULFILLMENT_SERVICE
|
||
} from '@/graphql/queries/fulfillment-services-v2'
|
||
|
||
export function ServicesTab() {
|
||
// V2 QUERY
|
||
const { data, loading, error, refetch } = useQuery(GET_MY_FULFILLMENT_SERVICES_V2, {
|
||
fetchPolicy: 'cache-and-network',
|
||
})
|
||
|
||
// V2 MUTATIONS
|
||
const [createService] = useMutation(CREATE_FULFILLMENT_SERVICE, {
|
||
update: (cache, { data }) => {
|
||
if (data?.createFulfillmentService?.success) {
|
||
// ОБНОВЛЕНИЕ APOLLO CACHE
|
||
const existingData = cache.readQuery({ query: GET_MY_FULFILLMENT_SERVICES_V2 })
|
||
if (existingData) {
|
||
cache.writeQuery({
|
||
query: GET_MY_FULFILLMENT_SERVICES_V2,
|
||
data: {
|
||
myFulfillmentServices: [
|
||
...existingData.myFulfillmentServices,
|
||
data.createFulfillmentService.service
|
||
]
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
// V2 DATA ACCESS
|
||
const services = data?.myFulfillmentServices || []
|
||
|
||
// UI ЛОГИКА ОСТАЕТСЯ НЕИЗМЕННОЙ
|
||
return (
|
||
<div className="services-management">
|
||
{/* Рендер услуг */}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### URL МАРШРУТИЗАЦИЯ V2
|
||
|
||
```typescript
|
||
// services-dashboard.tsx - URL управление
|
||
|
||
import { usePathname, useRouter } from 'next/navigation'
|
||
|
||
export function ServicesDashboard() {
|
||
const pathname = usePathname()
|
||
const router = useRouter()
|
||
|
||
// ОПРЕДЕЛЕНИЕ АКТИВНОГО ТАБА ПО URL
|
||
const getActiveTab = () => {
|
||
if (pathname.includes('/services/services')) return 'services'
|
||
if (pathname.includes('/services/logistics')) return 'logistics'
|
||
if (pathname.includes('/services/consumables')) return 'consumables'
|
||
return 'services'
|
||
}
|
||
|
||
// НАВИГАЦИЯ МЕЖДУ ТАБАМИ
|
||
const handleTabChange = (tab: string) => {
|
||
switch (tab) {
|
||
case 'services':
|
||
router.push('/fulfillment/services/services')
|
||
break
|
||
case 'consumables':
|
||
router.push('/fulfillment/services/consumables')
|
||
break
|
||
case 'logistics':
|
||
router.push('/fulfillment/services/logistics')
|
||
break
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="services-dashboard">
|
||
{/* Tab navigation с уникальными URL */}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### APOLLO CLIENT CACHE MANAGEMENT
|
||
|
||
```typescript
|
||
// Паттерн обновления кэша в V2
|
||
|
||
const [updateService] = useMutation(UPDATE_FULFILLMENT_SERVICE, {
|
||
update: (cache, { data }) => {
|
||
if (data?.updateFulfillmentService?.success && data.updateFulfillmentService.service) {
|
||
|
||
// ОБНОВЛЕНИЕ СУЩЕСТВУЮЩИХ ДАННЫХ В КЭШЕ
|
||
const existingData = cache.readQuery({
|
||
query: GET_MY_FULFILLMENT_SERVICES_V2
|
||
}) as { myFulfillmentServices: FulfillmentService[] } | null
|
||
|
||
if (existingData) {
|
||
const updatedService = data.updateFulfillmentService.service
|
||
|
||
cache.writeQuery({
|
||
query: GET_MY_FULFILLMENT_SERVICES_V2,
|
||
data: {
|
||
myFulfillmentServices: existingData.myFulfillmentServices.map(service =>
|
||
service.id === updatedService.id ? updatedService : service
|
||
)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 🚀 ПРОИЗВОДИТЕЛЬНОСТЬ И ОПТИМИЗАЦИЯ
|
||
|
||
### ИНДЕКСЫ БД
|
||
|
||
```sql
|
||
-- ОСНОВНЫЕ ИНДЕКСЫ
|
||
CREATE INDEX "fulfillment_services_v2_fulfillmentId_isActive_idx"
|
||
ON "fulfillment_services_v2"("fulfillmentId", "isActive");
|
||
|
||
CREATE INDEX "fulfillment_consumables_v2_fulfillmentId_isAvailable_idx"
|
||
ON "fulfillment_consumables_v2"("fulfillmentId", "isAvailable");
|
||
|
||
CREATE INDEX "fulfillment_logistics_v2_fromLocation_toLocation_idx"
|
||
ON "fulfillment_logistics_v2"("fromLocation", "toLocation");
|
||
```
|
||
|
||
### APOLLO CLIENT ОПТИМИЗАЦИЯ
|
||
|
||
```typescript
|
||
// ОПТИМИЗИРОВАННЫЕ QUERY ПОЛИТИКИ
|
||
const { data } = useQuery(GET_MY_FULFILLMENT_SERVICES_V2, {
|
||
fetchPolicy: 'cache-and-network', // Быстрый отклик + актуальность
|
||
errorPolicy: 'all', // Показ частичных данных при ошибках
|
||
notifyOnNetworkStatusChange: true, // UI обратная связь
|
||
})
|
||
```
|
||
|
||
### ПАГИНАЦИЯ (ГОТОВНОСТЬ)
|
||
|
||
```graphql
|
||
# РАСШИРЕННЫЕ QUERIES (для будущих версий)
|
||
type Query {
|
||
fulfillmentServices(
|
||
fulfillmentId: ID!
|
||
first: Int
|
||
after: String
|
||
filter: FulfillmentServiceFilter
|
||
): FulfillmentServiceConnection!
|
||
}
|
||
|
||
type FulfillmentServiceConnection {
|
||
edges: [FulfillmentServiceEdge!]!
|
||
pageInfo: PageInfo!
|
||
totalCount: Int!
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔒 БЕЗОПАСНОСТЬ V2
|
||
|
||
### ДОМЕННАЯ ИЗОЛЯЦИЯ
|
||
|
||
```typescript
|
||
// ПРИНЦИП: Пользователь видит только свои данные
|
||
const services = await prisma.fulfillmentService.findMany({
|
||
where: {
|
||
fulfillmentId: user.organization.id, // ← КРИТИЧЕСКАЯ СТРОКА
|
||
isActive: true,
|
||
},
|
||
})
|
||
```
|
||
|
||
### ПРОВЕРКА ПРАВ ДОСТУПА
|
||
|
||
```typescript
|
||
// ДОПОЛНИТЕЛЬНАЯ ПРОВЕРКА ДЛЯ КРИТИЧЕСКИХ ОПЕРАЦИЙ
|
||
if (user.organization.type !== 'FULFILLMENT') {
|
||
throw new Error('Доступ запрещен: требуется тип организации FULFILLMENT')
|
||
}
|
||
```
|
||
|
||
### ВАЛИДАЦИЯ ВХОДНЫХ ДАННЫХ
|
||
|
||
```typescript
|
||
// ЗАЩИТА ОТ НЕКОРРЕКТНЫХ ДАННЫХ
|
||
const validateServiceInput = (input: CreateFulfillmentServiceInput) => {
|
||
if (!input.name?.trim()) {
|
||
throw new Error('Название услуги обязательно')
|
||
}
|
||
|
||
if (input.name.length > 255) {
|
||
throw new Error('Название слишком длинное')
|
||
}
|
||
|
||
if (input.price <= 0) {
|
||
throw new Error('Цена должна быть положительной')
|
||
}
|
||
|
||
if (input.price > 1000000) {
|
||
throw new Error('Цена слишком высокая')
|
||
}
|
||
}
|
||
```
|
||
|
||
### САНИТИЗАЦИЯ ДАННЫХ
|
||
|
||
```typescript
|
||
// ОЧИСТКА ВХОДЯЩИХ ДАННЫХ
|
||
const sanitizedInput = {
|
||
...input,
|
||
name: input.name?.trim(),
|
||
description: input.description?.trim() || null,
|
||
price: parseFloat(input.price.toFixed(2)), // 2 знака после запятой
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 📈 МОНИТОРИНГ И ОТЛАДКА
|
||
|
||
### ЛОГИРОВАНИЕ ОПЕРАЦИЙ
|
||
|
||
```typescript
|
||
// В резолверах
|
||
console.warn('V2 Service Operation:', {
|
||
operation: 'createFulfillmentService',
|
||
fulfillmentId: user.organization.id,
|
||
serviceName: input.name,
|
||
timestamp: new Date().toISOString(),
|
||
})
|
||
```
|
||
|
||
### ERROR HANDLING
|
||
|
||
```typescript
|
||
try {
|
||
// Бизнес-логика
|
||
} catch (error) {
|
||
// СТРУКТУРИРОВАННОЕ ЛОГИРОВАНИЕ ОШИБОК
|
||
console.error('V2 Service Error:', {
|
||
operation: 'createFulfillmentService',
|
||
error: error.message,
|
||
stack: error.stack,
|
||
input: JSON.stringify(input),
|
||
user: user.phone,
|
||
timestamp: new Date().toISOString(),
|
||
})
|
||
|
||
// ПОЛЬЗОВАТЕЛЮ ПОКАЗЫВАЕМ БЕЗОПАСНОЕ СООБЩЕНИЕ
|
||
return {
|
||
success: false,
|
||
message: 'Внутренняя ошибка сервера',
|
||
service: null,
|
||
}
|
||
}
|
||
```
|
||
|
||
### ПРОИЗВОДИТЕЛЬНОСТЬ МОНИТОРИНГ
|
||
|
||
```typescript
|
||
// ЗАМЕРЫ ВРЕМЕНИ ВЫПОЛНЕНИЯ
|
||
const startTime = Date.now()
|
||
|
||
const services = await prisma.fulfillmentService.findMany({
|
||
where: { fulfillmentId: user.organization.id },
|
||
})
|
||
|
||
const endTime = Date.now()
|
||
console.warn('V2 Query Performance:', {
|
||
operation: 'myFulfillmentServices',
|
||
duration: `${endTime - startTime}ms`,
|
||
resultCount: services.length,
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 🧪 ТЕСТИРОВАНИЕ V2
|
||
|
||
### UNIT ТЕСТЫ РЕЗОЛВЕРОВ
|
||
|
||
```typescript
|
||
// Пример теста резолвера
|
||
describe('FulfillmentServices V2 Resolvers', () => {
|
||
describe('myFulfillmentServices', () => {
|
||
it('should return only services for current fulfillment', async () => {
|
||
const context = mockContext({ organizationId: 'fulfillment-1' })
|
||
|
||
const result = await fulfillmentServicesQueries.myFulfillmentServices(
|
||
{},
|
||
{},
|
||
context
|
||
)
|
||
|
||
expect(result).toHaveLength(2)
|
||
result.forEach(service => {
|
||
expect(service.fulfillmentId).toBe('fulfillment-1')
|
||
})
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
### ИНТЕГРАЦИОННЫЕ ТЕСТЫ
|
||
|
||
```typescript
|
||
// Тест полного цикла CRUD
|
||
describe('Fulfillment Services Integration', () => {
|
||
it('should create, read, update, delete service', async () => {
|
||
// CREATE
|
||
const createResult = await createFulfillmentService({
|
||
name: 'Test Service',
|
||
price: 100,
|
||
unit: 'шт'
|
||
})
|
||
expect(createResult.success).toBe(true)
|
||
|
||
// READ
|
||
const services = await myFulfillmentServices()
|
||
expect(services).toContainEqual(
|
||
expect.objectContaining({ name: 'Test Service' })
|
||
)
|
||
|
||
// UPDATE
|
||
const updateResult = await updateFulfillmentService({
|
||
id: createResult.service.id,
|
||
price: 150
|
||
})
|
||
expect(updateResult.success).toBe(true)
|
||
|
||
// DELETE
|
||
const deleteResult = await deleteFulfillmentService(createResult.service.id)
|
||
expect(deleteResult).toBe(true)
|
||
})
|
||
})
|
||
```
|
||
|
||
### E2E ТЕСТЫ КОМПОНЕНТОВ
|
||
|
||
```typescript
|
||
// Cypress тест UI
|
||
describe('Services Management V2', () => {
|
||
it('should manage services through UI', () => {
|
||
cy.visit('/fulfillment/services/services')
|
||
|
||
// Создание услуги
|
||
cy.get('[data-testid="add-service-btn"]').click()
|
||
cy.get('[data-testid="service-name"]').type('Test Service')
|
||
cy.get('[data-testid="service-price"]').type('100')
|
||
cy.get('[data-testid="save-btn"]').click()
|
||
|
||
// Проверка создания
|
||
cy.contains('Test Service').should('exist')
|
||
cy.contains('100 ₽').should('exist')
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 📚 РУКОВОДСТВО ПО РАСШИРЕНИЮ V2
|
||
|
||
### ДОБАВЛЕНИЕ НОВОГО ПОЛЯ
|
||
|
||
#### 1. ОБНОВИТЬ PRISMA МОДЕЛЬ:
|
||
```prisma
|
||
model FulfillmentService {
|
||
// ... существующие поля
|
||
newField String? // Новое поле
|
||
}
|
||
```
|
||
|
||
#### 2. ДОБАВИТЬ В GRAPHQL СХЕМУ:
|
||
```graphql
|
||
type FulfillmentService {
|
||
# ... существующие поля
|
||
newField: String
|
||
}
|
||
|
||
input CreateFulfillmentServiceInput {
|
||
# ... существующие поля
|
||
newField: String
|
||
}
|
||
```
|
||
|
||
#### 3. ОБНОВИТЬ РЕЗОЛВЕРЫ:
|
||
```typescript
|
||
createFulfillmentService: async (_, { input }, context) => {
|
||
const service = await prisma.fulfillmentService.create({
|
||
data: {
|
||
...input,
|
||
newField: input.newField, // ← Добавить обработку
|
||
fulfillmentId: user.organization.id,
|
||
},
|
||
})
|
||
}
|
||
```
|
||
|
||
#### 4. ОБНОВИТЬ UI КОМПОНЕНТЫ:
|
||
```typescript
|
||
// В forms
|
||
<Input
|
||
name="newField"
|
||
value={formData.newField}
|
||
onChange={handleInputChange}
|
||
/>
|
||
|
||
// В отображении
|
||
<span>{service.newField}</span>
|
||
```
|
||
|
||
### ДОБАВЛЕНИЕ НОВОГО ТИПА УСЛУГ
|
||
|
||
#### 1. СОЗДАТЬ НОВУЮ PRISMA МОДЕЛЬ:
|
||
```prisma
|
||
model FulfillmentNewType {
|
||
id String @id @default(cuid())
|
||
fulfillmentId String
|
||
// ... специфичные поля
|
||
|
||
fulfillment Organization @relation("FulfillmentNewTypeV2", fields: [fulfillmentId], references: [id])
|
||
|
||
@@index([fulfillmentId])
|
||
@@map("fulfillment_new_type_v2")
|
||
}
|
||
```
|
||
|
||
#### 2. СОЗДАТЬ РЕЗОЛВЕРЫ:
|
||
```typescript
|
||
// fulfillment-new-type-v2.ts
|
||
export const fulfillmentNewTypeQueries = {
|
||
myFulfillmentNewType: async (_, __, context) => {
|
||
// Логика запроса
|
||
},
|
||
}
|
||
|
||
export const fulfillmentNewTypeMutations = {
|
||
createFulfillmentNewType: async (_, { input }, context) => {
|
||
// Логика создания
|
||
},
|
||
}
|
||
```
|
||
|
||
#### 3. ПОДКЛЮЧИТЬ К ОСНОВНЫМ РЕЗОЛВЕРАМ:
|
||
```typescript
|
||
// В index.ts
|
||
import { fulfillmentNewTypeQueries, fulfillmentNewTypeMutations } from './fulfillment-new-type-v2'
|
||
|
||
const mergedResolvers = mergeResolvers(
|
||
// ... существующие резолверы
|
||
{
|
||
Query: fulfillmentNewTypeQueries,
|
||
Mutation: fulfillmentNewTypeMutations,
|
||
},
|
||
)
|
||
```
|
||
|
||
#### 4. СОЗДАТЬ UI КОМПОНЕНТ:
|
||
```typescript
|
||
// new-type-tab.tsx
|
||
export function NewTypeTab() {
|
||
const { data } = useQuery(GET_MY_FULFILLMENT_NEW_TYPE_V2)
|
||
const newTypeItems = data?.myFulfillmentNewType || []
|
||
|
||
return (
|
||
<div className="new-type-management">
|
||
{/* UI для управления новым типом */}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### ИНТЕГРАЦИЯ С ВНЕШНИМИ СИСТЕМАМИ
|
||
|
||
```typescript
|
||
// Пример: интеграция с внешним API
|
||
const createFulfillmentServiceWithExternalSync = async (input, context) => {
|
||
// 1. Создать в локальной БД
|
||
const service = await prisma.fulfillmentService.create({
|
||
data: { ...input, fulfillmentId: context.user.organization.id }
|
||
})
|
||
|
||
// 2. Синхронизировать с внешней системой
|
||
try {
|
||
await ExternalAPI.syncService(service)
|
||
} catch (error) {
|
||
console.warn('External sync failed:', error)
|
||
// Продолжаем работу без внешней синхронизации
|
||
}
|
||
|
||
return { success: true, service }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🎯 ЛУЧШИЕ ПРАКТИКИ V2
|
||
|
||
### 1. ИМЕНОВАНИЕ
|
||
- **Модели**: `FulfillmentServiceType` - ясное доменное имя
|
||
- **Резолверы**: `myFulfillmentServices` - владение + тип данных
|
||
- **Файлы**: `fulfillment-services-v2.ts` - домен + версия
|
||
|
||
### 2. СТРУКТУРА ДАННЫХ
|
||
- **Всегда включать `fulfillmentId`** для доменной изоляции
|
||
- **Использовать `Decimal`** для денежных полей
|
||
- **Добавлять `createdAt/updatedAt`** для аудита
|
||
- **Использовать `isActive`** вместо жесткого удаления
|
||
|
||
### 3. БЕЗОПАСНОСТЬ
|
||
- **Фильтровать по `fulfillmentId`** во всех запросах
|
||
- **Валидировать входные данные** на уровне резолверов
|
||
- **Логировать критические операции**
|
||
- **Использовать `try/catch`** с корректной обработкой ошибок
|
||
|
||
### 4. ПРОИЗВОДИТЕЛЬНОСТЬ
|
||
- **Добавлять индексы** на часто используемые поля
|
||
- **Использовать `include`** вместо отдельных запросов
|
||
- **Применять `cache-and-network`** политику Apollo
|
||
- **Мониторить время выполнения** запросов
|
||
|
||
### 5. ПОДДЕРЖИВАЕМОСТЬ
|
||
- **Документировать сложную логику**
|
||
- **Писать unit тесты** для резолверов
|
||
- **Использовать TypeScript** для типизации
|
||
- **Следовать принципу единой ответственности**
|
||
|
||
---
|
||
|
||
## 🔮 ROADMAP V2
|
||
|
||
### ЗАПЛАНИРОВАННЫЕ УЛУЧШЕНИЯ:
|
||
|
||
1. **Q4 2025**: Пагинация для больших списков
|
||
2. **Q1 2026**: Поиск и фильтрация в GraphQL
|
||
3. **Q2 2026**: Bulk операции (массовое создание/обновление)
|
||
4. **Q3 2026**: Интеграция с внешними системами учета
|
||
5. **Q4 2026**: Аналитика и отчеты по услугам
|
||
|
||
### ПОТЕНЦИАЛЬНЫЕ РАСШИРЕНИЯ:
|
||
|
||
- **Версионирование услуг** - отслеживание изменений цен
|
||
- **Категоризация услуг** - группировка по типам
|
||
- **Шаблоны услуг** - быстрое создание похожих услуг
|
||
- **Интеграция с календарем** - планирование предоставления услуг
|
||
- **Система скидок** - гибкое ценообразование
|
||
|
||
---
|
||
|
||
## 📖 ЗАКЛЮЧЕНИЕ
|
||
|
||
V2 архитектура услуг SFERA представляет собой современное, масштабируемое и безопасное решение для управления услугами фулфилмента. Ключевые достижения:
|
||
|
||
**🎯 АРХИТЕКТУРНЫЕ ПРИНЦИПЫ:**
|
||
- Доменная специализация моделей данных
|
||
- Модульность резолверов и компонентов
|
||
- Безопасность на уровне доменов
|
||
- Производительность через оптимизированные индексы
|
||
|
||
**🚀 ТЕХНИЧЕСКИЕ ПРЕИМУЩЕСТВА:**
|
||
- Полная типизация TypeScript
|
||
- Оптимизированные GraphQL запросы
|
||
- Эффективный Apollo Client кэш
|
||
- Comprehensive error handling
|
||
|
||
**🛡️ ГОТОВНОСТЬ К PRODUCTION:**
|
||
- Тщательное тестирование (Unit + Integration + E2E)
|
||
- Monitoring и logging
|
||
- Безопасная обработка ошибок
|
||
- Документированные API
|
||
|
||
Данная архитектура служит эталоном для будущих V2 систем SFERA и может быть адаптирована для других доменов платформы.
|
||
|
||
---
|
||
|
||
_Документ создан: 03.09.2025_
|
||
_Версия: V2.0_
|
||
_Статус: Production Ready_
|
||
_Команда: SFERA Development Team_ |