Files
sfera-new/docs/development/V2_ARCHITECTURE_SERVICES.md
Veronika Smirnova cdeee82237 feat: реализовать полную автосинхронизацию V2 системы расходников с nameForSeller и анализ миграции
-  Добавлено поле 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>
2025-09-03 23:17:42 +03:00

32 KiB
Raw Blame History

🏗️ ТЕХНИЧЕСКАЯ ДОКУМЕНТАЦИЯ: V2 АРХИТЕКТУРА УСЛУГ

Статус: PRODUCTION READY
Версия: V2.0
Дата создания: 03.09.2025
Область: Модульная архитектура услуг фулфилмента


🎯 ОБЗОР V2 АРХИТЕКТУРЫ УСЛУГ

ФИЛОСОФИЯ V2:

"Один домен - одна модель - одна ответственность"

V2 архитектура услуг основана на принципе доменной специализации, где каждый тип данных имеет собственную оптимизированную структуру, резолверы и логику обработки.

ОСНОВНЫЕ ПРИНЦИПЫ:

  1. Доменная изоляция - каждый тип услуг в отдельной таблице
  2. Специализированные резолверы - отдельные файлы для каждого домена
  3. Безопасность по умолчанию - автоматическая изоляция по fulfillmentId
  4. Масштабируемость - независимое развитие каждого типа

🗄️ V2 МОДЕЛИ ДАННЫХ

1. FULFILLMENT SERVICE (Услуги)

Назначение: Управление услугами, предоставляемыми фулфилментом

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 (Расходники)

Назначение: Управление расходными материалами фулфилмента

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 (Логистика)

Назначение: Управление логистическими маршрутами

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

СХЕМА ТИПОВ

# ОСНОВНЫЕ ТИПЫ
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

type Query {
  # УСЛУГИ ФУЛФИЛМЕНТА  
  myFulfillmentServices: [FulfillmentService!]!
  fulfillmentServicesByFulfillment(fulfillmentId: ID!): [FulfillmentService!]!
  
  # РАСХОДНИКИ ФУЛФИЛМЕНТА
  myFulfillmentConsumables: [FulfillmentConsumable!]!
  fulfillmentConsumablesByFulfillment(fulfillmentId: ID!): [FulfillmentConsumable!]!
  
  # ЛОГИСТИКА ФУЛФИЛМЕНТА  
  myFulfillmentLogistics: [FulfillmentLogistics!]!
  fulfillmentLogisticsByFulfillment(fulfillmentId: ID!): [FulfillmentLogistics!]!
}

V2 MUTATIONS

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 ТИПЫ

# СОЗДАНИЕ УСЛУГИ
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 ТИПЫ

# УНИВЕРСАЛЬНЫЕ ОТВЕТЫ
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

АРХИТЕКТУРА РЕЗОЛВЕРОВ

// Файл: /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 РЕЗОЛВЕРА

// УСЛУГИ ФУЛФИЛМЕНТА (собственные)
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 РЕЗОЛВЕРА

// СОЗДАНИЕ УСЛУГИ
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,
    }
  }
},

БЕЗОПАСНОСТЬ РЕЗОЛВЕРОВ

ДОМЕННАЯ ИЗОЛЯЦИЯ:

// ВСЕГДА ФИЛЬТРОВАТЬ ПО fulfillmentId
const services = await prisma.fulfillmentService.findMany({
  where: {
    fulfillmentId: user.organization.id, // ← КРИТИЧЕСКАЯ БЕЗОПАСНОСТЬ
    isActive: true,
  },
})

ВАЛИДАЦИЯ ДАННЫХ:

// ПРОВЕРКА ОБЯЗАТЕЛЬНЫХ ПОЛЕЙ
if (!input.name?.trim()) {
  return { success: false, message: 'Название обязательно' }
}

// ПРОВЕРКА БИЗНЕС-ЛОГИКИ  
if (!input.price || input.price <= 0) {
  return { success: false, message: 'Цена должна быть больше нуля' }
}

ОБРАБОТКА ОШИБОК:

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

// Пример: 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

// 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

// Паттерн обновления кэша в 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
            )
          }
        })
      }
    }
  }
})

🚀 ПРОИЗВОДИТЕЛЬНОСТЬ И ОПТИМИЗАЦИЯ

ИНДЕКСЫ БД

-- ОСНОВНЫЕ ИНДЕКСЫ
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 ОПТИМИЗАЦИЯ

// ОПТИМИЗИРОВАННЫЕ QUERY ПОЛИТИКИ
const { data } = useQuery(GET_MY_FULFILLMENT_SERVICES_V2, {
  fetchPolicy: 'cache-and-network',    // Быстрый отклик + актуальность
  errorPolicy: 'all',                  // Показ частичных данных при ошибках
  notifyOnNetworkStatusChange: true,   // UI обратная связь
})

ПАГИНАЦИЯ (ГОТОВНОСТЬ)

# РАСШИРЕННЫЕ QUERIES (для будущих версий)
type Query {
  fulfillmentServices(
    fulfillmentId: ID!
    first: Int
    after: String
    filter: FulfillmentServiceFilter
  ): FulfillmentServiceConnection!
}

type FulfillmentServiceConnection {
  edges: [FulfillmentServiceEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

🔒 БЕЗОПАСНОСТЬ V2

ДОМЕННАЯ ИЗОЛЯЦИЯ

// ПРИНЦИП: Пользователь видит только свои данные
const services = await prisma.fulfillmentService.findMany({
  where: {
    fulfillmentId: user.organization.id, // ← КРИТИЧЕСКАЯ СТРОКА
    isActive: true,
  },
})

ПРОВЕРКА ПРАВ ДОСТУПА

// ДОПОЛНИТЕЛЬНАЯ ПРОВЕРКА ДЛЯ КРИТИЧЕСКИХ ОПЕРАЦИЙ
if (user.organization.type !== 'FULFILLMENT') {
  throw new Error('Доступ запрещен: требуется тип организации FULFILLMENT')
}

ВАЛИДАЦИЯ ВХОДНЫХ ДАННЫХ

// ЗАЩИТА ОТ НЕКОРРЕКТНЫХ ДАННЫХ
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('Цена слишком высокая')
  }
}

САНИТИЗАЦИЯ ДАННЫХ

// ОЧИСТКА ВХОДЯЩИХ ДАННЫХ  
const sanitizedInput = {
  ...input,
  name: input.name?.trim(),
  description: input.description?.trim() || null,
  price: parseFloat(input.price.toFixed(2)), // 2 знака после запятой
}

📈 МОНИТОРИНГ И ОТЛАДКА

ЛОГИРОВАНИЕ ОПЕРАЦИЙ

// В резолверах
console.warn('V2 Service Operation:', {
  operation: 'createFulfillmentService',
  fulfillmentId: user.organization.id,
  serviceName: input.name,
  timestamp: new Date().toISOString(),
})

ERROR HANDLING

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,
  }
}

ПРОИЗВОДИТЕЛЬНОСТЬ МОНИТОРИНГ

// ЗАМЕРЫ ВРЕМЕНИ ВЫПОЛНЕНИЯ
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 ТЕСТЫ РЕЗОЛВЕРОВ

// Пример теста резолвера
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')
      })
    })
  })
})

ИНТЕГРАЦИОННЫЕ ТЕСТЫ

// Тест полного цикла 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 ТЕСТЫ КОМПОНЕНТОВ

// 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 МОДЕЛЬ:

model FulfillmentService {
  // ... существующие поля
  newField       String?      // Новое поле
}

2. ДОБАВИТЬ В GRAPHQL СХЕМУ:

type FulfillmentService {
  # ... существующие поля
  newField: String
}

input CreateFulfillmentServiceInput {
  # ... существующие поля  
  newField: String
}

3. ОБНОВИТЬ РЕЗОЛВЕРЫ:

createFulfillmentService: async (_, { input }, context) => {
  const service = await prisma.fulfillmentService.create({
    data: {
      ...input,
      newField: input.newField, // ← Добавить обработку
      fulfillmentId: user.organization.id,
    },
  })
}

4. ОБНОВИТЬ UI КОМПОНЕНТЫ:

// В forms
<Input 
  name="newField"
  value={formData.newField}
  onChange={handleInputChange}
/>

// В отображении
<span>{service.newField}</span>

ДОБАВЛЕНИЕ НОВОГО ТИПА УСЛУГ

1. СОЗДАТЬ НОВУЮ 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. СОЗДАТЬ РЕЗОЛВЕРЫ:

// fulfillment-new-type-v2.ts
export const fulfillmentNewTypeQueries = {
  myFulfillmentNewType: async (_, __, context) => {
    // Логика запроса
  },
}

export const fulfillmentNewTypeMutations = {
  createFulfillmentNewType: async (_, { input }, context) => {
    // Логика создания
  },
}

3. ПОДКЛЮЧИТЬ К ОСНОВНЫМ РЕЗОЛВЕРАМ:

// В index.ts
import { fulfillmentNewTypeQueries, fulfillmentNewTypeMutations } from './fulfillment-new-type-v2'

const mergedResolvers = mergeResolvers(
  // ... существующие резолверы
  {
    Query: fulfillmentNewTypeQueries,
    Mutation: fulfillmentNewTypeMutations,
  },
)

4. СОЗДАТЬ UI КОМПОНЕНТ:

// 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>
  )
}

ИНТЕГРАЦИЯ С ВНЕШНИМИ СИСТЕМАМИ

// Пример: интеграция с внешним 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