
- ✅ Добавлено поле 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>
1136 lines
41 KiB
Markdown
1136 lines
41 KiB
Markdown
# 📦 АРХИТЕКТУРА СИСТЕМЫ ИНВЕНТАРЯ SFERA
|
||
|
||
> **Версия:** 2.0
|
||
> **Дата:** 2025-09-03
|
||
> **Статус:** Активная разработка (миграция V1→V2)
|
||
|
||
---
|
||
|
||
## 🎯 ОБЗОР СИСТЕМЫ
|
||
|
||
Система инвентаря SFERA представляет собой комплексную архитектуру для управления складскими остатками, ценообразованием и бизнес-процессами между 4 типами организаций: **FULFILLMENT**, **SELLER**, **WHOLESALE**, **LOGIST**.
|
||
|
||
### 🏗️ АРХИТЕКТУРНЫЕ ПРИНЦИПЫ
|
||
|
||
1. **Доменная изоляция** - каждая организация видит только свои данные
|
||
2. **Двойная система учета** - V1 (legacy) + V2 (современная) с автосинхронизацией
|
||
3. **Модульная структура** - независимые резолверы и компоненты
|
||
4. **Типобезопасность** - полная типизация TypeScript + GraphQL
|
||
|
||
---
|
||
|
||
## 📊 МОДЕЛИ ДАННЫХ
|
||
|
||
### 🔄 V2 СИСТЕМА (Актуальная)
|
||
|
||
#### **FulfillmentConsumableInventory** - Складские остатки фулфилмента
|
||
|
||
```prisma
|
||
model FulfillmentConsumableInventory {
|
||
id String @id @default(cuid())
|
||
fulfillmentCenterId String // Изоляция по фулфилменту
|
||
productId String // Связь с каталогом товаров
|
||
|
||
// СКЛАДСКИЕ ДАННЫЕ
|
||
currentStock Int @default(0) // Текущий остаток
|
||
minStock Int @default(0) // Минимальный порог
|
||
maxStock Int? // Максимальный порог
|
||
reservedStock Int @default(0) // Зарезервировано
|
||
totalReceived Int @default(0) // Всего получено
|
||
totalShipped Int @default(0) // Всего отгружено
|
||
|
||
// ЦЕНЫ
|
||
averageCost Decimal @default(0) @db.Decimal(10, 2) // Себестоимость
|
||
resalePrice Decimal? @db.Decimal(10, 2) // Цена селлерам
|
||
|
||
// МЕТАДАННЫЕ
|
||
lastSupplyDate DateTime? // Последняя поставка
|
||
lastUsageDate DateTime? // Последнее использование
|
||
notes String? // Заметки
|
||
|
||
// СВЯЗИ
|
||
fulfillmentCenter Organization @relation(\"FFInventory\")
|
||
product Product @relation(\"InventoryProducts\")
|
||
catalogItems FulfillmentConsumable[] // → Каталог услуг
|
||
|
||
@@unique([fulfillmentCenterId, productId])
|
||
}
|
||
```
|
||
|
||
#### **FulfillmentConsumable** - Каталог услуг фулфилмента
|
||
|
||
```prisma
|
||
model FulfillmentConsumable {
|
||
id String @id @default(cuid())
|
||
fulfillmentId String // Владелец услуги
|
||
inventoryId String? // 🔗 Связь со складом
|
||
|
||
// КАТАЛОЖНЫЕ ДАННЫЕ
|
||
name String // Оригинальное название
|
||
nameForSeller String? // 🆕 Кастомное название для селлеров
|
||
article String? // Артикул
|
||
pricePerUnit Decimal @default(0) @db.Decimal(10, 2) // Цена селлерам
|
||
unit String @default(\"шт\")
|
||
|
||
// СКЛАДСКИЕ ДАННЫЕ (синхронизируются из Inventory)
|
||
currentStock Int @default(0) // Текущий остаток
|
||
minStock Int @default(0) // Минимальный порог
|
||
isAvailable Boolean @default(false) // Доступность
|
||
|
||
// МЕТАДАННЫЕ
|
||
imageUrl String? // Фото товара
|
||
sortOrder Int @default(0) // Порядок сортировки
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// СВЯЗИ
|
||
fulfillment Organization @relation(\"FulfillmentServices\")
|
||
inventory FulfillmentConsumableInventory? @relation(\"CatalogInventory\")
|
||
}
|
||
```
|
||
|
||
#### **SellerGoodsInventory** - Складские остатки товаров селлера
|
||
|
||
```prisma
|
||
model SellerGoodsInventory {
|
||
id String @id @default(cuid())
|
||
sellerId String // Владелец товаров
|
||
fulfillmentCenterId String // Где хранится
|
||
productId String // Что хранится
|
||
|
||
// СКЛАДСКИЕ ДАННЫЕ
|
||
currentStock Int @default(0) // Доступно
|
||
reservedStock Int @default(0) // Зарезервировано
|
||
inPreparationStock Int @default(0) // В подготовке
|
||
totalReceived Int @default(0) // Всего получено
|
||
totalShipped Int @default(0) // Всего отгружено
|
||
|
||
// АВТОЗАКАЗЫ
|
||
minStock Int @default(0) // Порог автозаказа
|
||
maxStock Int? // Максимальный остаток
|
||
|
||
// ЦЕНЫ
|
||
averageCost Decimal @default(0) @db.Decimal(10, 2) // Себестоимость
|
||
salePrice Decimal @default(0) @db.Decimal(10, 2) // Цена продажи
|
||
|
||
// СВЯЗИ
|
||
seller Organization @relation(\"SellerInventory\")
|
||
fulfillmentCenter Organization @relation(\"SellerInventoryWarehouse\")
|
||
product Product @relation(\"SellerInventoryProducts\")
|
||
|
||
@@unique([sellerId, fulfillmentCenterId, productId])
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔄 БИЗНЕС-ПРОЦЕССЫ
|
||
|
||
### 📋 ПОТОК ТОВАРОВ: СЕЛЛЕР → ПОСТАВЩИК → ФУЛФИЛМЕНТ
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant S as Селлер
|
||
participant UI as CreateSupplyForm
|
||
participant API as GraphQL API
|
||
participant W as Поставщик
|
||
participant L as Логистика
|
||
participant F as Фулфилмент
|
||
participant INV as InventorySystem
|
||
|
||
S->>UI: 1. Создает поставку
|
||
UI->>API: 2. createSellerGoodsSupply()
|
||
API->>W: 3. Уведомление о заказе
|
||
W->>API: 4. Одобряет заказ
|
||
API->>L: 5. Назначение логистики
|
||
L->>API: 6. Подтверждение доставки
|
||
API->>F: 7. Уведомление о приемке
|
||
F->>INV: 8. processSupplyOrderReceipt()
|
||
INV->>INV: 9. updateInventory() - V2
|
||
INV->>INV: 10. Автосинхронизация V1
|
||
```
|
||
|
||
### 🎯 ДЕТАЛЬНЫЕ ЭТАПЫ ПРОЦЕССА
|
||
|
||
#### ЭТАП 1: СОЗДАНИЕ ЗАКАЗА (Селлер)
|
||
|
||
**UI Компоненты:**
|
||
```typescript
|
||
// CreateSuppliersSupplyPage/index.tsx
|
||
const CreateSuppliersSupplyPage = () => {
|
||
const [selectedSupplier, setSelectedSupplier] = useState<Partner | null>(null)
|
||
const [selectedGoods, setSelectedGoods] = useState<GoodsItem[]>([])
|
||
const [productRecipes, setProductRecipes] = useState<ProductRecipes>({})
|
||
const [deliverySettings, setDeliverySettings] = useState<DeliverySettings>({})
|
||
|
||
const handleCreateSupply = async () => {
|
||
// Валидация формы
|
||
if (!isFormValid) return
|
||
|
||
// Трансформация V1 → V2
|
||
const v2InputData = adaptV1ToV2Format(formData, selectedGoods, productRecipes)
|
||
|
||
// Создание поставки
|
||
await createSellerGoodsSupply({
|
||
variables: { input: v2InputData }
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
**GraphQL Мутация:**
|
||
```graphql
|
||
mutation CreateSellerGoodsSupply($input: CreateSellerGoodsSupplyInput!) {
|
||
createSellerGoodsSupply(input: $input) {
|
||
success
|
||
message
|
||
supplyOrder {
|
||
id
|
||
status
|
||
totalAmount
|
||
seller { id name }
|
||
supplier { id name }
|
||
fulfillmentCenter { id name }
|
||
recipeItems {
|
||
id
|
||
productId
|
||
quantity
|
||
recipeType
|
||
product { name article price }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### ЭТАП 2: ОБРАБОТКА ЗАКАЗА (Backend)
|
||
|
||
**Резолвер:**
|
||
```typescript
|
||
// goods-supply-v2.ts
|
||
createSellerGoodsSupply: async (_, { input }, context) => {
|
||
// 1. Валидация пользователя
|
||
const user = await validateSellerUser(context)
|
||
|
||
// 2. Проверка партнерских отношений
|
||
const fulfillmentPartner = await validatePartnership(
|
||
user.organizationId,
|
||
input.fulfillmentCenterId,
|
||
'FULFILLMENT'
|
||
)
|
||
|
||
// 3. Создание поставки
|
||
const supplyOrder = await prisma.sellerGoodsSupplyOrder.create({
|
||
data: {
|
||
sellerId: user.organizationId,
|
||
fulfillmentCenterId: input.fulfillmentCenterId,
|
||
supplierId: input.supplierId,
|
||
status: 'PENDING',
|
||
requestedDeliveryDate: new Date(input.requestedDeliveryDate),
|
||
notes: input.notes,
|
||
totalAmount: calculateTotalAmount(input.recipeItems),
|
||
|
||
// 4. Нормализованная рецептура
|
||
recipeItems: {
|
||
create: input.recipeItems.map(item => ({
|
||
productId: item.productId,
|
||
quantity: item.quantity,
|
||
recipeType: item.recipeType,
|
||
unitPrice: item.unitPrice,
|
||
totalPrice: item.unitPrice * item.quantity,
|
||
}))
|
||
}
|
||
},
|
||
include: {
|
||
recipeItems: { include: { product: true }},
|
||
seller: true,
|
||
supplier: true,
|
||
fulfillmentCenter: true
|
||
}
|
||
})
|
||
|
||
// 5. Уведомления участникам
|
||
await notifyOrderCreated(supplyOrder)
|
||
|
||
return {
|
||
success: true,
|
||
message: 'Поставка успешно создана',
|
||
supplyOrder
|
||
}
|
||
}
|
||
```
|
||
|
||
#### ЭТАП 3: ПРИЕМКА НА СКЛАДЕ (Фулфилмент)
|
||
|
||
**Мутация приемки:**
|
||
```typescript
|
||
// fulfillment-goods-v2.ts
|
||
receiveSellerGoodsSupply: async (_, { input }, context) => {
|
||
// 1. Обновление статуса поставки
|
||
const supplyOrder = await prisma.sellerGoodsSupplyOrder.update({
|
||
where: { id: input.supplyOrderId },
|
||
data: {
|
||
status: 'DELIVERED',
|
||
deliveredAt: new Date(),
|
||
actualPackagesCount: input.actualPackagesCount,
|
||
actualVolume: input.actualVolume
|
||
}
|
||
})
|
||
|
||
// 2. Обработка приемки через inventory-management
|
||
await processSellerGoodsSupplyReceipt(
|
||
input.supplyOrderId,
|
||
input.receivedItems // [ { productId, receivedQuantity, unitPrice } ]
|
||
)
|
||
}
|
||
```
|
||
|
||
**Управление инвентарем:**
|
||
```typescript
|
||
// inventory-management-goods.ts
|
||
export async function processSellerGoodsSupplyReceipt(
|
||
supplyOrderId: string,
|
||
items: ReceivedItem[]
|
||
): Promise<void> {
|
||
for (const item of items) {
|
||
// Обновляем инвентарь товаров селлера
|
||
await updateSellerGoodsInventory({
|
||
sellerId: supplyOrder.sellerId,
|
||
fulfillmentCenterId: supplyOrder.fulfillmentCenterId,
|
||
productId: item.productId,
|
||
quantity: item.receivedQuantity,
|
||
type: 'INCOMING',
|
||
sourceType: 'SUPPLY_ORDER',
|
||
unitCost: item.unitPrice
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 💰 СИСТЕМА ЦЕНООБРАЗОВАНИЯ
|
||
|
||
### 🎯 ДВУХУРОВНЕВАЯ СИСТЕМА ЦЕН
|
||
|
||
#### **1. СЕБЕСТОИМОСТЬ (averageCost)**
|
||
```typescript
|
||
// Рассчитывается автоматически методом взвешенной средней
|
||
async function recalculateAverageCost(
|
||
inventoryId: string,
|
||
newQuantity: number,
|
||
newUnitCost: number
|
||
): Promise<void> {
|
||
const inventory = await prisma.fulfillmentConsumableInventory.findUnique({
|
||
where: { id: inventoryId }
|
||
})
|
||
|
||
const oldTotalValue = inventory.averageCost * (inventory.currentStock - newQuantity)
|
||
const newTotalValue = newUnitCost * newQuantity
|
||
const totalQuantity = inventory.currentStock
|
||
|
||
const newAverageCost = (oldTotalValue + newTotalValue) / totalQuantity
|
||
|
||
await prisma.fulfillmentConsumableInventory.update({
|
||
where: { id: inventoryId },
|
||
data: { averageCost: newAverageCost }
|
||
})
|
||
}
|
||
```
|
||
|
||
#### **2. ЦЕНА ДЛЯ СЕЛЛЕРОВ (resalePrice/pricePerUnit)**
|
||
```typescript
|
||
// Устанавливается фулфилментом вручную
|
||
updateFulfillmentConsumable: async (_, { input }) => {
|
||
await prisma.fulfillmentConsumable.update({
|
||
where: { id: input.id },
|
||
data: {
|
||
pricePerUnit: input.pricePerUnit, // Цена для селлеров
|
||
nameForSeller: input.nameForSeller // Название для селлеров
|
||
}
|
||
})
|
||
|
||
// 🔄 Синхронизация с системой инвентаря
|
||
await prisma.fulfillmentConsumableInventory.update({
|
||
where: { id: catalogItem.inventoryId },
|
||
data: { resalePrice: input.pricePerUnit }
|
||
})
|
||
}
|
||
```
|
||
|
||
#### **3. МАРЖА И АНАЛИТИКА**
|
||
```typescript
|
||
interface PriceAnalytics {
|
||
averageCost: number // Себестоимость
|
||
resalePrice: number // Цена продажи
|
||
margin: number // Абсолютная маржа
|
||
marginPercent: number // Процент маржи
|
||
turnoverDays: number // Оборачиваемость в днях
|
||
}
|
||
|
||
const calculateMargin = (averageCost: number, resalePrice: number) => ({
|
||
margin: resalePrice - averageCost,
|
||
marginPercent: ((resalePrice - averageCost) / averageCost) * 100
|
||
})
|
||
|
||
// Пример: себестоимость 15.50₽, цена 25.00₽ = 61% маржа
|
||
```
|
||
|
||
---
|
||
|
||
## 🔄 АВТОСИНХРОНИЗАЦИЯ V1 ↔ V2
|
||
|
||
### 🎯 СТРАТЕГИЯ СИНХРОНИЗАЦИИ
|
||
|
||
```typescript
|
||
// inventory-management.ts - основная логика синхронизации
|
||
export async function updateInventory(movement: InventoryMovement): Promise<void> {
|
||
// 1. Обновление V2 системы (источник истины)
|
||
const inventory = await prisma.fulfillmentConsumableInventory.upsert({
|
||
where: {
|
||
fulfillmentCenterId_productId: {
|
||
fulfillmentCenterId: movement.fulfillmentCenterId,
|
||
productId: movement.productId,
|
||
},
|
||
},
|
||
create: {
|
||
fulfillmentCenterId: movement.fulfillmentCenterId,
|
||
productId: movement.productId,
|
||
currentStock: movement.quantity,
|
||
totalReceived: movement.type === 'INCOMING' ? movement.quantity : 0,
|
||
totalShipped: movement.type === 'OUTGOING' ? movement.quantity : 0,
|
||
averageCost: movement.unitCost || 0,
|
||
lastSupplyDate: movement.type === 'INCOMING' ? new Date() : undefined,
|
||
lastUsageDate: movement.type === 'OUTGOING' ? new Date() : undefined,
|
||
},
|
||
update: {
|
||
currentStock: {
|
||
increment: movement.type === 'INCOMING' ? movement.quantity : -movement.quantity,
|
||
},
|
||
totalReceived: movement.type === 'INCOMING'
|
||
? { increment: movement.quantity }
|
||
: undefined,
|
||
totalShipped: movement.type === 'OUTGOING'
|
||
? { increment: movement.quantity }
|
||
: undefined,
|
||
lastSupplyDate: movement.type === 'INCOMING' ? new Date() : undefined,
|
||
lastUsageDate: movement.type === 'OUTGOING' ? new Date() : undefined,
|
||
},
|
||
include: { product: true },
|
||
})
|
||
|
||
// 2. 🔄 АВТОСИНХРОНИЗАЦИЯ V1 ← V2 (при поступлении товаров)
|
||
if (movement.type === 'INCOMING') {
|
||
try {
|
||
const existingCatalogItem = await prisma.fulfillmentConsumable.findFirst({
|
||
where: {
|
||
fulfillmentId: movement.fulfillmentCenterId,
|
||
name: inventory.product.name,
|
||
},
|
||
})
|
||
|
||
if (existingCatalogItem) {
|
||
// Обновляем существующую запись в каталоге
|
||
await prisma.fulfillmentConsumable.update({
|
||
where: { id: existingCatalogItem.id },
|
||
data: {
|
||
currentStock: inventory.currentStock,
|
||
isAvailable: inventory.currentStock > 0,
|
||
inventoryId: inventory.id,
|
||
updatedAt: new Date(),
|
||
},
|
||
})
|
||
} else {
|
||
// Создаем новую запись в каталоге
|
||
await prisma.fulfillmentConsumable.create({
|
||
data: {
|
||
fulfillmentId: movement.fulfillmentCenterId,
|
||
inventoryId: inventory.id,
|
||
name: inventory.product.name,
|
||
article: inventory.product.article || '',
|
||
pricePerUnit: 0, // Цену устанавливает фулфилмент вручную
|
||
unit: inventory.product.unit || 'шт',
|
||
minStock: inventory.minStock,
|
||
currentStock: inventory.currentStock,
|
||
isAvailable: inventory.currentStock > 0,
|
||
imageUrl: inventory.product.imageUrl,
|
||
sortOrder: 0,
|
||
},
|
||
})
|
||
}
|
||
} catch (syncError) {
|
||
console.error('⚠️ Failed to sync FulfillmentConsumable:', syncError)
|
||
// Не останавливаем процесс, если синхронизация не удалась
|
||
}
|
||
}
|
||
|
||
// 3. Пересчет средневзвешенной стоимости
|
||
if (movement.type === 'INCOMING' && movement.unitCost) {
|
||
await recalculateAverageCost(inventory.id, movement.quantity, movement.unitCost)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 📊 ТРИГГЕРЫ СИНХРОНИЗАЦИИ
|
||
|
||
```typescript
|
||
// Когда срабатывает автосинхронизация:
|
||
|
||
1. **Поступление товаров** → updateInventory(type: 'INCOMING')
|
||
├── Обновляет FulfillmentConsumableInventory
|
||
└── Создает/обновляет FulfillmentConsumable
|
||
|
||
2. **Использование товаров** → updateInventory(type: 'OUTGOING')
|
||
├── Обновляет FulfillmentConsumableInventory
|
||
└── Обновляет FulfillmentConsumable.currentStock
|
||
|
||
3. **Изменение цен** → updateFulfillmentConsumable()
|
||
├── Обновляет FulfillmentConsumable.pricePerUnit
|
||
└── Синхронизирует с FulfillmentConsumableInventory.resalePrice
|
||
|
||
4. **Одноразовая миграция** → syncInventoryToCatalog()
|
||
└── Переносит все данные из Системы B в Систему A
|
||
```
|
||
|
||
---
|
||
|
||
## 🔧 ТЕХНИЧЕСКИЕ ДЕТАЛИ
|
||
|
||
### 📁 СТРУКТУРА ФАЙЛОВ
|
||
|
||
```bash
|
||
# INVENTORY CORE
|
||
/src/lib/inventory-management.ts # Ядро системы управления остатками
|
||
/src/lib/inventory-management-goods.ts # Управление товарным инвентарем
|
||
/src/scripts/sync-inventory-to-catalog.ts # Скрипт одноразовой синхронизации
|
||
|
||
# GRAPHQL LAYER
|
||
/src/graphql/resolvers/
|
||
├── fulfillment-services-v2.ts # V2 услуги фулфилмента (расходники)
|
||
├── fulfillment-inventory-v2.ts # V2 складские остатки фулфилмента
|
||
├── fulfillment-consumables-v2.ts # V2 расходники фулфилмента
|
||
├── seller-inventory-v2.ts # V2 остатки селлеров
|
||
├── goods-supply-v2.ts # V2 товарные поставки
|
||
└── seller-consumables-v2.ts # V2 расходники селлеров
|
||
|
||
/src/graphql/queries/
|
||
├── fulfillment-services-v2.ts # Запросы для услуг фулфилмента
|
||
├── seller-consumables-v2.ts # Запросы для расходников селлера
|
||
└── goods-supply-v2.ts # Запросы для товарных поставок
|
||
|
||
# UI LAYER
|
||
/src/app/seller/create/
|
||
├── goods/page.tsx # Создание товарных поставок
|
||
└── consumables/page.tsx # Создание расходников
|
||
|
||
/src/components/supplies/
|
||
├── create-suppliers/ # Форма создания товарных поставок
|
||
├── create-consumables-supply-page.tsx # Форма создания расходников
|
||
├── supplies-dashboard.tsx # Дашборд поставок
|
||
└── multilevel-supplies-table/ # Таблица поставок
|
||
|
||
/src/components/services/
|
||
├── supplies-tab.tsx # V2 таб расходников фулфилмента
|
||
├── services-tab.tsx # V2 таб услуг
|
||
└── logistics-tab.tsx # V2 таб логистики
|
||
```
|
||
|
||
### 🎨 UI АРХИТЕКТУРА
|
||
|
||
#### **Модульная структура компонентов:**
|
||
```typescript
|
||
// CreateSuppliersSupplyPage - главный компонент
|
||
CreateSuppliersSupplyPage/
|
||
├── blocks/ # UI блоки
|
||
│ ├── SuppliersBlock.tsx # Выбор поставщика
|
||
│ ├── ProductCardsBlock.tsx # Карточки товаров
|
||
│ ├── DetailedCatalogBlock.tsx # Детальный каталог
|
||
│ └── CartBlock.tsx # Корзина
|
||
├── hooks/ # Бизнес-логика
|
||
│ ├── useSupplierSelection.ts # Логика выбора поставщика
|
||
│ ├── useProductCatalog.ts # Управление каталогом
|
||
│ ├── useRecipeBuilder.ts # Построение рецептуры
|
||
│ └── useSupplyCart.ts # Логика корзины
|
||
└── types/ # Типы TypeScript
|
||
└── supply-creation.types.ts
|
||
```
|
||
|
||
#### **Корзина поставки:**
|
||
```typescript
|
||
interface SupplyCartState {
|
||
selectedGoods: GoodsItem[] // Товары в корзине
|
||
productRecipes: Record<string, Recipe> // Рецептуры для каждого товара
|
||
deliverySettings: DeliverySettings // Настройки доставки
|
||
totalAmount: number // Общая сумма заказа
|
||
isFormValid: boolean // Валидность формы
|
||
isSubmitting: boolean // Состояние отправки
|
||
}
|
||
|
||
interface Recipe {
|
||
selectedServices: string[] // ID услуг фулфилмента
|
||
fulfillmentConsumables: ConsumableItem[] // Расходники фулфилмента
|
||
sellerConsumables: ConsumableItem[] // Расходники селлера
|
||
marketplaceCardId?: string // Карточка WB
|
||
}
|
||
```
|
||
|
||
### 🔧 РЕЗОЛВЕРЫ И API
|
||
|
||
#### **V2 API Endpoints:**
|
||
|
||
**Queries (чтение данных):**
|
||
```typescript
|
||
// Для фулфилмента
|
||
myFulfillmentServices // Услуги фулфилмента
|
||
myFulfillmentConsumables // Расходники из каталога
|
||
myFulfillmentLogistics // Логистические маршруты
|
||
myFulfillmentSupplies // Складские остатки (V2)
|
||
|
||
// Для селлера
|
||
mySellerGoodsSupplies // Товарные поставки селлера
|
||
mySellerConsumableSupplies // Поставки расходников селлера
|
||
mySellerGoodsInventory // Остатки товаров на складах
|
||
mySellerConsumableInventory // Остатки расходников
|
||
|
||
// Для поставщика
|
||
mySupplierGoodsOrders // Заказы на товары
|
||
mySupplierConsumableOrders // Заказы на расходники
|
||
|
||
// Универсальные
|
||
counterpartySupplies // Каталог расходников партнеров
|
||
partnersWithServices // Партнеры с их услугами
|
||
```
|
||
|
||
**Mutations (изменение данных):**
|
||
```typescript
|
||
// Создание поставок
|
||
createSellerGoodsSupply // Товарная поставка селлера
|
||
createSellerConsumableSupply // Поставка расходников селлера
|
||
createFulfillmentConsumableSupply // Заказ расходников фулфилментом
|
||
|
||
// Обновление статусов
|
||
updateSupplyOrderStatus // Универсальное обновление статуса
|
||
receiveSellerGoodsSupply // Приемка товаров селлера
|
||
receiveConsumableSupply // Приемка расходников
|
||
|
||
// Управление каталогом
|
||
updateFulfillmentConsumable // Цены и названия расходников
|
||
updateFulfillmentService // Услуги фулфилмента
|
||
updateFulfillmentLogistics // Логистические маршруты
|
||
|
||
// Управление инвентарем
|
||
updateInventoryStock // Ручная корректировка остатков
|
||
transferInventoryBetweenWarehuses // Перемещение между складами
|
||
```
|
||
|
||
---
|
||
|
||
## ⚡ ПРОИЗВОДИТЕЛЬНОСТЬ И ОПТИМИЗАЦИЯ
|
||
|
||
### 🚀 СТРАТЕГИИ ОПТИМИЗАЦИИ
|
||
|
||
#### **1. Batch операции**
|
||
```typescript
|
||
// Групповые обновления для больших поставок
|
||
export async function batchUpdateInventory(
|
||
operations: InventoryOperation[]
|
||
): Promise<void> {
|
||
const operations_grouped = groupBy(operations, 'fulfillmentCenterId')
|
||
|
||
await Promise.all(
|
||
Object.entries(operations_grouped).map(async ([fulfillmentId, ops]) => {
|
||
return await prisma.$transaction(async (tx) => {
|
||
for (const op of ops) {
|
||
await updateInventorySingle(op, tx)
|
||
}
|
||
})
|
||
})
|
||
)
|
||
}
|
||
```
|
||
|
||
#### **2. Кеширование каталогов**
|
||
```typescript
|
||
// Redis кеш для часто запрашиваемых каталогов
|
||
export class CatalogCache {
|
||
private redis = new Redis(process.env.REDIS_URL)
|
||
|
||
async getCachedSupplies(fulfillmentId: string): Promise<Supply[]> {
|
||
const cacheKey = `supplies:${fulfillmentId}`
|
||
const cached = await this.redis.get(cacheKey)
|
||
|
||
if (cached) {
|
||
return JSON.parse(cached)
|
||
}
|
||
|
||
const supplies = await this.fetchFromDB(fulfillmentId)
|
||
await this.redis.setex(cacheKey, 300, JSON.stringify(supplies)) // 5 мин TTL
|
||
return supplies
|
||
}
|
||
}
|
||
```
|
||
|
||
#### **3. Оптимистичные обновления UI**
|
||
```typescript
|
||
// useOptimisticInventory.ts
|
||
export function useOptimisticInventory() {
|
||
const [optimisticUpdates, setOptimisticUpdates] = useState<OptimisticUpdate[]>([])
|
||
|
||
const updateOptimistically = (inventoryId: string, stockDelta: number) => {
|
||
setOptimisticUpdates(prev => [
|
||
...prev,
|
||
{
|
||
inventoryId,
|
||
stockDelta,
|
||
timestamp: Date.now()
|
||
}
|
||
])
|
||
|
||
// Откат через 5 секунд, если сервер не подтвердил
|
||
setTimeout(() => revertOptimisticUpdate(inventoryId), 5000)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🎨 UI/UX PATTERNS
|
||
|
||
### 🧩 КОМПОНЕНТНАЯ АРХИТЕКТУРА
|
||
|
||
#### **1. Smart Components (Containers)**
|
||
```typescript
|
||
// Содержат бизнес-логику и состояние
|
||
export function SuppliesManagementPage() {
|
||
const { supplies, loading, error } = useSuppliesQuery()
|
||
const { updateStock, isUpdating } = useInventoryMutations()
|
||
|
||
return (
|
||
<SuppliesTable
|
||
data={supplies}
|
||
loading={loading}
|
||
onUpdateStock={updateStock}
|
||
/>
|
||
)
|
||
}
|
||
```
|
||
|
||
#### **2. Presentation Components**
|
||
```typescript
|
||
// Чистые UI компоненты без логики
|
||
interface SuppliesTableProps {
|
||
data: Supply[]
|
||
loading: boolean
|
||
onUpdateStock: (id: string, quantity: number) => void
|
||
}
|
||
|
||
export function SuppliesTable({ data, loading, onUpdateStock }: SuppliesTableProps) {
|
||
// Только рендеринг, никакой бизнес-логики
|
||
}
|
||
```
|
||
|
||
#### **3. Custom Hooks**
|
||
```typescript
|
||
// Переиспользуемая бизнес-логика
|
||
export function useSupplyCart() {
|
||
const [cartItems, setCartItems] = useState<CartItem[]>([])
|
||
const [totalAmount, setTotalAmount] = useState(0)
|
||
|
||
const addToCart = useCallback((item: GoodsItem) => {
|
||
setCartItems(prev => [...prev, adaptGoodsToCartItem(item)])
|
||
}, [])
|
||
|
||
const removeFromCart = useCallback((itemId: string) => {
|
||
setCartItems(prev => prev.filter(item => item.id !== itemId))
|
||
}, [])
|
||
|
||
return {
|
||
cartItems,
|
||
totalAmount,
|
||
addToCart,
|
||
removeFromCart,
|
||
clearCart: () => setCartItems([]),
|
||
isCartEmpty: cartItems.length === 0
|
||
}
|
||
}
|
||
```
|
||
|
||
### 🎯 UX PATTERNS
|
||
|
||
#### **1. Progressive Disclosure**
|
||
```typescript
|
||
// Постепенное раскрытие сложности
|
||
<WizardForm>
|
||
<Step1>Выбор поставщика</Step1> {/* Простой выбор */}
|
||
<Step2>Выбор товаров</Step2> {/* Средняя сложность */}
|
||
<Step3>Создание рецептуры</Step3> {/* Высокая сложность */}
|
||
<Step4>Настройки доставки</Step4> {/* Финализация */}
|
||
</WizardForm>
|
||
```
|
||
|
||
#### **2. Optimistic Updates**
|
||
```typescript
|
||
// Мгновенная отзывчивость UI
|
||
const handleStockUpdate = async (itemId: string, newStock: number) => {
|
||
// 1. Мгновенно обновляем UI
|
||
updateUIOptimistically(itemId, newStock)
|
||
|
||
try {
|
||
// 2. Отправляем запрос на сервер
|
||
await updateInventoryStock({ itemId, newStock })
|
||
|
||
// 3. Подтверждаем успех
|
||
confirmOptimisticUpdate(itemId)
|
||
} catch (error) {
|
||
// 4. Откатываем в случае ошибки
|
||
revertOptimisticUpdate(itemId)
|
||
showErrorToast('Не удалось обновить остаток')
|
||
}
|
||
}
|
||
```
|
||
|
||
#### **3. Real-time Feedback**
|
||
```typescript
|
||
// Мгновенная обратная связь пользователю
|
||
<StockInput
|
||
value={currentStock}
|
||
onChange={(newValue) => {
|
||
updateField('currentStock', newValue)
|
||
|
||
// Мгновенная валидация
|
||
if (newValue < minStock) {
|
||
showWarning('Остаток ниже минимального')
|
||
}
|
||
|
||
// Мгновенный расчет
|
||
updateTotalAmount(calculateNewTotal(newValue))
|
||
}}
|
||
validators={[
|
||
(value) => value >= 0 || 'Остаток не может быть отрицательным',
|
||
(value) => value <= maxStock || 'Превышен максимальный остаток'
|
||
]}
|
||
/>
|
||
```
|
||
|
||
---
|
||
|
||
## ⚠️ ПРОБЛЕМЫ И РИСКИ
|
||
|
||
### 🚨 КРИТИЧЕСКИЕ ПРОБЛЕМЫ
|
||
|
||
#### **1. Потенциальная рассинхронизация данных**
|
||
```typescript
|
||
// ПРОБЛЕМА: Если автосинхронизация не сработает
|
||
if (type === 'INCOMING') {
|
||
try {
|
||
await syncWithFulfillmentConsumable(inventory)
|
||
} catch (syncError) {
|
||
console.error('⚠️ Failed to sync:', syncError)
|
||
// Данные могут рассинхрониться между V1 и V2
|
||
}
|
||
}
|
||
|
||
// РЕШЕНИЕ: Добавить систему retry и мониторинг
|
||
const syncWithRetry = async (data, maxRetries = 3) => {
|
||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||
try {
|
||
await syncWithFulfillmentConsumable(data)
|
||
return
|
||
} catch (error) {
|
||
if (attempt === maxRetries) {
|
||
await logSyncFailure(data, error)
|
||
throw error
|
||
}
|
||
await delay(1000 * attempt) // Exponential backoff
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### **2. Дублирование бизнес-логики**
|
||
```typescript
|
||
// ПРОБЛЕМА: Одинаковая логика в разных файлах
|
||
// В supplies-dashboard.tsx
|
||
const calculateTotalAmount = (items) => items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||
|
||
// В useSupplyCart.ts
|
||
const calculateCartTotal = (cartItems) => cartItems.reduce((total, item) => total + (item.unitPrice * item.quantity), 0)
|
||
|
||
// РЕШЕНИЕ: Выносить в utils
|
||
// /src/lib/utils/calculations.ts
|
||
export const calculateSupplyTotal = (items: SupplyItem[]) => {
|
||
return items.reduce((sum, item) => sum + (item.unitPrice * item.quantity), 0)
|
||
}
|
||
```
|
||
|
||
#### **3. Сложность отладки**
|
||
```typescript
|
||
// ПРОБЛЕМА: Логи разбросаны по разным файлам и системам
|
||
console.log('V1 Supply created') // в supplies-dashboard.tsx
|
||
console.warn('V2 Inventory updated') // в inventory-management.ts
|
||
console.error('GraphQL error') // в resolvers.ts
|
||
|
||
// РЕШЕНИЕ: Централизованное логирование
|
||
// /src/lib/logging/inventory-logger.ts
|
||
export class InventoryLogger {
|
||
static operation(system: 'V1' | 'V2', operation: string, data: any) {
|
||
const logEntry = {
|
||
timestamp: new Date().toISOString(),
|
||
system,
|
||
operation,
|
||
data: JSON.stringify(data),
|
||
sessionId: getCurrentSessionId(),
|
||
userId: getCurrentUserId()
|
||
}
|
||
|
||
console.log('📦 INVENTORY:', logEntry)
|
||
|
||
// Отправляем в внешние системы мониторинга
|
||
if (process.env.NODE_ENV === 'production') {
|
||
sendToLogAggregator(logEntry)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### ⚡ ПРОИЗВОДИТЕЛЬНОСТЬ
|
||
|
||
#### **1. N+1 queries проблема**
|
||
```typescript
|
||
// ПРОБЛЕМА: Запросы в цикле
|
||
const supplies = await prisma.supply.findMany()
|
||
for (const supply of supplies) {
|
||
supply.organization = await prisma.organization.findUnique({
|
||
where: { id: supply.organizationId }
|
||
})
|
||
}
|
||
|
||
// РЕШЕНИЕ: Include relations
|
||
const supplies = await prisma.supply.findMany({
|
||
include: {
|
||
organization: true,
|
||
items: {
|
||
include: {
|
||
product: true
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
#### **2. Большие формы с множественными полями**
|
||
```typescript
|
||
// ПРОБЛЕМА: Частые ререндеры при изменении формы
|
||
const [formData, setFormData] = useState(initialFormData)
|
||
|
||
// Каждое изменение поля вызывает ререндер всей формы
|
||
const updateField = (field, value) => {
|
||
setFormData(prev => ({ ...prev, [field]: value }))
|
||
}
|
||
|
||
// РЕШЕНИЕ: React Hook Form с оптимизациями
|
||
const form = useForm({
|
||
defaultValues: initialFormData,
|
||
mode: 'onChange',
|
||
reValidateMode: 'onChange'
|
||
})
|
||
|
||
// Регистрация полей без ререндера родительского компонента
|
||
<Input {...form.register('productName')} />
|
||
```
|
||
|
||
---
|
||
|
||
## 🛠️ РЕКОМЕНДАЦИИ
|
||
|
||
### 🎯 КРАТКОСРОЧНЫЕ (1-2 недели)
|
||
|
||
#### **1. Система мониторинга синхронизации**
|
||
```typescript
|
||
// /src/lib/monitoring/sync-monitor.ts
|
||
export class SyncMonitor {
|
||
static async checkSyncHealth(): Promise<SyncHealth> {
|
||
const v1Count = await prisma.supply.count()
|
||
const v2InventoryCount = await prisma.fulfillmentConsumableInventory.count()
|
||
const v2CatalogCount = await prisma.fulfillmentConsumable.count()
|
||
|
||
const syncDrift = Math.abs(v2InventoryCount - v2CatalogCount)
|
||
|
||
return {
|
||
isHealthy: syncDrift < 5, // Допустимое расхождение
|
||
v1Records: v1Count,
|
||
v2InventoryRecords: v2InventoryCount,
|
||
v2CatalogRecords: v2CatalogCount,
|
||
syncDrift,
|
||
lastSyncCheck: new Date()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### **2. Унификация API для фронтенда**
|
||
```typescript
|
||
// /src/lib/api/unified-inventory.ts
|
||
export class UnifiedInventoryAPI {
|
||
// Единый интерфейс для всех типов инвентаря
|
||
static async getInventory(
|
||
organizationType: OrganizationType,
|
||
organizationId: string,
|
||
filters: InventoryFilters
|
||
): Promise<InventoryItem[]> {
|
||
switch (organizationType) {
|
||
case 'FULFILLMENT':
|
||
return await this.getFulfillmentInventory(organizationId, filters)
|
||
case 'SELLER':
|
||
return await this.getSellerInventory(organizationId, filters)
|
||
default:
|
||
throw new Error(`Unsupported organization type: ${organizationType}`)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### **3. Валидация данных на всех уровнях**
|
||
```typescript
|
||
// /src/lib/validation/inventory-validation.ts
|
||
export const inventoryValidationSchema = z.object({
|
||
productId: z.string().cuid(),
|
||
quantity: z.number().min(0),
|
||
unitCost: z.number().min(0).optional(),
|
||
type: z.enum(['INCOMING', 'OUTGOING']),
|
||
fulfillmentCenterId: z.string().cuid()
|
||
})
|
||
|
||
export function validateInventoryOperation(data: unknown): InventoryOperation {
|
||
return inventoryValidationSchema.parse(data)
|
||
}
|
||
```
|
||
|
||
### 🚀 СРЕДНЕСРОЧНЫЕ (1-2 месяца)
|
||
|
||
#### **1. Микросервисная декомпозиция**
|
||
```typescript
|
||
// Выделение инвентаря в отдельный сервис
|
||
class InventoryService {
|
||
async updateStock(operation: StockOperation): Promise<StockResult>
|
||
async getAnalytics(filters: AnalyticsFilters): Promise<Analytics>
|
||
async validateOperation(operation: StockOperation): Promise<ValidationResult>
|
||
async syncWithExternalSystems(data: SyncData): Promise<void>
|
||
}
|
||
```
|
||
|
||
#### **2. Event-driven архитектура**
|
||
```typescript
|
||
// Асинхронная обработка через события
|
||
export const inventoryEvents = {
|
||
STOCK_UPDATED: 'inventory:stock-updated',
|
||
PRICE_CHANGED: 'inventory:price-changed',
|
||
LOW_STOCK_ALERT: 'inventory:low-stock-alert',
|
||
SYNC_COMPLETED: 'inventory:sync-completed'
|
||
}
|
||
|
||
// Event handlers
|
||
EventBus.on(inventoryEvents.STOCK_UPDATED, async (data) => {
|
||
await updateRelatedSystems(data)
|
||
await triggerAnalyticsRecalculation(data.productId)
|
||
await checkLowStockAlerts(data.fulfillmentCenterId)
|
||
})
|
||
```
|
||
|
||
### 🏗️ ДОЛГОСРОЧНЫЕ (3-6 месяцев)
|
||
|
||
#### **1. Real-time синхронизация**
|
||
```typescript
|
||
// WebSocket интеграция для мгновенных обновлений
|
||
export class InventoryWebSocket {
|
||
static notify(event: InventoryEvent) {
|
||
const channels = [
|
||
`fulfillment:${event.fulfillmentId}`,
|
||
`seller:${event.sellerId}`,
|
||
`admin:global`
|
||
]
|
||
|
||
channels.forEach(channel => {
|
||
webSocketManager.emit(channel, {
|
||
type: event.type,
|
||
data: event.data,
|
||
timestamp: new Date().toISOString()
|
||
})
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
#### **2. Интеграция с внешними системами**
|
||
```typescript
|
||
// 1С/SAP интеграция
|
||
export class ERPIntegration {
|
||
async syncInventoryToERP(inventoryData: InventorySnapshot): Promise<void>
|
||
async importSuppliersFromERP(): Promise<Supplier[]>
|
||
async exportTransactionsToAccounting(period: DateRange): Promise<void>
|
||
}
|
||
|
||
// Маркетплейс интеграция
|
||
export class MarketplaceSync {
|
||
async updateWildberriesStock(productId: string, newStock: number): Promise<void>
|
||
async importOzonOrders(): Promise<Order[]>
|
||
async syncPricesAcrossMarketplaces(): Promise<void>
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 📈 МЕТРИКИ И АНАЛИТИКА
|
||
|
||
### 📊 KPI СИСТЕМЫ ИНВЕНТАРЯ
|
||
|
||
```typescript
|
||
interface InventoryKPI {
|
||
// Основные метрики
|
||
totalProducts: number // Общее количество SKU
|
||
totalStockValue: number // Общая стоимость остатков
|
||
avgTurnoverDays: number // Средняя оборачиваемость
|
||
lowStockItemsCount: number // Товаров с низким остатком
|
||
|
||
// Эффективность
|
||
stockoutFrequency: number // Частота дефицита
|
||
overstockValue: number // Стоимость излишков
|
||
warehouseUtilization: number // Заполненность складов
|
||
|
||
// Финансовые
|
||
avgMarginPercent: number // Средняя маржинальность
|
||
inventoryROI: number // ROI по инвентарю
|
||
costOfGoods: number // Себестоимость товаров
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🎉 ЗАКЛЮЧЕНИЕ
|
||
|
||
Система инвентаря SFERA представляет собой **сложную, но хорошо структурированную архитектуру** с активной миграцией с V1 на V2.
|
||
|
||
**Ключевые достижения:**
|
||
- ✅ Модульная архитектура с четким разделением ответственности
|
||
- ✅ Автосинхронизация между системами V1 и V2
|
||
- ✅ Типобезопасная GraphQL схема
|
||
- ✅ Производительные UI компоненты с оптимистичными обновлениями
|
||
|
||
**Основные вызовы:**
|
||
- ⚠️ Переходный период с дублированием функционала
|
||
- ⚠️ Сложность отладки из-за множественных систем
|
||
- ⚠️ Потенциальная рассинхронизация данных
|
||
|
||
**Следующие шаги:**
|
||
1. Завершить миграцию всех компонентов на V2
|
||
2. Добавить мониторинг синхронизации
|
||
3. Оптимизировать производительность запросов
|
||
4. Интегрировать с внешними системами
|
||
|
||
**📊 Статистика системы:**
|
||
- **Моделей Prisma:** 12+ (инвентарь + связанные)
|
||
- **GraphQL резолверов:** 45+
|
||
- **UI компонентов:** 25+
|
||
- **API endpoints:** 30+
|
||
- **Бизнес-процессов:** 8 основных
|
||
- **Статусов поставок:** 13 различных |