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>
This commit is contained in:
1136
docs/development/INVENTORY_SYSTEM_ARCHITECTURE.md
Normal file
1136
docs/development/INVENTORY_SYSTEM_ARCHITECTURE.md
Normal file
@ -0,0 +1,1136 @@
|
||||
# 📦 АРХИТЕКТУРА СИСТЕМЫ ИНВЕНТАРЯ 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 различных
|
Reference in New Issue
Block a user