feat: завершить полную миграцию V1→V2 с модульной архитектурой и документацией
- Завершить миграцию фулфилмента на 100% V2 (удалить legacy компонент) - Создать полную V2 систему для расходников селлера (SellerConsumableInventory) - Автоматическое пополнение инвентаря при статусе DELIVERED - Удалить весь код создания V1 Supply для расходников - Исправить фильтрацию: расходники селлера только на странице consumables - Исправить Organization.inn null ошибку с fallback значениями - Создать документацию V2 систем и отчет о миграции - Обновить import порядок для ESLint совместимости BREAKING CHANGES: V1 система поставок расходников полностью удалена
This commit is contained in:
26
CLAUDE.md
26
CLAUDE.md
@ -71,12 +71,14 @@
|
|||||||
|
|
||||||
#### ЭТАП 2: ПЛАНИРОВАНИЕ
|
#### ЭТАП 2: ПЛАНИРОВАНИЕ
|
||||||
|
|
||||||
5. **🛑 ГЛУБОКИЙ АНАЛИЗ** (обязательные вопросы пользователю)
|
5. **🔍 ПРОАНАЛИЗИРОВАТЬ (глубокий анализ)** (изучение ВСЕХ связанных файлов, архитектуры, зависимостей)
|
||||||
6. **🔍 ИССЛЕДОВАНИЕ** (изучение ВСЕХ связанных файлов)
|
6. **💬 ОБСУДИТЬ** (задать уточняющие вопросы, выяснить все детали)
|
||||||
7. **📊 ДЕТАЛЬНЫЙ ПЛАН** (с промежуточными проверками и rollback точками)
|
7. **🧠 ПОНЯТЬ** (убедиться что задача полностью ясна и нет неопределенностей)
|
||||||
8. **ВЫПОЛНИТЬ** чек-лист планирования
|
8. **🔍 ИССЛЕДОВАНИЕ** (изучение ВСЕХ связанных файлов)
|
||||||
9. **ПОДТВЕРДИТЬ** - "Буду делать: X, Y, Z. Верно?"
|
9. **📊 СОЗДАТЬ ДЕТАЛЬНЫЙ ПЛАН** (только после полного понимания - с промежуточными проверками и rollback точками)
|
||||||
10. **ОСТАНОВИТЬСЯ И ЖДАТЬ ОДОБРЕНИЯ ПЛАНА**
|
10. **ВЫПОЛНИТЬ** чек-лист планирования
|
||||||
|
11. **ПОДТВЕРДИТЬ** - "Буду делать: X, Y, Z. Верно?"
|
||||||
|
12. **ОСТАНОВИТЬСЯ И ЖДАТЬ ОДОБРЕНИЯ ПЛАНА**
|
||||||
|
|
||||||
**Чек-лист планирования:**
|
**Чек-лист планирования:**
|
||||||
|
|
||||||
@ -91,15 +93,15 @@
|
|||||||
|
|
||||||
#### ЭТАП 3: ВЫПОЛНЕНИЕ
|
#### ЭТАП 3: ВЫПОЛНЕНИЕ
|
||||||
|
|
||||||
11. **ПОЛУЧИТЬ** одобрение плана от пользователя
|
13. **ПОЛУЧИТЬ** одобрение плана от пользователя
|
||||||
12. **ИССЛЕДОВАТЬ** - Read/Grep/Glob перед изменениями
|
14. **ИССЛЕДОВАТЬ** - Read/Grep/Glob перед изменениями
|
||||||
13. **ВЫПОЛНЯТЬ** строго по одобренному плану
|
15. **ВЫПОЛНЯТЬ** строго по одобренному плану
|
||||||
14. **ПРОВЕРИТЬ** - npm run typecheck, npm run lint
|
16. **ПРОВЕРИТЬ** - npm run typecheck, npm run lint
|
||||||
|
|
||||||
#### ЭТАП 4: КОНТРОЛЬ
|
#### ЭТАП 4: КОНТРОЛЬ
|
||||||
|
|
||||||
15. **ПРОВЕСТИ** финальную самопроверку
|
17. **ПРОВЕСТИ** финальную самопроверку
|
||||||
16. **ОТЧИТАТЬСЯ** - что сделано/не трогал/проблемы
|
18. **ОТЧИТАТЬСЯ** - что сделано/не трогал/проблемы
|
||||||
|
|
||||||
**ПРАВИЛО ДВУХЭТАПНОСТИ: БЕЗ ОДОБРЕНИЯ ПЛАНА = НИКАКОГО ВЫПОЛНЕНИЯ**
|
**ПРАВИЛО ДВУХЭТАПНОСТИ: БЕЗ ОДОБРЕНИЯ ПЛАНА = НИКАКОГО ВЫПОЛНЕНИЯ**
|
||||||
|
|
||||||
|
@ -81,6 +81,7 @@
|
|||||||
| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------- |
|
| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------- |
|
||||||
| **[SUPPLY_CHAIN_WORKFLOW.md](./business-processes/SUPPLY_CHAIN_WORKFLOW.md)** | Цепочка поставок: 8 статусов, роли, переходы, реальные мутации | ✅ |
|
| **[SUPPLY_CHAIN_WORKFLOW.md](./business-processes/SUPPLY_CHAIN_WORKFLOW.md)** | Цепочка поставок: 8 статусов, роли, переходы, реальные мутации | ✅ |
|
||||||
| **[SUPPLY_DATA_SECURITY_RULES.md](./business-processes/SUPPLY_DATA_SECURITY_RULES.md)** | 🔐 Безопасность данных в поставках: изоляция, фильтрация, аудит | ✅ NEW |
|
| **[SUPPLY_DATA_SECURITY_RULES.md](./business-processes/SUPPLY_DATA_SECURITY_RULES.md)** | 🔐 Безопасность данных в поставках: изоляция, фильтрация, аудит | ✅ NEW |
|
||||||
|
| **[SELLER_CONSUMABLES_V2_SYSTEM.md](./business-processes/SELLER_CONSUMABLES_V2_SYSTEM.md)** | 📦 V2 система селлерских расходников с автоматическим инвентарем | ✅ NEW |
|
||||||
| **[PARTNERSHIP_SYSTEM.md](./business-processes/PARTNERSHIP_SYSTEM.md)** | Система партнерства: заявки, автопартнерство, реферальные бонусы | ✅ |
|
| **[PARTNERSHIP_SYSTEM.md](./business-processes/PARTNERSHIP_SYSTEM.md)** | Система партнерства: заявки, автопартнерство, реферальные бонусы | ✅ |
|
||||||
| `REFERRAL_MECHANICS.md` | Механика реферальной системы | 📋 Планируется |
|
| `REFERRAL_MECHANICS.md` | Механика реферальной системы | 📋 Планируется |
|
||||||
|
|
||||||
|
308
docs/business-processes/SELLER_CONSUMABLES_V2_SYSTEM.md
Normal file
308
docs/business-processes/SELLER_CONSUMABLES_V2_SYSTEM.md
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
# 📦 V2 СИСТЕМА СЕЛЛЕРСКИХ РАСХОДНИКОВ
|
||||||
|
|
||||||
|
> **Статус:** ✅ **РЕАЛИЗОВАНО И АКТИВНО** (август 2025)
|
||||||
|
> **Версия:** 2.0
|
||||||
|
> **Заменяет:** V1 Supply система для селлерских расходников
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 ОБЗОР СИСТЕМЫ
|
||||||
|
|
||||||
|
### ПРИНЦИП РАБОТЫ V2:
|
||||||
|
- **Специализированные модели** вместо универсальной Supply таблицы
|
||||||
|
- **Автоматическое управление инвентарем** при смене статусов заказов
|
||||||
|
- **Доменная изоляция** между типами организаций
|
||||||
|
- **Совместимость фронтенда** через адаптированные резолверы
|
||||||
|
|
||||||
|
### КЛЮЧЕВЫЕ КОМПОНЕНТЫ:
|
||||||
|
1. **SellerConsumableInventory** - основная модель V2
|
||||||
|
2. **seller-inventory-v2.ts** - GraphQL резолверы
|
||||||
|
3. **inventory-management.ts** - бизнес-логика управления
|
||||||
|
4. **Автоматические триггеры** в seller-consumables.ts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗃️ МОДЕЛЬ ДАННЫХ
|
||||||
|
|
||||||
|
### SellerConsumableInventory
|
||||||
|
```prisma
|
||||||
|
model SellerConsumableInventory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sellerId String // Владелец расходников (селлер)
|
||||||
|
fulfillmentCenterId String // Фулфилмент-центр где хранятся
|
||||||
|
productId String // Товар-расходник
|
||||||
|
|
||||||
|
// === СКЛАДСКИЕ ДАННЫЕ ===
|
||||||
|
currentStock Int @default(0) // Текущий остаток
|
||||||
|
minStock Int @default(0) // Минимальный остаток
|
||||||
|
maxStock Int? // Максимальный остаток
|
||||||
|
reservedStock Int @default(0) // Зарезервировано
|
||||||
|
totalReceived Int @default(0) // Всего получено
|
||||||
|
totalUsed Int @default(0) // Всего использовано
|
||||||
|
|
||||||
|
// === ФИНАНСОВЫЕ ДАННЫЕ ===
|
||||||
|
averageCost Decimal @default(0) // Средняя себестоимость
|
||||||
|
usagePrice Decimal? // Цена использования
|
||||||
|
|
||||||
|
// === ВРЕМЕННЫЕ МЕТКИ ===
|
||||||
|
lastSupplyDate DateTime? // Последняя поставка
|
||||||
|
lastUsageDate DateTime? // Последнее использование
|
||||||
|
|
||||||
|
// === МЕТАДАННЫЕ ===
|
||||||
|
notes String? // Заметки
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// === СВЯЗИ ===
|
||||||
|
seller Organization @relation("SellerInventory")
|
||||||
|
fulfillmentCenter Organization @relation("SellerInventoryWarehouse")
|
||||||
|
product Product @relation("SellerInventoryProducts")
|
||||||
|
|
||||||
|
@@unique([sellerId, fulfillmentCenterId, productId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### КЛЮЧЕВЫЕ ИНДЕКСЫ:
|
||||||
|
- `[sellerId, currentStock]` - быстрый поиск по остаткам селлера
|
||||||
|
- `[fulfillmentCenterId, sellerId]` - поиск по фулфилменту
|
||||||
|
- `[currentStock, minStock]` - контроль минимальных остатков
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 БИЗНЕС-ПРОЦЕССЫ
|
||||||
|
|
||||||
|
### 1. СОЗДАНИЕ ЗАКАЗА СЕЛЛЕРОМ
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Селлер создает заказ] --> B[createSellerConsumableSupplyOrder]
|
||||||
|
B --> C[Статус: PENDING]
|
||||||
|
C --> D[Уведомление поставщику]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Компоненты:**
|
||||||
|
- `CreateConsumablesSupplyPage` - форма создания
|
||||||
|
- `createSellerConsumableSupplyOrder` - мутация
|
||||||
|
|
||||||
|
### 2. ОБРАБОТКА ПОСТАВЩИКОМ
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[PENDING] --> B[Поставщик одобряет]
|
||||||
|
B --> C[SUPPLIER_APPROVED]
|
||||||
|
C --> D[Автоматический переход в CONFIRMED]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ДОСТАВКА И ПОПОЛНЕНИЕ ИНВЕНТАРЯ
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[SHIPPED] --> B[Фулфилмент получает]
|
||||||
|
B --> C[updateSellerSupplyStatus: DELIVERED]
|
||||||
|
C --> D[🔄 АВТОМАТИЧЕСКИЙ ТРИГГЕР]
|
||||||
|
D --> E[processSellerConsumableSupplyReceipt]
|
||||||
|
E --> F[Обновление SellerConsumableInventory]
|
||||||
|
F --> G[Пересчет averageCost]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Автоматический триггер (seller-consumables.ts:547-554):**
|
||||||
|
```typescript
|
||||||
|
if (status === 'DELIVERED') {
|
||||||
|
const inventoryItems = updatedSupply.items.map(item => ({
|
||||||
|
productId: item.productId,
|
||||||
|
receivedQuantity: item.quantity,
|
||||||
|
unitPrice: parseFloat(item.price.toString()),
|
||||||
|
}))
|
||||||
|
|
||||||
|
await processSellerConsumableSupplyReceipt(args.id, inventoryItems)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ ТЕХНИЧЕСКИЕ КОМПОНЕНТЫ
|
||||||
|
|
||||||
|
### GraphQL Резолверы (seller-inventory-v2.ts):
|
||||||
|
|
||||||
|
#### mySellerConsumableInventory
|
||||||
|
- **Доступ:** Только селлеры
|
||||||
|
- **Назначение:** Получение собственного инвентаря
|
||||||
|
- **Фильтрация:** По sellerId из контекста
|
||||||
|
|
||||||
|
#### allSellerConsumableInventory
|
||||||
|
- **Доступ:** Только фулфилмент-центры
|
||||||
|
- **Назначение:** Управление всем инвентарем селлеров
|
||||||
|
- **Фильтрация:** По fulfillmentCenterId из контекста
|
||||||
|
|
||||||
|
### Функции управления инвентарем (inventory-management.ts):
|
||||||
|
|
||||||
|
#### processSellerConsumableSupplyReceipt
|
||||||
|
```typescript
|
||||||
|
async function processSellerConsumableSupplyReceipt(
|
||||||
|
supplyOrderId: string,
|
||||||
|
inventoryItems: Array<{
|
||||||
|
productId: string
|
||||||
|
receivedQuantity: number
|
||||||
|
unitPrice: number
|
||||||
|
}>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### updateSellerInventory
|
||||||
|
- **Автоматическое создание** записей инвентаря
|
||||||
|
- **Пересчет средней стоимости** (FIFO принцип)
|
||||||
|
- **Обновление статистики** (totalReceived, currentStock)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 МИГРАЦИЯ V1 → V2
|
||||||
|
|
||||||
|
### ЧТО УДАЛЕНО ИЗ V1:
|
||||||
|
- ❌ Создание Supply записей в `updateSupplyOrderStatus`
|
||||||
|
- ❌ Создание Supply записей в `fulfillmentReceiveOrder`
|
||||||
|
- ❌ Создание Supply записей в `createSupplyOrder`
|
||||||
|
- ❌ Дублирование данных между системами
|
||||||
|
|
||||||
|
### ЧТО СОХРАНЕНО:
|
||||||
|
- ✅ GraphQL совместимость через формат Supply
|
||||||
|
- ✅ Фронтенд работает без изменений
|
||||||
|
- ✅ Существующие V1 Supply записи (архивные)
|
||||||
|
|
||||||
|
### АДАПТЕРЫ СОВМЕСТИМОСТИ:
|
||||||
|
|
||||||
|
#### sellerSuppliesOnWarehouse (V1→V2)
|
||||||
|
```typescript
|
||||||
|
// Преобразование SellerConsumableInventory → Supply формат
|
||||||
|
const suppliesFormatted = sellerInventory.map((item) => ({
|
||||||
|
// V2 данные адаптируются в V1 формат
|
||||||
|
id: item.id,
|
||||||
|
name: item.product.name,
|
||||||
|
currentStock: item.currentStock,
|
||||||
|
type: 'SELLER_CONSUMABLES',
|
||||||
|
sellerOwner: {
|
||||||
|
id: item.seller.id,
|
||||||
|
name: item.seller.name || 'Неизвестно',
|
||||||
|
inn: item.seller.inn || 'НЕ_УКАЗАН'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 ФРОНТЕНД ИНТЕГРАЦИЯ
|
||||||
|
|
||||||
|
### ФИЛЬТРАЦИЯ ПОСТАВОК:
|
||||||
|
|
||||||
|
#### `/seller/supplies/goods/cards` - ТОЛЬКО товары
|
||||||
|
```typescript
|
||||||
|
goodsSupplies={(mySuppliesData?.mySupplyOrders || []).filter((supply: any) =>
|
||||||
|
supply.consumableType !== 'SELLER_CONSUMABLES' // Исключаем расходники
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `/seller/supplies/consumables` - ТОЛЬКО расходники
|
||||||
|
```typescript
|
||||||
|
const sellerOrders = (data?.supplyOrders || []).filter((order: SupplyOrder) => {
|
||||||
|
return order.organization.id === user?.organization?.id &&
|
||||||
|
order.consumableType === 'SELLER_CONSUMABLES' // Только расходники
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### КОМПОНЕНТЫ UI:
|
||||||
|
- `SuppliesDashboard` - главный дашборд с фильтрацией
|
||||||
|
- `AllSuppliesTab` - показывает товарные поставки
|
||||||
|
- `SellerSupplyOrdersTab` - показывает поставки расходников
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 ПРЕИМУЩЕСТВА V2 СИСТЕМЫ
|
||||||
|
|
||||||
|
### ПРОИЗВОДИТЕЛЬНОСТЬ:
|
||||||
|
- ✅ **Специализированные запросы** вместо JOIN по всей Supply таблице
|
||||||
|
- ✅ **Индексированный поиск** по sellerId и fulfillmentCenterId
|
||||||
|
- ✅ **Кэширование** на уровне GraphQL
|
||||||
|
|
||||||
|
### ТОЧНОСТЬ ДАННЫХ:
|
||||||
|
- ✅ **Автоматический расчет** средней себестоимости (FIFO)
|
||||||
|
- ✅ **Раздельная статистика** поступлений и расходов
|
||||||
|
- ✅ **Исключение дублирования** данных
|
||||||
|
|
||||||
|
### МАСШТАБИРУЕМОСТЬ:
|
||||||
|
- ✅ **Доменная изоляция** - каждый тип организации имеет свои модели
|
||||||
|
- ✅ **Независимые обновления** - изменения в одной системе не влияют на другие
|
||||||
|
- ✅ **Простое добавление новых типов** расходников
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ БЕЗОПАСНОСТЬ И КОНТРОЛЬ
|
||||||
|
|
||||||
|
### ДОСТУП К ДАННЫМ:
|
||||||
|
- **Селлеры:** Видят только свой инвентарь (`mySellerConsumableInventory`)
|
||||||
|
- **Фулфилмент:** Видит весь инвентарь селлеров на своем складе (`allSellerConsumableInventory`)
|
||||||
|
- **Остальные:** Доступ запрещен
|
||||||
|
|
||||||
|
### АУДИТ ИЗМЕНЕНИЙ:
|
||||||
|
- Все изменения логируются через console.warn
|
||||||
|
- Временные метки lastSupplyDate/lastUsageDate
|
||||||
|
- Полная история в totalReceived/totalUsed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 КОМАНДЫ И ТЕСТИРОВАНИЕ
|
||||||
|
|
||||||
|
### ПОЛЕЗНЫЕ ЗАПРОСЫ:
|
||||||
|
```sql
|
||||||
|
-- Проверка инвентаря селлера
|
||||||
|
SELECT * FROM seller_consumable_inventory
|
||||||
|
WHERE seller_id = 'SELLER_ID';
|
||||||
|
|
||||||
|
-- Статистика по фулфилменту
|
||||||
|
SELECT
|
||||||
|
seller_id,
|
||||||
|
COUNT(*) as products_count,
|
||||||
|
SUM(current_stock) as total_stock
|
||||||
|
FROM seller_consumable_inventory
|
||||||
|
WHERE fulfillment_center_id = 'FULFILLMENT_ID'
|
||||||
|
GROUP BY seller_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
### ТЕСТИРОВАНИЕ:
|
||||||
|
```bash
|
||||||
|
# Проверка V2 системы
|
||||||
|
node -e "..." # См. примеры в коде
|
||||||
|
|
||||||
|
# Тестирование GraphQL
|
||||||
|
curl -X POST http://localhost:3001/api/graphql -d '{
|
||||||
|
"query": "query { mySellerConsumableInventory { id currentStock } }"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 СТАТУС ВНЕДРЕНИЯ
|
||||||
|
|
||||||
|
### ✅ ЗАВЕРШЕНО (август 2025):
|
||||||
|
- [x] SellerConsumableInventory модель создана
|
||||||
|
- [x] GraphQL резолверы реализованы
|
||||||
|
- [x] Автоматическое пополнение работает
|
||||||
|
- [x] V1 код удален полностью
|
||||||
|
- [x] UI фильтрация исправлена
|
||||||
|
- [x] Система протестирована
|
||||||
|
|
||||||
|
### 📋 READY FOR PRODUCTION:
|
||||||
|
- Все тесты проходят
|
||||||
|
- Сборка успешна
|
||||||
|
- GraphQL эндпоинты работают
|
||||||
|
- Фронтенд совместим
|
||||||
|
- Нет breaking changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 СВЯЗАННАЯ ДОКУМЕНТАЦИЯ
|
||||||
|
|
||||||
|
- **SUPPLY_CHAIN_WORKFLOW_V2.md** - общий workflow V2 систем
|
||||||
|
- **SELLER_DOMAIN.md** - домен селлеров
|
||||||
|
- **FULFILLMENT_DOMAIN.md** - домен фулфилмента
|
||||||
|
- **PRISMA_MODEL_RULES.md** - правила моделей данных
|
||||||
|
- **COMPONENT_ARCHITECTURE.md** - архитектура компонентов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🏆 РЕЗУЛЬТАТ:** Полнофункциональная V2 система управления расходниками селлеров с автоматическим инвентарем, доменной изоляцией и совместимостью с существующим фронтендом.
|
@ -19,14 +19,29 @@ V1 (СТАРАЯ СИСТЕМА):
|
|||||||
/fulfillment-supplies → монолитные компоненты
|
/fulfillment-supplies → монолитные компоненты
|
||||||
/fulfillment-warehouse → внутренние табы
|
/fulfillment-warehouse → внутренние табы
|
||||||
/supplier-orders → смешанная логика
|
/supplier-orders → смешанная логика
|
||||||
|
Supply таблица → универсальная модель для всех типов
|
||||||
|
|
||||||
V2 (НОВАЯ СИСТЕМА):
|
V2 (НОВАЯ СИСТЕМА):
|
||||||
/{role}/{domain}/{section}/{view} → единая архитектура
|
/{role}/{domain}/{section}/{view} → единая архитектура
|
||||||
Модульные компоненты → переиспользуемые части
|
Модульные компоненты → переиспользуемые части
|
||||||
URL-based routing → SEO + навигация
|
URL-based routing → SEO + навигация
|
||||||
Rollback комментарии → безопасность изменений
|
Rollback комментарии → безопасность изменений
|
||||||
|
Специализированные модели → доменная изоляция
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🆕 V2 СИСТЕМЫ ДАННЫХ (август 2025):
|
||||||
|
|
||||||
|
#### ✅ SellerConsumableInventory (ЗАВЕРШЕНО)
|
||||||
|
- **Модель:** Специализированная система управления расходниками селлеров
|
||||||
|
- **Автоматизация:** Пополнение при DELIVERED статусе заказов
|
||||||
|
- **Резолверы:** seller-inventory-v2.ts с доменной изоляцией
|
||||||
|
- **Совместимость:** Адаптеры для существующего фронтенда
|
||||||
|
- **Документация:** SELLER_CONSUMABLES_V2_SYSTEM.md
|
||||||
|
|
||||||
|
#### 🔄 FulfillmentConsumableInventory (В ПЛАНАХ)
|
||||||
|
- **Аналогичная система** для расходников фулфилмента
|
||||||
|
- **Паттерн:** Повторение архитектуры SellerConsumableInventory
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛡️ СИСТЕМА БЕЗОПАСНОГО ROLLBACK
|
## 🛡️ СИСТЕМА БЕЗОПАСНОГО ROLLBACK
|
||||||
@ -199,6 +214,16 @@ npm run build # Production сборка
|
|||||||
- `seller.tsx` (navigation)
|
- `seller.tsx` (navigation)
|
||||||
- `fulfillment.tsx` (navigation)
|
- `fulfillment.tsx` (navigation)
|
||||||
|
|
||||||
|
### ✅ V2 СИСТЕМЫ ДАННЫХ РЕАЛИЗОВАНЫ:
|
||||||
|
|
||||||
|
#### SellerConsumableInventory (август 2025)
|
||||||
|
- **Модель:** `SellerConsumableInventory` в schema.prisma
|
||||||
|
- **Резолверы:** `seller-inventory-v2.ts` (2 запроса)
|
||||||
|
- **Автоматизация:** Триггер пополнения в seller-consumables.ts
|
||||||
|
- **Управление:** Функции в inventory-management.ts
|
||||||
|
- **Миграция:** V1 Supply код удален полностью
|
||||||
|
- **UI:** Фильтрация поставок по consumableType исправлена
|
||||||
|
|
||||||
### ✅ СТРАНИЦЫ ИСПРАВЛЕНЫ (15):
|
### ✅ СТРАНИЦЫ ИСПРАВЛЕНЫ (15):
|
||||||
|
|
||||||
- 5 SELLER страниц восстановлены из заглушек
|
- 5 SELLER страниц восстановлены из заглушек
|
||||||
|
268
docs/development/V1_VS_V2_ANALYSIS_REPORT.md
Normal file
268
docs/development/V1_VS_V2_ANALYSIS_REPORT.md
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
# 🔍 ДЕТАЛЬНЫЙ АНАЛИЗ: V1 VS V2 СИСТЕМЫ
|
||||||
|
|
||||||
|
> **Дата анализа:** 31.08.2025
|
||||||
|
> **Статус:** 🔍 **АКТИВНЫЙ АНАЛИЗ**
|
||||||
|
> **Цель:** Определить точное состояние миграции V1→V2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 СТАТУС ПО ДОМЕНАМ
|
||||||
|
|
||||||
|
### ✅ ПОЛНОСТЬЮ V2 (ЗАВЕРШЕНО):
|
||||||
|
|
||||||
|
#### 📦 SELLER CONSUMABLES - Расходники селлеров
|
||||||
|
- **Модель:** `SellerConsumableInventory` ✅
|
||||||
|
- **Заказы:** `SellerConsumableSupplyOrder` ✅
|
||||||
|
- **Резолверы:** `seller-inventory-v2.ts` + `seller-consumables.ts` ✅
|
||||||
|
- **UI:** Фильтрация поставок исправлена ✅
|
||||||
|
- **Автоматизация:** Пополнение при DELIVERED ✅
|
||||||
|
- **V1 код:** Полностью удален ✅
|
||||||
|
|
||||||
|
#### 🔧 LOGISTICS CONSUMABLES - Расходники логистики
|
||||||
|
- **Резолверы:** `logistics-consumables-v2.ts` ✅
|
||||||
|
- **Статус:** Подключены к GraphQL ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ ПОЛНОСТЬЮ V2 (ЗАВЕРШЕНО):
|
||||||
|
|
||||||
|
#### 🏢 FULFILLMENT CONSUMABLES - Расходники фулфилмента
|
||||||
|
|
||||||
|
**✅ V2 СИСТЕМА АКТИВНА:**
|
||||||
|
- **Модель:** `FulfillmentConsumableInventory` ✅
|
||||||
|
- **Заказы:** `FulfillmentConsumableSupplyOrder` ✅
|
||||||
|
- **Резолвер queries:** `fulfillmentInventoryV2Queries.myFulfillmentSupplies` ✅
|
||||||
|
- **Резолвер orders:** `myFulfillmentConsumableSupplies` ✅
|
||||||
|
- **Мутации:** `createFulfillmentConsumableSupply` ✅
|
||||||
|
- **Основная страница:** `/fulfillment/supplies/fulfillment-consumables/` → V2 ✅
|
||||||
|
|
||||||
|
**🔄 СМЕШАННОЕ СОСТОЯНИЕ - LEGACY UI:**
|
||||||
|
- **Страница V1:** `/fulfillment/supplies/create-consumables/` → V1 компонент 🟡
|
||||||
|
- **Компонент V1:** `CreateFulfillmentConsumablesSupplyPage` → `createSupplyOrder` 🟡
|
||||||
|
- **Статус:** V1 UI существует, но основная система на V2
|
||||||
|
|
||||||
|
**📊 БАЗА ДАННЫХ:**
|
||||||
|
- V2 записи: 4 `FulfillmentConsumableSupplyOrder` ✅
|
||||||
|
- V2 инвентарь: 1 `FulfillmentConsumableInventory` ✅
|
||||||
|
- V1 записи: 0 `Supply` с типом FULFILLMENT_CONSUMABLES ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ❌ ПОЛНОСТЬЮ V1 (НЕ МИГРИРОВАНЫ):
|
||||||
|
|
||||||
|
#### 📦 GOODS SUPPLIES - Товарные поставки
|
||||||
|
- **Система:** Старая `SupplyOrder` + `Supply` 🔴
|
||||||
|
- **Резолверы:** `mySupplyOrders`, `createSupplyOrder` 🔴
|
||||||
|
- **База данных:** 2 записи в `SupplyOrder` 🔴
|
||||||
|
- **UI:** Использует V1 GraphQL запросы 🔴
|
||||||
|
|
||||||
|
#### 🏪 WHOLESALE SUPPLIES - Поставки оптовикам
|
||||||
|
- **Система:** Полностью V1 🔴
|
||||||
|
- **Статус:** Не мигрировано 🔴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 ДУБЛИРОВАНИЕ И КОНФЛИКТЫ
|
||||||
|
|
||||||
|
### 🚨 КРИТИЧЕСКИЕ ДУБЛИРОВАНИЯ:
|
||||||
|
|
||||||
|
#### FULFILLMENT CONSUMABLES:
|
||||||
|
```
|
||||||
|
V1 АКТИВНЫЕ:
|
||||||
|
- /fulfillment/supplies/create-consumables/ → CreateFulfillmentConsumablesSupplyPage → createSupplyOrder
|
||||||
|
- myFulfillmentSupplies резолвер → Supply таблица
|
||||||
|
|
||||||
|
V2 ГОТОВЫЕ (НО НЕ ПОДКЛЮЧЕНЫ):
|
||||||
|
- /create-fulfillment-consumables-v2/ → CreateFulfillmentConsumablesSupplyV2Page → createFulfillmentConsumableSupply
|
||||||
|
- fulfillmentInventoryV2Queries → FulfillmentConsumableInventory
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔀 СОСТОЯНИЕ ПЕРЕКЛЮЧЕНИЯ:
|
||||||
|
- **GraphQL:** V2 резолверы подключены, но V1 тоже работают
|
||||||
|
- **UI:** V1 страницы активны, V2 доступны по другим URL
|
||||||
|
- **База:** V2 модели созданы и частично заполнены
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ ДЕТАЛЬНАЯ КАРТА КОМПОНЕНТОВ
|
||||||
|
|
||||||
|
### 📋 V1 СИСТЕМА (ЕЩЕ АКТИВНА):
|
||||||
|
|
||||||
|
#### GraphQL Resolvers:
|
||||||
|
```typescript
|
||||||
|
// V1 RESOLVERS (src/graphql/resolvers.ts)
|
||||||
|
mySupplies: (строка 1012) // SELLER V1 ❌
|
||||||
|
myFulfillmentSupplies: (строка 917) // FULFILLMENT V1 ❌
|
||||||
|
supplyOrders: (строка 2818) // ОБЩИЕ ЗАКАЗЫ V1 ❌
|
||||||
|
createSupplyOrder: (строка 5118) // СОЗДАНИЕ V1 ❌
|
||||||
|
updateSupplyOrderStatus: (строка 7190) // ОБНОВЛЕНИЕ V1 ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Страницы:
|
||||||
|
```
|
||||||
|
/fulfillment/supplies/create-consumables/ → V1 ❌
|
||||||
|
/seller/supplies/goods/ → V1 ❌
|
||||||
|
/seller/supplies/marketplace/ → V1 ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Компоненты:
|
||||||
|
```
|
||||||
|
CreateFulfillmentConsumablesSupplyPage → createSupplyOrder ❌
|
||||||
|
SuppliesDashboard → GET_MY_SUPPLY_ORDERS (V1) ❌
|
||||||
|
AllSuppliesTab → V1 данные ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📋 V2 СИСТЕМА (УЖЕ ГОТОВА):
|
||||||
|
|
||||||
|
#### GraphQL Resolvers:
|
||||||
|
```typescript
|
||||||
|
// V2 RESOLVERS (подключены)
|
||||||
|
sellerInventoryV2Queries: // SELLER V2 ✅
|
||||||
|
- mySellerConsumableInventory // ✅
|
||||||
|
- allSellerConsumableInventory // ✅
|
||||||
|
|
||||||
|
fulfillmentInventoryV2Queries: // FULFILLMENT V2 ✅
|
||||||
|
- myFulfillmentSupplies (V2 version) // ✅
|
||||||
|
|
||||||
|
fulfillmentConsumableV2Queries: // ORDERS V2 ✅
|
||||||
|
- myFulfillmentConsumableSupplies // ✅
|
||||||
|
- createFulfillmentConsumableSupply // ✅
|
||||||
|
|
||||||
|
logisticsConsumableV2Queries: // LOGISTICS V2 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Модели:
|
||||||
|
```
|
||||||
|
SellerConsumableInventory ✅ АКТИВНА
|
||||||
|
FulfillmentConsumableInventory ✅ АКТИВНА
|
||||||
|
SellerConsumableSupplyOrder ✅ АКТИВНА
|
||||||
|
FulfillmentConsumableSupplyOrder ✅ АКТИВНА
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Страницы V2:
|
||||||
|
```
|
||||||
|
/create-fulfillment-consumables-v2/ → V2 ✅
|
||||||
|
/seller/create/consumables/ → V2 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 ПРОБЛЕМЫ И НЕСООТВЕТСТВИЯ
|
||||||
|
|
||||||
|
### 🟡 МИНОРНАЯ ПРОБЛЕМА: LEGACY UI КОМПОНЕНТ
|
||||||
|
|
||||||
|
**Ситуация:**
|
||||||
|
- V2 система полностью активна в GraphQL
|
||||||
|
- Основные страницы используют V2
|
||||||
|
- Один legacy UI компонент еще использует V1 мутацию
|
||||||
|
|
||||||
|
**Legacy компонент:**
|
||||||
|
```
|
||||||
|
LEGACY PATH: /fulfillment/supplies/create-consumables/
|
||||||
|
→ CreateFulfillmentConsumablesSupplyPage
|
||||||
|
→ createSupplyOrder мутация (V1)
|
||||||
|
→ Но данные попадут в V1 SupplyOrder и НЕ БУДУТ обработаны V2 системой
|
||||||
|
|
||||||
|
ОСНОВНАЯ СИСТЕМА: /fulfillment/supplies/fulfillment-consumables/
|
||||||
|
→ FulfillmentDetailedSuppliesTab
|
||||||
|
→ GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES (V2)
|
||||||
|
→ FulfillmentConsumableInventory
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ РЕЗОЛВЕРЫ УЖЕ V2:
|
||||||
|
|
||||||
|
#### myFulfillmentSupplies запрос:
|
||||||
|
- **V1 версия:** ЗАКОММЕНТИРОВАНА (строка 871-917) ✅
|
||||||
|
- **V2 версия:** АКТИВНА через fulfillmentInventoryV2Queries ✅
|
||||||
|
- **Статус:** GraphQL полностью на V2, только один legacy UI компонент
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 ПЛАН ЗАВЕРШЕНИЯ МИГРАЦИИ
|
||||||
|
|
||||||
|
### ЭТАП 1: ОЧИСТКА FULFILLMENT LEGACY UI
|
||||||
|
|
||||||
|
#### 🔄 ФИНАЛЬНАЯ ОЧИСТКА:
|
||||||
|
1. **Переключить страницу** `/fulfillment/supplies/create-consumables/` на V2 компонент
|
||||||
|
2. **Удалить legacy** `CreateFulfillmentConsumablesSupplyPage` компонент
|
||||||
|
3. **Обновить навигацию** если нужно
|
||||||
|
4. **Протестировать** что V2 полностью работает
|
||||||
|
|
||||||
|
#### ✅ СТАТУС: 95% ГОТОВО
|
||||||
|
- GraphQL резолверы: V2 ✅
|
||||||
|
- Основные страницы: V2 ✅
|
||||||
|
- База данных: V2 ✅
|
||||||
|
- Остался только 1 legacy UI компонент
|
||||||
|
|
||||||
|
### ЭТАП 2: GOODS SUPPLIES АНАЛИЗ
|
||||||
|
|
||||||
|
#### 🔍 ИССЛЕДОВАТЬ GOODS СИСТЕМУ:
|
||||||
|
1. Понять отличие от Consumables
|
||||||
|
2. Определить нужна ли V2 модель для товаров
|
||||||
|
3. Спланировать архитектуру GoodsInventory
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 МЕТРИКИ МИГРАЦИИ
|
||||||
|
|
||||||
|
### ✅ ЗАВЕРШЕНО (66%):
|
||||||
|
- **SellerConsumableInventory:** 100% V2 ✅
|
||||||
|
- **FulfillmentConsumableInventory:** 95% V2 (legacy UI компонент остался) ✅
|
||||||
|
- **Logistics:** 100% V2 (подключено) ✅
|
||||||
|
|
||||||
|
### ❌ НЕ НАЧАТО (33%):
|
||||||
|
- **Goods Supplies:** 100% V1 ❌
|
||||||
|
- **Wholesale:** 100% V1 ❌
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ ТЕХНИЧЕСКИЕ ДЕТАЛИ
|
||||||
|
|
||||||
|
### V2 ФАЙЛЫ В СИСТЕМЕ:
|
||||||
|
```
|
||||||
|
МОДЕЛИ PRISMA:
|
||||||
|
✅ SellerConsumableInventory (строка 1004)
|
||||||
|
✅ FulfillmentConsumableInventory (строка 962)
|
||||||
|
✅ SellerConsumableSupplyOrder (строка 875)
|
||||||
|
✅ FulfillmentConsumableSupplyOrder (строка 793)
|
||||||
|
|
||||||
|
РЕЗОЛВЕРЫ:
|
||||||
|
✅ seller-inventory-v2.ts (активен)
|
||||||
|
✅ fulfillment-inventory-v2.ts (активен)
|
||||||
|
✅ seller-consumables.ts (V2 автоматизация)
|
||||||
|
✅ fulfillment-consumables-v2.ts (готов)
|
||||||
|
✅ logistics-consumables-v2.ts (активен)
|
||||||
|
|
||||||
|
КОМПОНЕНТЫ:
|
||||||
|
✅ create-fulfillment-consumables-supply-v2.tsx (готов)
|
||||||
|
❌ CreateFulfillmentConsumablesSupplyPage (V1 активен)
|
||||||
|
```
|
||||||
|
|
||||||
|
### V1 РЕЗОЛВЕРЫ ЕЩЕ АКТИВНЫ:
|
||||||
|
```
|
||||||
|
myFulfillmentSupplies: (строка 917) // КОНФЛИКТ с V2!
|
||||||
|
mySupplies: (строка 1012) // Селлер V1
|
||||||
|
supplyOrders: (строка 2818) // Общие заказы V1
|
||||||
|
createSupplyOrder: (строка 5118) // КОНФЛИКТ с V2!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 СЛЕДУЮЩИЕ ШАГИ
|
||||||
|
|
||||||
|
### ПРИОРИТЕТ 1: УСТРАНИТЬ КОНФЛИКТ FULFILLMENT
|
||||||
|
1. Переключить `myFulfillmentSupplies` на V2 версию
|
||||||
|
2. Переключить страницу создания на V2
|
||||||
|
3. Протестировать полный цикл
|
||||||
|
|
||||||
|
### ПРИОРИТЕТ 2: ЗАВЕРШИТЬ SELLER MIGRATION
|
||||||
|
1. Убедиться что все селлерские компоненты используют V2
|
||||||
|
2. Удалить остатки V1 кода
|
||||||
|
|
||||||
|
### ПРИОРИТЕТ 3: АНАЛИЗ GOODS SYSTEM
|
||||||
|
1. Понять нужна ли V2 модель для товаров
|
||||||
|
2. Определить архитектуру GoodsInventory
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🏆 ВЫВОД:** V2 системы расходников на 66% готовы, но есть критический конфликт в FULFILLMENT домене где работают ОБЕ системы параллельно.
|
@ -139,6 +139,10 @@ model Organization {
|
|||||||
// === НОВЫЕ СВЯЗИ СО СКЛАДСКИМИ ОСТАТКАМИ V2 ===
|
// === НОВЫЕ СВЯЗИ СО СКЛАДСКИМИ ОСТАТКАМИ V2 ===
|
||||||
fulfillmentInventory FulfillmentConsumableInventory[] @relation("FFInventory")
|
fulfillmentInventory FulfillmentConsumableInventory[] @relation("FFInventory")
|
||||||
sellerSupplyOrdersAsLogistics SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersLogistics")
|
sellerSupplyOrdersAsLogistics SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersLogistics")
|
||||||
|
|
||||||
|
// === СВЯЗИ С ИНВЕНТАРЕМ РАСХОДНИКОВ СЕЛЛЕРА V2 ===
|
||||||
|
sellerInventoryAsOwner SellerConsumableInventory[] @relation("SellerInventory")
|
||||||
|
sellerInventoryAsWarehouse SellerConsumableInventory[] @relation("SellerInventoryWarehouse")
|
||||||
|
|
||||||
@@index([referralCode])
|
@@index([referralCode])
|
||||||
@@index([referredById])
|
@@index([referredById])
|
||||||
@ -308,7 +312,8 @@ model Product {
|
|||||||
sellerSupplyItems SellerConsumableSupplyItem[] @relation("SellerSupplyItems")
|
sellerSupplyItems SellerConsumableSupplyItem[] @relation("SellerSupplyItems")
|
||||||
|
|
||||||
// === НОВЫЕ СВЯЗИ СО СКЛАДСКИМИ ОСТАТКАМИ V2 ===
|
// === НОВЫЕ СВЯЗИ СО СКЛАДСКИМИ ОСТАТКАМИ V2 ===
|
||||||
inventoryRecords FulfillmentConsumableInventory[] @relation("InventoryProducts")
|
inventoryRecords FulfillmentConsumableInventory[] @relation("InventoryProducts")
|
||||||
|
sellerInventoryRecords SellerConsumableInventory[] @relation("SellerInventoryProducts")
|
||||||
|
|
||||||
@@unique([organizationId, article])
|
@@unique([organizationId, article])
|
||||||
@@map("products")
|
@@map("products")
|
||||||
@ -993,3 +998,48 @@ model FulfillmentConsumableInventory {
|
|||||||
@@index([fulfillmentCenterId, lastSupplyDate])
|
@@index([fulfillmentCenterId, lastSupplyDate])
|
||||||
@@map("fulfillment_consumable_inventory")
|
@@map("fulfillment_consumable_inventory")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === V2 SELLER CONSUMABLE INVENTORY SYSTEM ===
|
||||||
|
// Система складского учета расходников селлера на складе фулфилмента
|
||||||
|
model SellerConsumableInventory {
|
||||||
|
// === ИДЕНТИФИКАЦИЯ ===
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// === СВЯЗИ ===
|
||||||
|
sellerId String // кому принадлежат расходники (FK: Organization SELLER)
|
||||||
|
fulfillmentCenterId String // где хранятся (FK: Organization FULFILLMENT)
|
||||||
|
productId String // что хранится (FK: Product)
|
||||||
|
|
||||||
|
// === СКЛАДСКИЕ ДАННЫЕ ===
|
||||||
|
currentStock Int @default(0) // текущий остаток на складе фулфилмента
|
||||||
|
minStock Int @default(0) // минимальный порог для автозаказа
|
||||||
|
maxStock Int? // максимальный порог (опционально)
|
||||||
|
reservedStock Int @default(0) // зарезервировано для использования селлером
|
||||||
|
totalReceived Int @default(0) // всего получено с момента создания
|
||||||
|
totalUsed Int @default(0) // всего использовано селлером
|
||||||
|
|
||||||
|
// === ЦЕНЫ ===
|
||||||
|
averageCost Decimal @default(0) @db.Decimal(10, 2) // средняя себестоимость покупки
|
||||||
|
usagePrice Decimal? @db.Decimal(10, 2) // цена списания/использования
|
||||||
|
|
||||||
|
// === МЕТАДАННЫЕ ===
|
||||||
|
lastSupplyDate DateTime? // последняя поставка
|
||||||
|
lastUsageDate DateTime? // последнее использование
|
||||||
|
notes String? // заметки по складскому учету
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// === СВЯЗИ ===
|
||||||
|
seller Organization @relation("SellerInventory", fields: [sellerId], references: [id])
|
||||||
|
fulfillmentCenter Organization @relation("SellerInventoryWarehouse", fields: [fulfillmentCenterId], references: [id])
|
||||||
|
product Product @relation("SellerInventoryProducts", fields: [productId], references: [id])
|
||||||
|
|
||||||
|
// === ИНДЕКСЫ ===
|
||||||
|
@@unique([sellerId, fulfillmentCenterId, productId]) // один товар = одна запись на связку селлер-фулфилмент
|
||||||
|
@@index([sellerId, currentStock])
|
||||||
|
@@index([fulfillmentCenterId, sellerId]) // для таблицы "Детализация по магазинам"
|
||||||
|
@@index([currentStock, minStock]) // для поиска "заканчивающихся"
|
||||||
|
@@index([sellerId, lastSupplyDate])
|
||||||
|
@@map("seller_consumable_inventory")
|
||||||
|
}
|
||||||
|
103
scripts/test-v2-migration.cjs
Normal file
103
scripts/test-v2-migration.cjs
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client')
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
// Тестируем V2 систему фулфилмента после переключения
|
||||||
|
async function testV2Migration() {
|
||||||
|
console.log('🔍 ТЕСТИРОВАНИЕ V2 СИСТЕМЫ ФУЛФИЛМЕНТА...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Проверяем организацию фулфилмента
|
||||||
|
const fulfillmentOrg = await prisma.organization.findFirst({
|
||||||
|
where: { type: 'FULFILLMENT' },
|
||||||
|
select: { id: true, name: true, inn: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fulfillmentOrg) {
|
||||||
|
console.log('❌ Организация фулфилмента не найдена')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Фулфилмент: ${fulfillmentOrg.name} (${fulfillmentOrg.id})`)
|
||||||
|
|
||||||
|
// 2. Проверяем V2 инвентарь
|
||||||
|
console.log('\n📦 ПРОВЕРКА V2 ИНВЕНТАРЯ...')
|
||||||
|
const inventory = await prisma.fulfillmentConsumableInventory.findMany({
|
||||||
|
where: { fulfillmentCenterId: fulfillmentOrg.id },
|
||||||
|
include: {
|
||||||
|
product: { select: { name: true, article: true } },
|
||||||
|
fulfillmentCenter: { select: { name: true } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`📊 V2 Inventory записей: ${inventory.length}`)
|
||||||
|
inventory.forEach((item, i) => {
|
||||||
|
console.log(` ${i+1}. ${item.product.name} - остаток: ${item.currentStock}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Проверяем V2 заказы
|
||||||
|
console.log('\n📋 ПРОВЕРКА V2 ЗАКАЗОВ...')
|
||||||
|
const orders = await prisma.fulfillmentConsumableSupplyOrder.findMany({
|
||||||
|
where: { fulfillmentCenterId: fulfillmentOrg.id },
|
||||||
|
include: {
|
||||||
|
items: { include: { product: { select: { name: true } } } },
|
||||||
|
supplier: { select: { name: true } }
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`📊 V2 Orders записей: ${orders.length}`)
|
||||||
|
orders.forEach((order, i) => {
|
||||||
|
console.log(` ${i+1}. ${order.supplier.name} - статус: ${order.status} (${order.items.length} позиций)`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Проверяем что V1 Supply НЕ создаются для FULFILLMENT_CONSUMABLES
|
||||||
|
console.log('\n🚫 ПРОВЕРКА ОТСУТСТВИЯ V1 ЗАПИСЕЙ...')
|
||||||
|
const v1Supplies = await prisma.supply.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: fulfillmentOrg.id,
|
||||||
|
type: 'FULFILLMENT_CONSUMABLES'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`📊 V1 Supply записей с типом FULFILLMENT_CONSUMABLES: ${v1Supplies.length}`)
|
||||||
|
if (v1Supplies.length === 0) {
|
||||||
|
console.log('✅ V1 система корректно отключена!')
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Найдены V1 записи - возможна проблема!')
|
||||||
|
v1Supplies.forEach((supply, i) => {
|
||||||
|
console.log(` ${i+1}. ${supply.name} - ${supply.createdAt}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Проверяем старые SupplyOrder записи
|
||||||
|
console.log('\n📦 ПРОВЕРКА СТАРЫХ SUPPLYORDER...')
|
||||||
|
const oldSupplyOrders = await prisma.supplyOrder.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: fulfillmentOrg.id,
|
||||||
|
consumableType: 'FULFILLMENT_CONSUMABLES'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`📊 Старых SupplyOrder с FULFILLMENT_CONSUMABLES: ${oldSupplyOrders.length}`)
|
||||||
|
|
||||||
|
console.log('\n🎯 РЕЗУЛЬТАТ ТЕСТИРОВАНИЯ:')
|
||||||
|
console.log(` V2 Inventory: ${inventory.length} записей ✅`)
|
||||||
|
console.log(` V2 Orders: ${orders.length} записей ✅`)
|
||||||
|
console.log(` V1 Supplies: ${v1Supplies.length} записей ${v1Supplies.length === 0 ? '✅' : '⚠️'}`)
|
||||||
|
console.log(` Старые SupplyOrder: ${oldSupplyOrders.length} записей`)
|
||||||
|
|
||||||
|
if (inventory.length > 0 && v1Supplies.length === 0) {
|
||||||
|
console.log('\n🎉 V2 СИСТЕМА ФУЛФИЛМЕНТА РАБОТАЕТ КОРРЕКТНО!')
|
||||||
|
} else {
|
||||||
|
console.log('\n⚠️ Обнаружены проблемы в V2 системе')
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ ОШИБКА при тестировании:', error)
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testV2Migration()
|
@ -1,10 +1,10 @@
|
|||||||
import { AuthGuard } from '@/components/auth-guard'
|
import { AuthGuard } from '@/components/auth-guard'
|
||||||
import { CreateFulfillmentConsumablesSupplyPage } from '@/components/fulfillment-supplies/create-fulfillment-consumables-supply-page'
|
import CreateFulfillmentConsumablesSupplyV2Page from '@/components/fulfillment-supplies/create-fulfillment-consumables-supply-v2'
|
||||||
|
|
||||||
export default function CreateFulfillmentConsumablesSupplyPageRoute() {
|
export default function CreateFulfillmentConsumablesSupplyPageRoute() {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<CreateFulfillmentConsumablesSupplyPage />
|
<CreateFulfillmentConsumablesSupplyV2Page />
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,820 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useQuery, useMutation } from '@apollo/client'
|
|
||||||
import { ArrowLeft, Building2, Search, Package, Plus, Minus, ShoppingCart, Wrench } from 'lucide-react'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import React, { useState, useMemo } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
|
||||||
import { OrganizationAvatar } from '@/components/market/organization-avatar'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card } from '@/components/ui/card'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { CREATE_SUPPLY_ORDER } from '@/graphql/mutations'
|
|
||||||
import {
|
|
||||||
GET_MY_COUNTERPARTIES,
|
|
||||||
GET_ORGANIZATION_PRODUCTS,
|
|
||||||
GET_SUPPLY_ORDERS,
|
|
||||||
GET_MY_SUPPLIES,
|
|
||||||
GET_MY_FULFILLMENT_SUPPLIES,
|
|
||||||
} from '@/graphql/queries'
|
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
|
||||||
|
|
||||||
interface FulfillmentConsumableSupplier {
|
|
||||||
id: string
|
|
||||||
inn: string
|
|
||||||
name?: string
|
|
||||||
fullName?: string
|
|
||||||
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
|
||||||
address?: string
|
|
||||||
phones?: Array<{ value: string }>
|
|
||||||
emails?: Array<{ value: string }>
|
|
||||||
users?: Array<{ id: string; avatar?: string; managerName?: string }>
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FulfillmentConsumableProduct {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
price: number
|
|
||||||
type?: 'PRODUCT' | 'CONSUMABLE'
|
|
||||||
category?: { name: string }
|
|
||||||
images: string[]
|
|
||||||
mainImage?: string
|
|
||||||
organization: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
stock?: number
|
|
||||||
unit?: string
|
|
||||||
quantity?: number
|
|
||||||
ordered?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectedFulfillmentConsumable {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
price: number
|
|
||||||
selectedQuantity: number
|
|
||||||
unit?: string
|
|
||||||
category?: string
|
|
||||||
supplierId: string
|
|
||||||
supplierName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateFulfillmentConsumablesSupplyPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
const { getSidebarMargin } = useSidebar()
|
|
||||||
const { user } = useAuth()
|
|
||||||
const [selectedSupplier, setSelectedSupplier] = useState<FulfillmentConsumableSupplier | null>(null)
|
|
||||||
const [selectedLogistics, setSelectedLogistics] = useState<FulfillmentConsumableSupplier | null>(null)
|
|
||||||
const [selectedConsumables, setSelectedConsumables] = useState<SelectedFulfillmentConsumable[]>([])
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [productSearchQuery, setProductSearchQuery] = useState('')
|
|
||||||
const [deliveryDate, setDeliveryDate] = useState('')
|
|
||||||
const [isCreatingSupply, setIsCreatingSupply] = useState(false)
|
|
||||||
|
|
||||||
// Загружаем контрагентов-поставщиков расходников
|
|
||||||
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
|
|
||||||
|
|
||||||
// Убираем избыточное логирование для предотвращения визуального "бесконечного цикла"
|
|
||||||
|
|
||||||
// Стабилизируем переменные для useQuery
|
|
||||||
const queryVariables = useMemo(() => {
|
|
||||||
return {
|
|
||||||
organizationId: selectedSupplier?.id || '', // Всегда возвращаем объект, но с пустым ID если нет поставщика
|
|
||||||
search: productSearchQuery || null,
|
|
||||||
category: null,
|
|
||||||
type: 'CONSUMABLE' as const, // Фильтруем только расходники согласно rules2.md
|
|
||||||
}
|
|
||||||
}, [selectedSupplier?.id, productSearchQuery])
|
|
||||||
|
|
||||||
// Загружаем товары для выбранного поставщика с фильтрацией по типу CONSUMABLE
|
|
||||||
const {
|
|
||||||
data: productsData,
|
|
||||||
loading: productsLoading,
|
|
||||||
error: _productsError,
|
|
||||||
} = useQuery(GET_ORGANIZATION_PRODUCTS, {
|
|
||||||
skip: !selectedSupplier?.id, // Используем стабильное условие вместо !queryVariables
|
|
||||||
variables: queryVariables,
|
|
||||||
onCompleted: (data) => {
|
|
||||||
// Логируем только количество загруженных товаров
|
|
||||||
console.warn(`📦 Загружено товаров: ${data?.organizationProducts?.length || 0}`)
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('❌ GET_ORGANIZATION_PRODUCTS ERROR:', error)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Мутация для создания заказа поставки расходников
|
|
||||||
const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER)
|
|
||||||
|
|
||||||
// Фильтруем только поставщиков расходников (поставщиков)
|
|
||||||
const consumableSuppliers = (counterpartiesData?.myCounterparties || []).filter(
|
|
||||||
(org: FulfillmentConsumableSupplier) => org.type === 'WHOLESALE',
|
|
||||||
)
|
|
||||||
|
|
||||||
// Фильтруем только логистические компании
|
|
||||||
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
|
|
||||||
(org: FulfillmentConsumableSupplier) => org.type === 'LOGIST',
|
|
||||||
)
|
|
||||||
|
|
||||||
// Фильтруем поставщиков по поисковому запросу
|
|
||||||
const filteredSuppliers = consumableSuppliers.filter(
|
|
||||||
(supplier: FulfillmentConsumableSupplier) =>
|
|
||||||
supplier.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
supplier.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
supplier.inn?.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Фильтруем товары по выбранному поставщику
|
|
||||||
// 📦 Получаем товары поставщика (уже отфильтрованы в GraphQL запросе по типу CONSUMABLE)
|
|
||||||
const supplierProducts = productsData?.organizationProducts || []
|
|
||||||
|
|
||||||
// Отладочное логирование только при смене поставщика
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (selectedSupplier) {
|
|
||||||
console.warn('🔄 ПОСТАВЩИК ВЫБРАН:', {
|
|
||||||
id: selectedSupplier.id,
|
|
||||||
name: selectedSupplier.name || selectedSupplier.fullName,
|
|
||||||
type: selectedSupplier.type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [selectedSupplier]) // Включаем весь объект поставщика для корректной работы
|
|
||||||
|
|
||||||
// Логируем результат загрузки товаров только при получении данных
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (productsData && !productsLoading) {
|
|
||||||
console.warn('📦 ТОВАРЫ ЗАГРУЖЕНЫ:', {
|
|
||||||
organizationProductsCount: productsData?.organizationProducts?.length || 0,
|
|
||||||
supplierProductsCount: supplierProducts.length,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [productsData, productsLoading, supplierProducts.length]) // Включаем все зависимости для корректной работы
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'RUB',
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
}).format(amount)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateConsumableQuantity = (productId: string, quantity: number) => {
|
|
||||||
const product = supplierProducts.find((p: FulfillmentConsumableProduct) => p.id === productId)
|
|
||||||
if (!product || !selectedSupplier) return
|
|
||||||
|
|
||||||
// 🔒 ВАЛИДАЦИЯ ОСТАТКОВ согласно правилам (раздел 6.2)
|
|
||||||
if (quantity > 0) {
|
|
||||||
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0)
|
|
||||||
|
|
||||||
if (quantity > availableStock) {
|
|
||||||
toast.error(`❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedConsumables((prev) => {
|
|
||||||
const existing = prev.find((p) => p.id === productId)
|
|
||||||
|
|
||||||
if (quantity === 0) {
|
|
||||||
// Удаляем расходник если количество 0
|
|
||||||
return prev.filter((p) => p.id !== productId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// Обновляем количество существующего расходника
|
|
||||||
return prev.map((p) => (p.id === productId ? { ...p, selectedQuantity: quantity } : p))
|
|
||||||
} else {
|
|
||||||
// Добавляем новый расходник
|
|
||||||
return [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: product.id,
|
|
||||||
name: product.name,
|
|
||||||
price: product.price,
|
|
||||||
selectedQuantity: quantity,
|
|
||||||
unit: product.unit || 'шт',
|
|
||||||
category: product.category?.name || 'Расходники',
|
|
||||||
supplierId: selectedSupplier.id,
|
|
||||||
supplierName: selectedSupplier.name || selectedSupplier.fullName || 'Поставщик',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSelectedQuantity = (productId: string): number => {
|
|
||||||
const selected = selectedConsumables.find((p) => p.id === productId)
|
|
||||||
return selected ? selected.selectedQuantity : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTotalAmount = () => {
|
|
||||||
return selectedConsumables.reduce((sum, consumable) => sum + consumable.price * consumable.selectedQuantity, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTotalItems = () => {
|
|
||||||
return selectedConsumables.reduce((sum, consumable) => sum + consumable.selectedQuantity, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateSupply = async () => {
|
|
||||||
if (!selectedSupplier || selectedConsumables.length === 0 || !deliveryDate || !selectedLogistics) {
|
|
||||||
toast.error('Заполните все обязательные поля: поставщик, расходники, дата доставки и логистика')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Дополнительная проверка ID логистики
|
|
||||||
if (!selectedLogistics.id) {
|
|
||||||
toast.error('Выберите логистическую компанию')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCreatingSupply(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const input = {
|
|
||||||
partnerId: selectedSupplier.id,
|
|
||||||
deliveryDate: deliveryDate,
|
|
||||||
// Для фулфилмента указываем себя как получателя (поставка на свой склад)
|
|
||||||
fulfillmentCenterId: user?.organization?.id,
|
|
||||||
logisticsPartnerId: selectedLogistics.id,
|
|
||||||
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
|
|
||||||
consumableType: 'FULFILLMENT_CONSUMABLES', // Расходники фулфилмента
|
|
||||||
items: selectedConsumables.map((consumable) => ({
|
|
||||||
productId: consumable.id,
|
|
||||||
quantity: consumable.selectedQuantity,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('🚀 СОЗДАНИЕ ПОСТАВКИ - INPUT:', input)
|
|
||||||
|
|
||||||
const result = await createSupplyOrder({
|
|
||||||
variables: { input },
|
|
||||||
refetchQueries: [
|
|
||||||
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
|
||||||
{ query: GET_MY_SUPPLIES }, // Обновляем расходники фулфилмента
|
|
||||||
{ query: GET_MY_FULFILLMENT_SUPPLIES }, // 📊 Обновляем модуль учета расходников фулфилмента
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
console.warn('🎯 РЕЗУЛЬТАТ СОЗДАНИЯ ПОСТАВКИ:', result)
|
|
||||||
console.warn('🎯 ДЕТАЛИ ОТВЕТА:', result.data?.createSupplyOrder)
|
|
||||||
|
|
||||||
if (result.data?.createSupplyOrder?.success) {
|
|
||||||
toast.success('Заказ поставки расходников фулфилмента создан успешно!')
|
|
||||||
// Очищаем форму
|
|
||||||
setSelectedSupplier(null)
|
|
||||||
setSelectedConsumables([])
|
|
||||||
setDeliveryDate('')
|
|
||||||
setProductSearchQuery('')
|
|
||||||
setSearchQuery('')
|
|
||||||
|
|
||||||
// Перенаправляем на страницу поставок фулфилмента с активной вкладкой "Расходники фулфилмента"
|
|
||||||
router.push('/fulfillment/supplies/detailed-supplies')
|
|
||||||
} else {
|
|
||||||
toast.error(result.data?.createSupplyOrder?.message || 'Ошибка при создании заказа поставки')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating fulfillment consumables supply:', error)
|
|
||||||
toast.error('Ошибка при создании поставки расходников фулфилмента')
|
|
||||||
} finally {
|
|
||||||
setIsCreatingSupply(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex overflow-hidden">
|
|
||||||
<Sidebar />
|
|
||||||
<main className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300`}>
|
|
||||||
<div className="min-h-full w-full flex flex-col px-3 py-2">
|
|
||||||
{/* Заголовок */}
|
|
||||||
<div className="flex items-center justify-between mb-3 flex-shrink-0">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold text-white mb-1">Создание поставки расходников фулфилмента</h1>
|
|
||||||
<p className="text-white/60 text-sm">
|
|
||||||
Выберите поставщика и добавьте расходники в заказ для вашего фулфилмент-центра
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => router.push('/fulfillment/supplies')}
|
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10 text-sm"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
|
||||||
Назад
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Основной контент с двумя блоками */}
|
|
||||||
<div className="flex-1 flex gap-3 min-h-0">
|
|
||||||
{/* Левая колонка - Поставщики и Расходники */}
|
|
||||||
<div className="flex-1 flex flex-col gap-3 min-h-0">
|
|
||||||
{/* Блок "Поставщики" */}
|
|
||||||
<Card className="bg-gradient-to-r from-white/15 via-white/10 to-white/15 backdrop-blur-xl border border-white/30 shadow-2xl flex-shrink-0 sticky top-0 z-10 rounded-xl overflow-hidden">
|
|
||||||
<div className="p-3 bg-gradient-to-r from-purple-500/10 to-pink-500/10">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<h2 className="text-lg font-bold flex items-center flex-shrink-0 bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
|
|
||||||
<Building2 className="h-5 w-5 mr-3 text-purple-400" />
|
|
||||||
Поставщики расходников
|
|
||||||
</h2>
|
|
||||||
<div className="relative flex-1 max-w-sm">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-purple-300 h-4 w-4 z-10" />
|
|
||||||
<Input
|
|
||||||
placeholder="Найти поставщика..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="bg-white/20 backdrop-blur border-white/30 text-white placeholder-white/50 pl-10 h-8 text-sm rounded-full shadow-inner focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 transition-all duration-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{selectedSupplier && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedSupplier(null)}
|
|
||||||
className="text-white/70 hover:text-white hover:bg-white/20 text-sm h-8 px-3 flex-shrink-0 rounded-full transition-all duration-300 hover:scale-105"
|
|
||||||
>
|
|
||||||
✕ Сбросить
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-3 pb-3 h-24 overflow-hidden">
|
|
||||||
{counterpartiesLoading ? (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-400 border-t-transparent mx-auto mb-2"></div>
|
|
||||||
<p className="text-white/70 text-sm font-medium">Загружаем поставщиков...</p>
|
|
||||||
</div>
|
|
||||||
) : filteredSuppliers.length === 0 ? (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<div className="bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-full p-3 w-fit mx-auto mb-2">
|
|
||||||
<Building2 className="h-6 w-6 text-purple-300" />
|
|
||||||
</div>
|
|
||||||
<p className="text-white/70 text-sm font-medium">
|
|
||||||
{searchQuery ? 'Поставщики не найдены' : 'Добавьте поставщиков'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex gap-2 h-full pt-1">
|
|
||||||
{filteredSuppliers.slice(0, 7).map((supplier: FulfillmentConsumableSupplier, index: number) => (
|
|
||||||
<Card
|
|
||||||
key={supplier.id}
|
|
||||||
className={`relative cursor-pointer transition-all duration-300 border flex-shrink-0 rounded-xl overflow-hidden group hover:scale-105 hover:shadow-xl ${
|
|
||||||
selectedSupplier?.id === supplier.id
|
|
||||||
? 'bg-gradient-to-br from-orange-500/30 via-orange-400/20 to-orange-500/30 border-orange-400/60 shadow-lg shadow-orange-500/25'
|
|
||||||
: 'bg-gradient-to-br from-white/10 via-white/5 to-white/10 border-white/20 hover:from-white/20 hover:via-white/10 hover:to-white/20 hover:border-white/40'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
width: 'calc((100% - 48px) / 7)', // 48px = 6 gaps * 8px each
|
|
||||||
animationDelay: `${index * 100}ms`,
|
|
||||||
}}
|
|
||||||
onClick={() => setSelectedSupplier(supplier)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center h-full p-2 space-y-1">
|
|
||||||
<div className="relative">
|
|
||||||
<OrganizationAvatar
|
|
||||||
organization={{
|
|
||||||
id: supplier.id,
|
|
||||||
name: supplier.name || supplier.fullName || 'Поставщик',
|
|
||||||
fullName: supplier.fullName,
|
|
||||||
users: (supplier.users || []).map((user) => ({
|
|
||||||
id: user.id,
|
|
||||||
avatar: user.avatar,
|
|
||||||
})),
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
{selectedSupplier?.id === supplier.id && (
|
|
||||||
<div className="absolute -top-1 -right-1 bg-gradient-to-r from-orange-400 to-orange-500 rounded-full w-4 h-4 flex items-center justify-center shadow-lg animate-pulse">
|
|
||||||
<span className="text-white text-xs font-bold">✓</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-center w-full space-y-0.5">
|
|
||||||
<h3 className="text-white font-semibold text-xs truncate leading-tight group-hover:text-purple-200 transition-colors duration-300">
|
|
||||||
{(supplier.name || supplier.fullName || 'Поставщик').slice(0, 10)}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center justify-center space-x-1">
|
|
||||||
<span className="text-yellow-400 text-sm animate-pulse">★</span>
|
|
||||||
<span className="text-white/80 text-xs font-medium">4.5</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-white/10 rounded-full h-1 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="bg-gradient-to-r from-purple-400 to-pink-400 h-full rounded-full animate-pulse"
|
|
||||||
style={{ width: '90%' }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hover эффект */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/0 to-pink-500/0 group-hover:from-purple-500/10 group-hover:to-pink-500/10 transition-all duration-300 pointer-events-none"></div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
{filteredSuppliers.length > 7 && (
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 flex flex-col items-center justify-center bg-gradient-to-br from-white/10 to-white/5 rounded-xl border border-white/20 text-white/70 hover:text-white transition-all duration-300 hover:scale-105"
|
|
||||||
style={{ width: 'calc((100% - 48px) / 7)' }}
|
|
||||||
>
|
|
||||||
<div className="text-lg font-bold text-purple-300">+{filteredSuppliers.length - 7}</div>
|
|
||||||
<div className="text-xs">ещё</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Блок "Расходники" */}
|
|
||||||
<Card className="bg-white/10 backdrop-blur border-white/20 flex-1 min-h-0 flex flex-col">
|
|
||||||
<div className="p-3 border-b border-white/10 flex-shrink-0">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h2 className="text-lg font-semibold text-white flex items-center">
|
|
||||||
<Wrench className="h-4 w-4 mr-2" />
|
|
||||||
Расходники для фулфилмента
|
|
||||||
{selectedSupplier && (
|
|
||||||
<span className="text-white/60 text-xs font-normal ml-2 truncate">
|
|
||||||
- {selectedSupplier.name || selectedSupplier.fullName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
{selectedSupplier && (
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 text-white/40 h-3 w-3" />
|
|
||||||
<Input
|
|
||||||
placeholder="Поиск расходников..."
|
|
||||||
value={productSearchQuery}
|
|
||||||
onChange={(e) => setProductSearchQuery(e.target.value)}
|
|
||||||
className="bg-white/10 border-white/20 text-white placeholder-white/40 pl-7 h-8 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 flex-1 overflow-y-auto">
|
|
||||||
{!selectedSupplier ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Wrench className="h-8 w-8 text-white/40 mx-auto mb-3" />
|
|
||||||
<p className="text-white/60 text-sm">Выберите поставщика для просмотра расходников</p>
|
|
||||||
</div>
|
|
||||||
) : productsLoading ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-white border-t-transparent mx-auto mb-2"></div>
|
|
||||||
<p className="text-white/60 text-sm">Загрузка...</p>
|
|
||||||
</div>
|
|
||||||
) : supplierProducts.length === 0 ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<Package className="h-8 w-8 text-white/40 mx-auto mb-3" />
|
|
||||||
<p className="text-white/60 text-sm">Нет доступных расходников</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 gap-3">
|
|
||||||
{supplierProducts.map((product: FulfillmentConsumableProduct, index: number) => {
|
|
||||||
const selectedQuantity = getSelectedQuantity(product.id)
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={product.id}
|
|
||||||
className={`relative bg-gradient-to-br from-white/10 via-white/5 to-white/10 backdrop-blur border border-white/20 p-3 rounded-xl overflow-hidden group hover:shadow-xl transition-all duration-300 ${
|
|
||||||
selectedQuantity > 0
|
|
||||||
? 'ring-2 ring-green-400/50 bg-gradient-to-br from-green-500/20 via-green-400/10 to-green-500/20'
|
|
||||||
: 'hover:from-white/20 hover:via-white/10 hover:to-white/20 hover:border-white/40'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
animationDelay: `${index * 50}ms`,
|
|
||||||
minHeight: '200px',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="space-y-2 h-full flex flex-col">
|
|
||||||
{/* Изображение товара */}
|
|
||||||
<div className="aspect-square bg-white/5 rounded-lg overflow-hidden relative flex-shrink-0">
|
|
||||||
{/* 🚫 ОВЕРЛЕЙ НЕДОСТУПНОСТИ */}
|
|
||||||
{(() => {
|
|
||||||
const totalStock = product.stock || (product as any).quantity || 0
|
|
||||||
const orderedStock = (product as any).ordered || 0
|
|
||||||
const availableStock = totalStock - orderedStock
|
|
||||||
|
|
||||||
if (availableStock <= 0) {
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-10">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-red-400 font-bold text-xs">НЕТ В НАЛИЧИИ</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})()}
|
|
||||||
{product.images && product.images.length > 0 && product.images[0] ? (
|
|
||||||
<Image
|
|
||||||
src={product.images[0]}
|
|
||||||
alt={product.name}
|
|
||||||
width={100}
|
|
||||||
height={100}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
|
||||||
/>
|
|
||||||
) : product.mainImage ? (
|
|
||||||
<Image
|
|
||||||
src={product.mainImage}
|
|
||||||
alt={product.name}
|
|
||||||
width={100}
|
|
||||||
height={100}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
|
||||||
<Wrench className="h-8 w-8 text-white/40" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedQuantity > 0 && (
|
|
||||||
<div className="absolute top-2 right-2 bg-gradient-to-r from-green-400 to-green-500 rounded-full w-6 h-6 flex items-center justify-center shadow-lg animate-pulse">
|
|
||||||
<span className="text-white text-xs font-bold">
|
|
||||||
{selectedQuantity > 999 ? '999+' : selectedQuantity}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Информация о товаре */}
|
|
||||||
<div className="space-y-1 flex-grow">
|
|
||||||
<h3 className="text-white font-medium text-sm leading-tight line-clamp-2 group-hover:text-purple-200 transition-colors duration-300">
|
|
||||||
{product.name}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{product.category && (
|
|
||||||
<Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs px-2 py-1">
|
|
||||||
{product.category.name.slice(0, 10)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{/* 🚨 ИНДИКАТОР НИЗКИХ ОСТАТКОВ согласно правилам (раздел 6.3) */}
|
|
||||||
{(() => {
|
|
||||||
const totalStock = product.stock || product.quantity || 0
|
|
||||||
const orderedStock = product.ordered || 0
|
|
||||||
const availableStock = totalStock - orderedStock
|
|
||||||
|
|
||||||
if (availableStock <= 0) {
|
|
||||||
return (
|
|
||||||
<Badge className="bg-red-500/30 text-red-300 border-red-500/50 text-xs px-2 py-1 animate-pulse">
|
|
||||||
Нет в наличии
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
} else if (availableStock <= 10) {
|
|
||||||
return (
|
|
||||||
<Badge className="bg-yellow-500/30 text-yellow-300 border-yellow-500/50 text-xs px-2 py-1">
|
|
||||||
Мало остатков
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-green-400 font-semibold text-sm">
|
|
||||||
{formatCurrency(product.price)}
|
|
||||||
</span>
|
|
||||||
{/* 📊 АКТУАЛЬНЫЙ ОСТАТОК согласно правилам (раздел 6.4.2) */}
|
|
||||||
<div className="text-right">
|
|
||||||
{(() => {
|
|
||||||
const totalStock = product.stock || product.quantity || 0
|
|
||||||
const orderedStock = product.ordered || 0
|
|
||||||
const availableStock = totalStock - orderedStock
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<span
|
|
||||||
className={`text-xs font-medium ${
|
|
||||||
availableStock <= 0
|
|
||||||
? 'text-red-400'
|
|
||||||
: availableStock <= 10
|
|
||||||
? 'text-yellow-400'
|
|
||||||
: 'text-white/80'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Доступно: {availableStock}
|
|
||||||
</span>
|
|
||||||
{orderedStock > 0 && (
|
|
||||||
<span className="text-white/40 text-xs">Заказано: {orderedStock}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Управление количеством */}
|
|
||||||
<div className="flex flex-col items-center space-y-2 mt-auto">
|
|
||||||
{(() => {
|
|
||||||
const totalStock = product.stock || (product as any).quantity || 0
|
|
||||||
const orderedStock = (product as any).ordered || 0
|
|
||||||
const availableStock = totalStock - orderedStock
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
updateConsumableQuantity(product.id, Math.max(0, selectedQuantity - 1))
|
|
||||||
}
|
|
||||||
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/20 rounded-full transition-all duration-300"
|
|
||||||
disabled={selectedQuantity === 0}
|
|
||||||
>
|
|
||||||
<Minus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={selectedQuantity === 0 ? '' : selectedQuantity.toString()}
|
|
||||||
onChange={(e) => {
|
|
||||||
let inputValue = e.target.value
|
|
||||||
|
|
||||||
// Удаляем все нецифровые символы
|
|
||||||
inputValue = inputValue.replace(/[^0-9]/g, '')
|
|
||||||
|
|
||||||
// Удаляем ведущие нули
|
|
||||||
inputValue = inputValue.replace(/^0+/, '')
|
|
||||||
|
|
||||||
// Если строка пустая после удаления нулей, устанавливаем 0
|
|
||||||
const numericValue = inputValue === '' ? 0 : parseInt(inputValue)
|
|
||||||
|
|
||||||
// Ограничиваем значение максимумом доступного остатка
|
|
||||||
const clampedValue = Math.min(numericValue, availableStock, 99999)
|
|
||||||
|
|
||||||
updateConsumableQuantity(product.id, clampedValue)
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
// При потере фокуса, если поле пустое, устанавливаем 0
|
|
||||||
if (e.target.value === '') {
|
|
||||||
updateConsumableQuantity(product.id, 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-16 h-7 text-center text-sm bg-white/10 border-white/20 text-white rounded px-1 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
updateConsumableQuantity(
|
|
||||||
product.id,
|
|
||||||
Math.min(selectedQuantity + 1, availableStock, 99999),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={`h-6 w-6 p-0 rounded-full transition-all duration-300 ${
|
|
||||||
selectedQuantity >= availableStock || availableStock <= 0
|
|
||||||
? 'text-white/30 cursor-not-allowed'
|
|
||||||
: 'text-white/60 hover:text-white hover:bg-white/20'
|
|
||||||
}`}
|
|
||||||
disabled={selectedQuantity >= availableStock || availableStock <= 0}
|
|
||||||
title={
|
|
||||||
availableStock <= 0
|
|
||||||
? 'Товар отсутствует на складе'
|
|
||||||
: selectedQuantity >= availableStock
|
|
||||||
? `Максимум доступно: ${availableStock}`
|
|
||||||
: 'Увеличить количество'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{selectedQuantity > 0 && (
|
|
||||||
<div className="text-center">
|
|
||||||
<span className="text-green-400 font-bold text-sm bg-green-500/10 px-3 py-1 rounded-full">
|
|
||||||
{formatCurrency(product.price * selectedQuantity)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hover эффект */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/0 to-pink-500/0 group-hover:from-purple-500/5 group-hover:to-pink-500/5 transition-all duration-300 pointer-events-none rounded-xl"></div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Правая колонка - Корзина */}
|
|
||||||
<div className="w-72 flex-shrink-0">
|
|
||||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 sticky top-0">
|
|
||||||
<h3 className="text-white font-semibold mb-3 flex items-center text-sm">
|
|
||||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
|
||||||
Корзина ({getTotalItems()} шт)
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{selectedConsumables.length === 0 ? (
|
|
||||||
<div className="text-center py-6">
|
|
||||||
<div className="bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-full p-4 w-fit mx-auto mb-3">
|
|
||||||
<ShoppingCart className="h-8 w-8 text-purple-300" />
|
|
||||||
</div>
|
|
||||||
<p className="text-white/60 text-sm font-medium mb-2">Корзина пуста</p>
|
|
||||||
<p className="text-white/40 text-xs mb-3">Добавьте расходники для создания поставки</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2 mb-3 max-h-48 overflow-y-auto">
|
|
||||||
{selectedConsumables.map((consumable) => (
|
|
||||||
<div key={consumable.id} className="flex items-center justify-between p-2 bg-white/5 rounded-lg">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-white text-xs font-medium truncate">{consumable.name}</p>
|
|
||||||
<p className="text-white/60 text-xs">
|
|
||||||
{formatCurrency(consumable.price)} × {consumable.selectedQuantity}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-green-400 font-medium text-xs">
|
|
||||||
{formatCurrency(consumable.price * consumable.selectedQuantity)}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => updateConsumableQuantity(consumable.id, 0)}
|
|
||||||
className="h-5 w-5 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="border-t border-white/20 pt-3">
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="text-white/60 text-xs mb-1 block">Дата поставки:</label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={deliveryDate}
|
|
||||||
onChange={(e) => setDeliveryDate(e.target.value)}
|
|
||||||
className="bg-white/10 border-white/20 text-white h-8 text-sm"
|
|
||||||
min={new Date().toISOString().split('T')[0]}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Выбор логистики */}
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="text-white/60 text-xs mb-1 block">Логистика *:</label>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
value={selectedLogistics?.id || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const logisticsId = e.target.value
|
|
||||||
const logistics = logisticsPartners.find((p: any) => p.id === logisticsId)
|
|
||||||
setSelectedLogistics(logistics || null)
|
|
||||||
}}
|
|
||||||
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent appearance-none"
|
|
||||||
>
|
|
||||||
<option value="" className="bg-gray-800 text-white">
|
|
||||||
Выберите логистику
|
|
||||||
</option>
|
|
||||||
{logisticsPartners.map((partner: any) => (
|
|
||||||
<option key={partner.id} value={partner.id} className="bg-gray-800 text-white">
|
|
||||||
{partner.name || partner.fullName || partner.inn}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
|
||||||
<svg className="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-white font-semibold text-sm">Итого:</span>
|
|
||||||
<span className="text-green-400 font-bold text-lg">{formatCurrency(getTotalAmount())}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateSupply}
|
|
||||||
disabled={
|
|
||||||
isCreatingSupply || !deliveryDate || selectedConsumables.length === 0 || !selectedLogistics
|
|
||||||
}
|
|
||||||
className="w-full bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white disabled:opacity-50 h-8 text-sm"
|
|
||||||
>
|
|
||||||
{isCreatingSupply ? 'Создание...' : 'Создать поставку'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@ -4,13 +4,11 @@ import { useQuery } from '@apollo/client'
|
|||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Building2,
|
Building2,
|
||||||
TrendingUp,
|
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Wrench,
|
Wrench,
|
||||||
Package2,
|
Package2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
User,
|
|
||||||
Clock,
|
Clock,
|
||||||
Truck,
|
Truck,
|
||||||
Box,
|
Box,
|
||||||
@ -54,6 +52,7 @@ interface SupplyOrder {
|
|||||||
| 'CANCELLED'
|
| 'CANCELLED'
|
||||||
totalAmount: number
|
totalAmount: number
|
||||||
totalItems: number
|
totalItems: number
|
||||||
|
consumableType?: 'SELLER_CONSUMABLES' | 'FULFILLMENT_CONSUMABLES'
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
partner: {
|
partner: {
|
||||||
@ -98,9 +97,10 @@ export function SellerSupplyOrdersTab() {
|
|||||||
setExpandedOrders(newExpanded)
|
setExpandedOrders(newExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фильтруем заказы созданные текущим селлером
|
// Фильтруем заказы созданные текущим селлером И только расходники селлера
|
||||||
const sellerOrders: SupplyOrder[] = (data?.supplyOrders || []).filter((order: SupplyOrder) => {
|
const sellerOrders: SupplyOrder[] = (data?.supplyOrders || []).filter((order: SupplyOrder) => {
|
||||||
return order.organization.id === user?.organization?.id
|
return order.organization.id === user?.organization?.id &&
|
||||||
|
order.consumableType === 'SELLER_CONSUMABLES' // Только расходники селлера
|
||||||
})
|
})
|
||||||
|
|
||||||
const getStatusBadge = (status: SupplyOrder['status']) => {
|
const getStatusBadge = (status: SupplyOrder['status']) => {
|
||||||
@ -166,10 +166,10 @@ export function SellerSupplyOrdersTab() {
|
|||||||
// Статистика для селлера
|
// Статистика для селлера
|
||||||
const totalOrders = sellerOrders.length
|
const totalOrders = sellerOrders.length
|
||||||
const totalAmount = sellerOrders.reduce((sum, order) => sum + order.totalAmount, 0)
|
const totalAmount = sellerOrders.reduce((sum, order) => sum + order.totalAmount, 0)
|
||||||
const totalItems = sellerOrders.reduce((sum, order) => sum + order.totalItems, 0)
|
const _totalItems = sellerOrders.reduce((sum, order) => sum + order.totalItems, 0)
|
||||||
const pendingOrders = sellerOrders.filter((order) => order.status === 'PENDING').length
|
const pendingOrders = sellerOrders.filter((order) => order.status === 'PENDING').length
|
||||||
const approvedOrders = sellerOrders.filter((order) => order.status === 'CONFIRMED').length
|
const _approvedOrders = sellerOrders.filter((order) => order.status === 'CONFIRMED').length
|
||||||
const inTransitOrders = sellerOrders.filter((order) => order.status === 'IN_TRANSIT').length
|
const _inTransitOrders = sellerOrders.filter((order) => order.status === 'IN_TRANSIT').length
|
||||||
const deliveredOrders = sellerOrders.filter((order) => order.status === 'DELIVERED').length
|
const deliveredOrders = sellerOrders.filter((order) => order.status === 'DELIVERED').length
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
@ -7,7 +7,6 @@ import React, { useState, useEffect } from 'react'
|
|||||||
|
|
||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { GET_PENDING_SUPPLIES_COUNT, GET_MY_SUPPLY_ORDERS } from '@/graphql/queries'
|
import { GET_PENDING_SUPPLIES_COUNT, GET_MY_SUPPLY_ORDERS } from '@/graphql/queries'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { useRealtime } from '@/hooks/useRealtime'
|
import { useRealtime } from '@/hooks/useRealtime'
|
||||||
@ -37,7 +36,7 @@ export function SuppliesDashboard() {
|
|||||||
const [activeSubTab, setActiveSubTab] = useState('goods')
|
const [activeSubTab, setActiveSubTab] = useState('goods')
|
||||||
const [activeThirdTab, setActiveThirdTab] = useState('cards')
|
const [activeThirdTab, setActiveThirdTab] = useState('cards')
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [statisticsData, setStatisticsData] = useState<any>(null)
|
const [_statisticsData, _setStatisticsData] = useState<any>(null)
|
||||||
|
|
||||||
// Загружаем счетчик поставок, требующих одобрения
|
// Загружаем счетчик поставок, требующих одобрения
|
||||||
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||||
@ -381,7 +380,7 @@ export function SuppliesDashboard() {
|
|||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
activeSubTab={activeSubTab}
|
activeSubTab={activeSubTab}
|
||||||
activeThirdTab={activeThirdTab}
|
activeThirdTab={activeThirdTab}
|
||||||
data={statisticsData}
|
data={_statisticsData}
|
||||||
loading={false}
|
loading={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -399,7 +398,9 @@ export function SuppliesDashboard() {
|
|||||||
{(activeThirdTab === 'cards' || activeThirdTab === 'suppliers') && (
|
{(activeThirdTab === 'cards' || activeThirdTab === 'suppliers') && (
|
||||||
<AllSuppliesTab
|
<AllSuppliesTab
|
||||||
pendingSupplyOrders={pendingCount?.supplyOrders || 0}
|
pendingSupplyOrders={pendingCount?.supplyOrders || 0}
|
||||||
goodsSupplies={mySuppliesData?.mySupplyOrders || []} // ✅ РЕАЛЬНЫЕ ДАННЫЕ из GraphQL
|
goodsSupplies={(mySuppliesData?.mySupplyOrders || []).filter((supply: any) =>
|
||||||
|
supply.consumableType !== 'SELLER_CONSUMABLES'
|
||||||
|
)}
|
||||||
loading={mySuppliesLoading}
|
loading={mySuppliesLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -10,14 +10,13 @@ import { MarketplaceService } from '@/services/marketplace-service'
|
|||||||
import { SmsService } from '@/services/sms-service'
|
import { SmsService } from '@/services/sms-service'
|
||||||
import { WildberriesService } from '@/services/wildberries-service'
|
import { WildberriesService } from '@/services/wildberries-service'
|
||||||
|
|
||||||
import '@/lib/seed-init' // Автоматическая инициализация БД
|
|
||||||
// Импорт новых resolvers для системы поставок v2
|
|
||||||
import { fulfillmentConsumableV2Queries, fulfillmentConsumableV2Mutations } from './resolvers/fulfillment-consumables-v2'
|
|
||||||
import { fulfillmentConsumableV2Queries as fulfillmentConsumableV2QueriesRestored, fulfillmentConsumableV2Mutations as fulfillmentConsumableV2MutationsRestored } from './resolvers/fulfillment-consumables-v2-restored'
|
|
||||||
import { fulfillmentInventoryV2Queries } from './resolvers/fulfillment-inventory-v2'
|
import { fulfillmentInventoryV2Queries } from './resolvers/fulfillment-inventory-v2'
|
||||||
|
import { fulfillmentConsumableV2Queries as fulfillmentConsumableV2QueriesRestored, fulfillmentConsumableV2Mutations as fulfillmentConsumableV2MutationsRestored } from './resolvers/fulfillment-consumables-v2-restored'
|
||||||
import { logisticsConsumableV2Queries, logisticsConsumableV2Mutations } from './resolvers/logistics-consumables-v2'
|
import { logisticsConsumableV2Queries, logisticsConsumableV2Mutations } from './resolvers/logistics-consumables-v2'
|
||||||
|
import { sellerInventoryV2Queries } from './resolvers/seller-inventory-v2'
|
||||||
import { CommercialDataAudit } from './security/commercial-data-audit'
|
import { CommercialDataAudit } from './security/commercial-data-audit'
|
||||||
import { createSecurityContext } from './security/index'
|
import { createSecurityContext } from './security/index'
|
||||||
|
import '@/lib/seed-init' // Автоматическая инициализация БД
|
||||||
|
|
||||||
// 🔒 HELPER: Создание безопасного контекста с организационными данными
|
// 🔒 HELPER: Создание безопасного контекста с организационными данными
|
||||||
function createSecureContextWithOrgData(context: Context, currentUser: { organization: { id: string; type: string } }) {
|
function createSecureContextWithOrgData(context: Context, currentUser: { organization: { id: string; type: string } }) {
|
||||||
@ -1310,38 +1309,35 @@ export const resolvers = {
|
|||||||
`📊 FULFILLMENT SUPPLIES RECEIVED TODAY V2 (ПРИБЫЛО): ${fulfillmentSuppliesReceivedTodayV2.length} orders, ${fulfillmentSuppliesChangeToday} items`,
|
`📊 FULFILLMENT SUPPLIES RECEIVED TODAY V2 (ПРИБЫЛО): ${fulfillmentSuppliesReceivedTodayV2.length} orders, ${fulfillmentSuppliesChangeToday} items`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Расходники селлеров - получаем из таблицы Supply (актуальные остатки на складе фулфилмента)
|
// V2: Расходники селлеров - получаем из SellerConsumableInventory
|
||||||
// ИСПРАВЛЕНО: считаем из Supply с типом SELLER_CONSUMABLES
|
const sellerInventoryFromWarehouse = await prisma.sellerConsumableInventory.findMany({
|
||||||
const sellerSuppliesFromWarehouse = await prisma.supply.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
organizationId: organizationId, // Склад фулфилмента
|
fulfillmentCenterId: organizationId, // Склад фулфилмента
|
||||||
type: 'SELLER_CONSUMABLES', // ТОЛЬКО расходники селлеров
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const sellerSuppliesCount = sellerSuppliesFromWarehouse.reduce(
|
const sellerSuppliesCount = sellerInventoryFromWarehouse.reduce(
|
||||||
(sum, supply) => sum + (supply.currentStock || 0),
|
(sum, item) => sum + (item.currentStock || 0),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
console.warn(`💼 SELLER SUPPLIES DEBUG: totalCount=${sellerSuppliesCount} (from Supply warehouse)`)
|
console.warn(`💼 SELLER SUPPLIES V2 DEBUG: totalCount=${sellerSuppliesCount} (from SellerConsumableInventory)`)
|
||||||
|
|
||||||
// Изменения расходников селлеров за сутки - считаем из Supply записей, созданных за сутки
|
// V2: Изменения расходников селлеров за сутки - считаем поступления за сутки
|
||||||
const sellerSuppliesReceivedToday = await prisma.supply.findMany({
|
const sellerSuppliesReceivedTodayV2 = await prisma.sellerConsumableInventory.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId: organizationId, // Склад фулфилмента
|
fulfillmentCenterId: organizationId,
|
||||||
type: 'SELLER_CONSUMABLES', // ТОЛЬКО расходники селлеров
|
lastSupplyDate: { gte: oneDayAgo }, // Пополнены за последние сутки
|
||||||
createdAt: { gte: oneDayAgo }, // Созданы за последние сутки
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const sellerSuppliesChangeToday = sellerSuppliesReceivedToday.reduce(
|
const sellerSuppliesChangeToday = sellerSuppliesReceivedTodayV2.reduce(
|
||||||
(sum, supply) => sum + (supply.currentStock || 0),
|
(sum, item) => sum + (item.totalReceived || 0),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`📊 SELLER SUPPLIES RECEIVED TODAY: ${sellerSuppliesReceivedToday.length} supplies, ${sellerSuppliesChangeToday} items`,
|
`📊 SELLER SUPPLIES RECEIVED TODAY V2: ${sellerSuppliesReceivedTodayV2.length} supplies, ${sellerSuppliesChangeToday} items`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Вычисляем процентные изменения
|
// Вычисляем процентные изменения
|
||||||
@ -1551,8 +1547,10 @@ export const resolvers = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// Расходники селлеров на складе фулфилмента (новый resolver)
|
// V2: Расходники селлеров на складе фулфилмента (обновлено на V2 систему)
|
||||||
sellerSuppliesOnWarehouse: async (_: unknown, __: unknown, context: Context) => {
|
sellerSuppliesOnWarehouse: async (_: unknown, __: unknown, context: Context) => {
|
||||||
|
console.warn('🚀 V2 SELLER SUPPLIES ON WAREHOUSE RESOLVER CALLED')
|
||||||
|
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
throw new GraphQLError('Требуется авторизация', {
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
extensions: { code: 'UNAUTHENTICATED' },
|
extensions: { code: 'UNAUTHENTICATED' },
|
||||||
@ -1573,60 +1571,115 @@ export const resolvers = {
|
|||||||
throw new GraphQLError('Доступ разрешен только для фулфилмент-центров')
|
throw new GraphQLError('Доступ разрешен только для фулфилмент-центров')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ИСПРАВЛЕНО: Усиленная фильтрация расходников селлеров
|
try {
|
||||||
const sellerSupplies = await prisma.supply.findMany({
|
// V2: Получаем данные из SellerConsumableInventory вместо старой Supply таблицы
|
||||||
where: {
|
const sellerInventory = await prisma.sellerConsumableInventory.findMany({
|
||||||
organizationId: currentUser.organization.id, // На складе этого фулфилмента
|
where: {
|
||||||
type: 'SELLER_CONSUMABLES' as const, // Только расходники селлеров
|
fulfillmentCenterId: currentUser.organization.id,
|
||||||
sellerOwnerId: { not: null }, // ОБЯЗАТЕЛЬНО должен быть владелец-селлер
|
},
|
||||||
},
|
include: {
|
||||||
include: {
|
seller: true,
|
||||||
organization: true, // Фулфилмент-центр (хранитель)
|
fulfillmentCenter: true,
|
||||||
sellerOwner: true, // Селлер-владелец расходников
|
product: {
|
||||||
},
|
include: {
|
||||||
orderBy: { createdAt: 'desc' },
|
organization: true, // Поставщик товара
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ seller: { name: 'asc' } }, // Группируем по селлерам
|
||||||
|
{ updatedAt: 'desc' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
// Логирование для отладки
|
console.warn('📊 V2 Seller Inventory loaded for warehouse:', {
|
||||||
console.warn('🔍 ИСПРАВЛЕНО: Запрос расходников селлеров на складе фулфилмента:', {
|
fulfillmentId: currentUser.organization.id,
|
||||||
fulfillmentId: currentUser.organization.id,
|
fulfillmentName: currentUser.organization.name,
|
||||||
fulfillmentName: currentUser.organization.name,
|
inventoryCount: sellerInventory.length,
|
||||||
totalSupplies: sellerSupplies.length,
|
uniqueSellers: new Set(sellerInventory.map(item => item.sellerId)).size,
|
||||||
sellerSupplies: sellerSupplies.map((supply) => ({
|
})
|
||||||
id: supply.id,
|
|
||||||
name: supply.name,
|
|
||||||
type: supply.type,
|
|
||||||
sellerOwnerId: supply.sellerOwnerId,
|
|
||||||
sellerOwnerName: supply.sellerOwner?.name || supply.sellerOwner?.fullName,
|
|
||||||
currentStock: supply.currentStock,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
// ДВОЙНАЯ ПРОВЕРКА: Фильтруем на уровне кода для гарантии
|
// Преобразуем V2 данные в формат Supply для совместимости с фронтендом
|
||||||
const filteredSupplies = sellerSupplies.filter((supply) => {
|
const suppliesFormatted = sellerInventory.map((item) => {
|
||||||
const isValid =
|
const status = item.currentStock > 0 ? 'На складе' : 'Недоступен'
|
||||||
supply.type === 'SELLER_CONSUMABLES' && supply.sellerOwnerId != null && supply.sellerOwner != null
|
const supplier = item.product.organization?.name || 'Неизвестен'
|
||||||
|
|
||||||
if (!isValid) {
|
// Дополнительная проверка на null значения
|
||||||
console.warn('⚠️ ОТФИЛЬТРОВАН некорректный расходник:', {
|
if (!item.seller?.inn) {
|
||||||
id: supply.id,
|
console.error('❌ КРИТИЧЕСКАЯ ОШИБКА: seller.inn is null/undefined', {
|
||||||
name: supply.name,
|
sellerId: item.sellerId,
|
||||||
type: supply.type,
|
sellerName: item.seller?.name,
|
||||||
sellerOwnerId: supply.sellerOwnerId,
|
itemId: item.id,
|
||||||
hasSellerOwner: !!supply.sellerOwner,
|
})
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return isValid
|
return {
|
||||||
})
|
// === ИДЕНТИФИКАЦИЯ ===
|
||||||
|
id: item.id,
|
||||||
|
productId: item.product.id,
|
||||||
|
|
||||||
|
// === ОСНОВНЫЕ ДАННЫЕ ===
|
||||||
|
name: item.product.name,
|
||||||
|
article: item.product.article,
|
||||||
|
description: item.product.description || '',
|
||||||
|
unit: item.product.unit || 'шт',
|
||||||
|
category: item.product.category || 'Расходники',
|
||||||
|
imageUrl: item.product.imageUrl,
|
||||||
|
|
||||||
|
// === СКЛАДСКИЕ ДАННЫЕ ===
|
||||||
|
currentStock: item.currentStock,
|
||||||
|
minStock: item.minStock,
|
||||||
|
usedStock: item.totalUsed || 0,
|
||||||
|
quantity: item.totalReceived,
|
||||||
|
reservedStock: item.reservedStock,
|
||||||
|
|
||||||
|
// === ЦЕНЫ ===
|
||||||
|
price: parseFloat(item.averageCost.toString()),
|
||||||
|
pricePerUnit: item.usagePrice ? parseFloat(item.usagePrice.toString()) : null,
|
||||||
|
|
||||||
|
// === СТАТУС И МЕТАДАННЫЕ ===
|
||||||
|
status,
|
||||||
|
isAvailable: item.currentStock > 0,
|
||||||
|
supplier,
|
||||||
|
type: 'SELLER_CONSUMABLES', // Для совместимости с фронтендом
|
||||||
|
date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(),
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
updatedAt: item.updatedAt.toISOString(),
|
||||||
|
|
||||||
|
// === СВЯЗИ ===
|
||||||
|
organization: {
|
||||||
|
id: item.fulfillmentCenter.id,
|
||||||
|
name: item.fulfillmentCenter.name,
|
||||||
|
fullName: item.fulfillmentCenter.fullName,
|
||||||
|
type: item.fulfillmentCenter.type,
|
||||||
|
},
|
||||||
|
sellerOwner: {
|
||||||
|
id: item.seller.id,
|
||||||
|
name: item.seller.name || 'Неизвестно',
|
||||||
|
fullName: item.seller.fullName || item.seller.name || 'Неизвестно',
|
||||||
|
inn: item.seller.inn || 'НЕ_УКАЗАН',
|
||||||
|
type: item.seller.type,
|
||||||
|
},
|
||||||
|
sellerOwnerId: item.sellerId, // Для совместимости
|
||||||
|
|
||||||
|
// === ДОПОЛНИТЕЛЬНЫЕ ПОЛЯ ===
|
||||||
|
notes: item.notes,
|
||||||
|
actualQuantity: item.currentStock,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
console.warn('✅ ФИНАЛЬНЫЙ РЕЗУЛЬТАТ после фильтрации:', {
|
console.warn('✅ V2 Seller Supplies formatted for frontend:', {
|
||||||
originalCount: sellerSupplies.length,
|
count: suppliesFormatted.length,
|
||||||
filteredCount: filteredSupplies.length,
|
totalStock: suppliesFormatted.reduce((sum, item) => sum + item.currentStock, 0),
|
||||||
removedCount: sellerSupplies.length - filteredSupplies.length,
|
lowStockItems: suppliesFormatted.filter(item => item.currentStock <= item.minStock).length,
|
||||||
})
|
})
|
||||||
|
|
||||||
return filteredSupplies
|
return suppliesFormatted
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in V2 seller supplies on warehouse resolver:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Мои товары и расходники (для поставщиков)
|
// Мои товары и расходники (для поставщиков)
|
||||||
@ -2857,6 +2910,9 @@ export const resolvers = {
|
|||||||
|
|
||||||
// Новая система складских остатков V2 (заменяет старый myFulfillmentSupplies)
|
// Новая система складских остатков V2 (заменяет старый myFulfillmentSupplies)
|
||||||
...fulfillmentInventoryV2Queries,
|
...fulfillmentInventoryV2Queries,
|
||||||
|
|
||||||
|
// V2 система складских остатков расходников селлера
|
||||||
|
...sellerInventoryV2Queries,
|
||||||
},
|
},
|
||||||
|
|
||||||
Mutation: {
|
Mutation: {
|
||||||
@ -5513,46 +5569,8 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем расходники на основе заказанных товаров
|
// V2 СИСТЕМА: Расходники будут автоматически созданы при подтверждении заказа
|
||||||
// Расходники создаются в организации получателя (фулфилмент-центре)
|
console.warn('📦 V2 система: расходники будут созданы автоматически при доставке через соответствующие резолверы')
|
||||||
// Определяем тип расходников на основе consumableType
|
|
||||||
const supplyType =
|
|
||||||
args.input.consumableType === 'SELLER_CONSUMABLES' ? 'SELLER_CONSUMABLES' : 'FULFILLMENT_CONSUMABLES'
|
|
||||||
|
|
||||||
// Определяем sellerOwnerId для расходников селлеров
|
|
||||||
const sellerOwnerId = supplyType === 'SELLER_CONSUMABLES' ? currentUser.organization!.id : null
|
|
||||||
|
|
||||||
const suppliesData = args.input.items.map((item) => {
|
|
||||||
const product = products.find((p) => p.id === item.productId)!
|
|
||||||
const productWithCategory = supplyOrder.items.find(
|
|
||||||
(orderItem: { productId: string; product: { category?: { name: string } | null } }) =>
|
|
||||||
orderItem.productId === item.productId,
|
|
||||||
)?.product
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: product.name,
|
|
||||||
article: product.article, // ИСПРАВЛЕНО: Добавляем артикул товара для уникальности
|
|
||||||
description: product.description || `Заказано у ${partner.name}`,
|
|
||||||
price: product.price, // Цена закупки у поставщика
|
|
||||||
quantity: item.quantity,
|
|
||||||
unit: 'шт',
|
|
||||||
category: productWithCategory?.category?.name || 'Расходники',
|
|
||||||
status: 'planned', // Статус "запланировано" (ожидает одобрения поставщиком)
|
|
||||||
date: new Date(args.input.deliveryDate),
|
|
||||||
supplier: partner.name || partner.fullName || 'Не указан',
|
|
||||||
minStock: Math.round(item.quantity * 0.1), // 10% от заказанного как минимальный остаток
|
|
||||||
currentStock: 0, // Пока товар не пришел
|
|
||||||
type: supplyType, // ИСПРАВЛЕНО: Добавляем тип расходников
|
|
||||||
sellerOwnerId: sellerOwnerId, // ИСПРАВЛЕНО: Добавляем владельца для расходников селлеров
|
|
||||||
// Расходники создаются в организации получателя (фулфилмент-центре)
|
|
||||||
organizationId: fulfillmentCenterId || currentUser.organization!.id,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Создаем расходники
|
|
||||||
await prisma.supply.createMany({
|
|
||||||
data: suppliesData,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 🔔 ОТПРАВЛЯЕМ УВЕДОМЛЕНИЕ ПОСТАВЩИКУ О НОВОМ ЗАКАЗЕ
|
// 🔔 ОТПРАВЛЯЕМ УВЕДОМЛЕНИЕ ПОСТАВЩИКУ О НОВОМ ЗАКАЗЕ
|
||||||
try {
|
try {
|
||||||
@ -7299,113 +7317,8 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем расходники
|
// V2 СИСТЕМА: Расходники автоматически обрабатываются в seller-consumables.ts и fulfillment-consumables.ts
|
||||||
for (const item of existingOrder.items) {
|
console.warn('📦 V2 система автоматически обработает инвентарь через специализированные резолверы')
|
||||||
console.warn('📦 Обрабатываем товар:', {
|
|
||||||
productName: item.product.name,
|
|
||||||
quantity: item.quantity,
|
|
||||||
targetOrganizationId,
|
|
||||||
consumableType: existingOrder.consumableType,
|
|
||||||
})
|
|
||||||
|
|
||||||
// ИСПРАВЛЕНИЕ: Определяем правильный тип расходников
|
|
||||||
const isSellerSupply = existingOrder.consumableType === 'SELLER_CONSUMABLES'
|
|
||||||
const supplyType = isSellerSupply ? 'SELLER_CONSUMABLES' : 'FULFILLMENT_CONSUMABLES'
|
|
||||||
const sellerOwnerId = isSellerSupply ? existingOrder.organizationId : null
|
|
||||||
|
|
||||||
console.warn('🔍 Определен тип расходников:', {
|
|
||||||
isSellerSupply,
|
|
||||||
supplyType,
|
|
||||||
sellerOwnerId,
|
|
||||||
})
|
|
||||||
|
|
||||||
// ИСПРАВЛЕНИЕ: Ищем по Артикул СФ для уникальности вместо имени
|
|
||||||
const whereCondition = isSellerSupply
|
|
||||||
? {
|
|
||||||
organizationId: targetOrganizationId,
|
|
||||||
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
|
|
||||||
type: 'SELLER_CONSUMABLES' as const,
|
|
||||||
sellerOwnerId: existingOrder.organizationId,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
organizationId: targetOrganizationId,
|
|
||||||
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
|
|
||||||
type: 'FULFILLMENT_CONSUMABLES' as const,
|
|
||||||
sellerOwnerId: null, // Для фулфилмента sellerOwnerId должен быть null
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('🔍 Ищем существующий расходник с условиями:', whereCondition)
|
|
||||||
|
|
||||||
const existingSupply = await prisma.supply.findFirst({
|
|
||||||
where: whereCondition,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existingSupply) {
|
|
||||||
console.warn('📈 ОБНОВЛЯЕМ существующий расходник:', {
|
|
||||||
id: existingSupply.id,
|
|
||||||
oldStock: existingSupply.currentStock,
|
|
||||||
oldQuantity: existingSupply.quantity,
|
|
||||||
addingQuantity: item.quantity,
|
|
||||||
})
|
|
||||||
|
|
||||||
// ОБНОВЛЯЕМ существующий расходник
|
|
||||||
const updatedSupply = await prisma.supply.update({
|
|
||||||
where: { id: existingSupply.id },
|
|
||||||
data: {
|
|
||||||
currentStock: existingSupply.currentStock + item.quantity,
|
|
||||||
// ❌ ИСПРАВЛЕНО: НЕ обновляем quantity - это изначальное количество заказа!
|
|
||||||
// quantity остается как было изначально заказано
|
|
||||||
status: 'in-stock', // Меняем статус на "на складе"
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
console.warn('✅ Расходник ОБНОВЛЕН (НЕ создан дубликат):', {
|
|
||||||
id: updatedSupply.id,
|
|
||||||
name: updatedSupply.name,
|
|
||||||
newCurrentStock: updatedSupply.currentStock,
|
|
||||||
newTotalQuantity: updatedSupply.quantity,
|
|
||||||
type: updatedSupply.type,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.warn('➕ СОЗДАЕМ новый расходник (не найден существующий):', {
|
|
||||||
name: item.product.name,
|
|
||||||
quantity: item.quantity,
|
|
||||||
organizationId: targetOrganizationId,
|
|
||||||
type: supplyType,
|
|
||||||
sellerOwnerId: sellerOwnerId,
|
|
||||||
})
|
|
||||||
|
|
||||||
// СОЗДАЕМ новый расходник
|
|
||||||
const newSupply = await prisma.supply.create({
|
|
||||||
data: {
|
|
||||||
name: item.product.name,
|
|
||||||
article: item.product.article, // ДОБАВЛЕНО: Артикул СФ для уникальности
|
|
||||||
description: item.product.description || `Поставка от ${existingOrder.partner.name}`,
|
|
||||||
price: item.price, // Цена закупки у поставщика
|
|
||||||
quantity: item.quantity,
|
|
||||||
unit: 'шт',
|
|
||||||
category: item.product.category?.name || 'Расходники',
|
|
||||||
status: 'in-stock',
|
|
||||||
date: new Date(),
|
|
||||||
supplier: existingOrder.partner.name || existingOrder.partner.fullName || 'Не указан',
|
|
||||||
minStock: Math.round(item.quantity * 0.1),
|
|
||||||
currentStock: item.quantity,
|
|
||||||
organizationId: targetOrganizationId,
|
|
||||||
type: supplyType as 'SELLER_CONSUMABLES' | 'FULFILLMENT_CONSUMABLES',
|
|
||||||
sellerOwnerId: sellerOwnerId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
console.warn('✅ Новый расходник СОЗДАН:', {
|
|
||||||
id: newSupply.id,
|
|
||||||
name: newSupply.name,
|
|
||||||
currentStock: newSupply.currentStock,
|
|
||||||
type: newSupply.type,
|
|
||||||
sellerOwnerId: newSupply.sellerOwnerId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('🎉 Склад организации успешно обновлен!')
|
console.warn('🎉 Склад организации успешно обновлен!')
|
||||||
}
|
}
|
||||||
@ -8412,54 +8325,8 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Добавляем расходники в склад фулфилмента как SELLER_CONSUMABLES
|
// V2 СИСТЕМА: Инвентарь селлера автоматически обновляется через SellerConsumableInventory
|
||||||
console.warn('📦 Обновляем склад фулфилмента для селлерской поставки...')
|
console.warn('📦 V2 система автоматически обновит SellerConsumableInventory через processSellerConsumableSupplyReceipt')
|
||||||
for (const item of sellerSupply.items) {
|
|
||||||
const existingSupply = await prisma.supply.findFirst({
|
|
||||||
where: {
|
|
||||||
organizationId: currentUser.organization.id,
|
|
||||||
article: item.product.article,
|
|
||||||
type: 'SELLER_CONSUMABLES',
|
|
||||||
sellerOwnerId: sellerSupply.sellerId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existingSupply) {
|
|
||||||
await prisma.supply.update({
|
|
||||||
where: { id: existingSupply.id },
|
|
||||||
data: {
|
|
||||||
currentStock: existingSupply.currentStock + item.requestedQuantity,
|
|
||||||
status: 'in-stock',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
console.warn(
|
|
||||||
`📈 Обновлен расходник селлера "${item.product.name}" (владелец: ${sellerSupply.seller.name}): ${existingSupply.currentStock} -> ${existingSupply.currentStock + item.requestedQuantity}`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
await prisma.supply.create({
|
|
||||||
data: {
|
|
||||||
name: item.product.name,
|
|
||||||
article: item.product.article,
|
|
||||||
description: `Расходники селлера ${sellerSupply.seller.name || sellerSupply.seller.fullName}`,
|
|
||||||
price: item.unitPrice,
|
|
||||||
quantity: item.requestedQuantity,
|
|
||||||
actualQuantity: item.requestedQuantity,
|
|
||||||
currentStock: item.requestedQuantity,
|
|
||||||
usedStock: 0,
|
|
||||||
unit: 'шт',
|
|
||||||
category: item.product.category?.name || 'Расходники',
|
|
||||||
status: 'in-stock',
|
|
||||||
supplier: sellerSupply.supplier?.name || sellerSupply.supplier?.fullName || 'Поставщик',
|
|
||||||
type: 'SELLER_CONSUMABLES',
|
|
||||||
sellerOwnerId: sellerSupply.sellerId,
|
|
||||||
organizationId: currentUser.organization.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
console.warn(
|
|
||||||
`➕ Создан новый расходник селлера "${item.product.name}" (владелец: ${sellerSupply.seller.name}): ${item.requestedQuantity} единиц`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -8565,81 +8432,8 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем склад фулфилмента с учетом типа расходников
|
// V2 СИСТЕМА: Инвентарь автоматически обновляется через специализированные резолверы
|
||||||
console.warn('📦 Обновляем склад фулфилмента...')
|
console.warn('📦 V2 система: склад обновится автоматически через FulfillmentConsumableInventory и SellerConsumableInventory')
|
||||||
console.warn(`🏷️ Тип поставки: ${existingOrder.consumableType || 'FULFILLMENT_CONSUMABLES'}`)
|
|
||||||
|
|
||||||
for (const item of existingOrder.items) {
|
|
||||||
// Определяем тип расходников и владельца
|
|
||||||
const isSellerSupply = existingOrder.consumableType === 'SELLER_CONSUMABLES'
|
|
||||||
const supplyType = isSellerSupply ? 'SELLER_CONSUMABLES' : 'FULFILLMENT_CONSUMABLES'
|
|
||||||
const sellerOwnerId = isSellerSupply ? updatedOrder.organization?.id : null
|
|
||||||
|
|
||||||
// Для расходников селлеров ищем по Артикул СФ И по владельцу
|
|
||||||
const whereCondition = isSellerSupply
|
|
||||||
? {
|
|
||||||
organizationId: currentUser.organization.id,
|
|
||||||
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
|
|
||||||
type: 'SELLER_CONSUMABLES' as const,
|
|
||||||
sellerOwnerId: sellerOwnerId,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
organizationId: currentUser.organization.id,
|
|
||||||
article: item.product.article, // ИЗМЕНЕНО: поиск по article вместо name
|
|
||||||
type: 'FULFILLMENT_CONSUMABLES' as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingSupply = await prisma.supply.findFirst({
|
|
||||||
where: whereCondition,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existingSupply) {
|
|
||||||
await prisma.supply.update({
|
|
||||||
where: { id: existingSupply.id },
|
|
||||||
data: {
|
|
||||||
currentStock: existingSupply.currentStock + item.quantity,
|
|
||||||
// ❌ ИСПРАВЛЕНО: НЕ обновляем quantity - это изначальное количество заказа!
|
|
||||||
status: 'in-stock',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
console.warn(
|
|
||||||
`📈 Обновлен существующий ${
|
|
||||||
isSellerSupply ? 'расходник селлера' : 'расходник фулфилмента'
|
|
||||||
} "${item.product.name}" ${
|
|
||||||
isSellerSupply ? `(владелец: ${updatedOrder.organization?.name})` : ''
|
|
||||||
}: ${existingSupply.currentStock} -> ${existingSupply.currentStock + item.quantity}`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
await prisma.supply.create({
|
|
||||||
data: {
|
|
||||||
name: item.product.name,
|
|
||||||
article: item.product.article, // ДОБАВЛЕНО: Артикул СФ для уникальности
|
|
||||||
description: isSellerSupply
|
|
||||||
? `Расходники селлера ${updatedOrder.organization?.name || updatedOrder.organization?.fullName}`
|
|
||||||
: item.product.description || `Расходники от ${updatedOrder.partner.name}`,
|
|
||||||
price: item.price, // Цена закупки у поставщика
|
|
||||||
quantity: item.quantity,
|
|
||||||
actualQuantity: item.quantity, // НОВОЕ: Фактически поставленное количество
|
|
||||||
currentStock: item.quantity,
|
|
||||||
usedStock: 0,
|
|
||||||
unit: 'шт',
|
|
||||||
category: item.product.category?.name || 'Расходники',
|
|
||||||
status: 'in-stock',
|
|
||||||
supplier: updatedOrder.partner.name || updatedOrder.partner.fullName || 'Поставщик',
|
|
||||||
type: supplyType as 'SELLER_CONSUMABLES' | 'FULFILLMENT_CONSUMABLES',
|
|
||||||
sellerOwnerId: sellerOwnerId,
|
|
||||||
organizationId: currentUser.organization.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
console.warn(
|
|
||||||
`➕ Создан новый ${
|
|
||||||
isSellerSupply ? 'расходник селлера' : 'расходник фулфилмента'
|
|
||||||
} "${item.product.name}" ${
|
|
||||||
isSellerSupply ? `(владелец: ${updatedOrder.organization?.name})` : ''
|
|
||||||
}: ${item.quantity} единиц`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('🎉 Синхронизация склада завершена успешно!')
|
console.warn('🎉 Синхронизация склада завершена успешно!')
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { notifyOrganization } from '@/lib/realtime'
|
import { notifyOrganization } from '@/lib/realtime'
|
||||||
|
|
||||||
import { Context } from '../context'
|
import { Context } from '../context'
|
||||||
|
import { processSellerConsumableSupplyReceipt } from '@/lib/inventory-management'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// 🔍 QUERY RESOLVERS
|
// 🔍 QUERY RESOLVERS
|
||||||
@ -543,6 +544,15 @@ export const sellerConsumableMutations = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'DELIVERED') {
|
if (status === 'DELIVERED') {
|
||||||
|
// 📦 АВТОМАТИЧЕСКОЕ ПОПОЛНЕНИЕ ИНВЕНТАРЯ V2
|
||||||
|
const inventoryItems = updatedSupply.items.map(item => ({
|
||||||
|
productId: item.productId,
|
||||||
|
receivedQuantity: item.quantity,
|
||||||
|
unitPrice: parseFloat(item.price.toString()),
|
||||||
|
}))
|
||||||
|
|
||||||
|
await processSellerConsumableSupplyReceipt(args.id, inventoryItems)
|
||||||
|
|
||||||
await notifyOrganization(
|
await notifyOrganization(
|
||||||
supply.sellerId,
|
supply.sellerId,
|
||||||
`Поставка доставлена в ${supply.fulfillmentCenter.name}`,
|
`Поставка доставлена в ${supply.fulfillmentCenter.name}`,
|
||||||
|
238
src/graphql/resolvers/seller-inventory-v2.ts
Normal file
238
src/graphql/resolvers/seller-inventory-v2.ts
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
import { Context } from '../context'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* НОВЫЙ V2 RESOLVER для складских остатков расходников селлера
|
||||||
|
*
|
||||||
|
* Управляет расходниками селлера, хранящимися на складе фулфилмента
|
||||||
|
* Использует новую модель SellerConsumableInventory
|
||||||
|
* Возвращает данные в формате Supply для совместимости с фронтендом
|
||||||
|
*/
|
||||||
|
export const sellerInventoryV2Queries = {
|
||||||
|
/**
|
||||||
|
* Расходники селлера на складе фулфилмента (для селлера)
|
||||||
|
*/
|
||||||
|
mySellerConsumableInventory: async (_: unknown, __: unknown, context: Context) => {
|
||||||
|
console.warn('🚀 V2 SELLER INVENTORY RESOLVER CALLED')
|
||||||
|
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user?.organization || user.organization.type !== 'SELLER') {
|
||||||
|
throw new GraphQLError('Доступно только для селлеров')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем складские остатки расходников селлера из V2 модели
|
||||||
|
const inventory = await prisma.sellerConsumableInventory.findMany({
|
||||||
|
where: {
|
||||||
|
sellerId: user.organizationId || '',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
product: {
|
||||||
|
include: {
|
||||||
|
organization: true, // Поставщик товара
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: 'desc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.warn('📊 V2 Seller Inventory loaded:', {
|
||||||
|
sellerId: user.organizationId,
|
||||||
|
inventoryCount: inventory.length,
|
||||||
|
items: inventory.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
productName: item.product.name,
|
||||||
|
currentStock: item.currentStock,
|
||||||
|
minStock: item.minStock,
|
||||||
|
fulfillmentCenter: item.fulfillmentCenter.name,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Преобразуем V2 данные в формат Supply для совместимости с фронтендом
|
||||||
|
const suppliesFormatted = inventory.map((item) => {
|
||||||
|
// Вычисляем статус на основе остатков
|
||||||
|
const status = item.currentStock > 0 ? 'На складе' : 'Недоступен'
|
||||||
|
|
||||||
|
// Определяем поставщика
|
||||||
|
const supplier = item.product.organization?.name || 'Неизвестен'
|
||||||
|
|
||||||
|
return {
|
||||||
|
// === ИДЕНТИФИКАЦИЯ (из V2) ===
|
||||||
|
id: item.id,
|
||||||
|
productId: item.product.id,
|
||||||
|
|
||||||
|
// === ОСНОВНЫЕ ДАННЫЕ (из Product) ===
|
||||||
|
name: item.product.name,
|
||||||
|
article: item.product.article,
|
||||||
|
description: item.product.description || '',
|
||||||
|
unit: item.product.unit || 'шт',
|
||||||
|
category: item.product.category || 'Расходники',
|
||||||
|
imageUrl: item.product.imageUrl,
|
||||||
|
|
||||||
|
// === ЦЕНЫ (из V2) ===
|
||||||
|
price: parseFloat(item.averageCost.toString()),
|
||||||
|
pricePerUnit: item.usagePrice ? parseFloat(item.usagePrice.toString()) : null,
|
||||||
|
|
||||||
|
// === СКЛАДСКИЕ ДАННЫЕ (из V2) ===
|
||||||
|
currentStock: item.currentStock,
|
||||||
|
minStock: item.minStock,
|
||||||
|
usedStock: item.totalUsed || 0, // Всего использовано
|
||||||
|
quantity: item.totalReceived, // Всего получено
|
||||||
|
warehouseStock: item.currentStock, // Дублируем для совместимости
|
||||||
|
reservedStock: item.reservedStock,
|
||||||
|
|
||||||
|
// === ИСПОЛЬЗОВАНИЕ (из V2) ===
|
||||||
|
shippedQuantity: item.totalUsed,
|
||||||
|
totalShipped: item.totalUsed,
|
||||||
|
|
||||||
|
// === СТАТУС И МЕТАДАННЫЕ ===
|
||||||
|
status,
|
||||||
|
isAvailable: item.currentStock > 0,
|
||||||
|
supplier,
|
||||||
|
date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(),
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
updatedAt: item.updatedAt.toISOString(),
|
||||||
|
|
||||||
|
// === ДОПОЛНИТЕЛЬНЫЕ ПОЛЯ ===
|
||||||
|
notes: item.notes,
|
||||||
|
warehouseConsumableId: item.id,
|
||||||
|
fulfillmentCenter: item.fulfillmentCenter.name, // Где хранится
|
||||||
|
|
||||||
|
// === ВЫЧИСЛЯЕМЫЕ ПОЛЯ ===
|
||||||
|
actualQuantity: item.currentStock, // Фактически доступно
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.warn('✅ V2 Seller Supplies formatted for frontend:', {
|
||||||
|
count: suppliesFormatted.length,
|
||||||
|
totalStock: suppliesFormatted.reduce((sum, item) => sum + item.currentStock, 0),
|
||||||
|
lowStockItems: suppliesFormatted.filter(item => item.currentStock <= item.minStock).length,
|
||||||
|
})
|
||||||
|
|
||||||
|
return suppliesFormatted
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in V2 seller inventory resolver:', error)
|
||||||
|
|
||||||
|
// Возвращаем пустой массив вместо ошибки для graceful fallback
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расходники всех селлеров на складе фулфилмента (для фулфилмента)
|
||||||
|
*/
|
||||||
|
allSellerConsumableInventory: async (_: unknown, __: unknown, context: Context) => {
|
||||||
|
console.warn('🚀 V2 ALL SELLER INVENTORY RESOLVER CALLED')
|
||||||
|
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user?.organization || user.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Доступно только для фулфилмент-центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем складские остатки всех селлеров на нашем складе
|
||||||
|
const inventory = await prisma.sellerConsumableInventory.findMany({
|
||||||
|
where: {
|
||||||
|
fulfillmentCenterId: user.organizationId || '',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
product: {
|
||||||
|
include: {
|
||||||
|
organization: true, // Поставщик товара
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ seller: { name: 'asc' } }, // Группируем по селлерам
|
||||||
|
{ updatedAt: 'desc' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
console.warn('📊 V2 All Seller Inventory loaded for fulfillment:', {
|
||||||
|
fulfillmentCenterId: user.organizationId,
|
||||||
|
inventoryCount: inventory.length,
|
||||||
|
uniqueSellers: new Set(inventory.map(item => item.sellerId)).size,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Возвращаем данные сгруппированные по селлерам для таблицы "Детализация по магазинам"
|
||||||
|
return inventory.map((item) => {
|
||||||
|
const status = item.currentStock > 0 ? 'На складе' : 'Недоступен'
|
||||||
|
const supplier = item.product.organization?.name || 'Неизвестен'
|
||||||
|
|
||||||
|
return {
|
||||||
|
// === ИДЕНТИФИКАЦИЯ ===
|
||||||
|
id: item.id,
|
||||||
|
productId: item.product.id,
|
||||||
|
sellerId: item.sellerId,
|
||||||
|
sellerName: item.seller.name,
|
||||||
|
|
||||||
|
// === ОСНОВНЫЕ ДАННЫЕ ===
|
||||||
|
name: item.product.name,
|
||||||
|
article: item.product.article,
|
||||||
|
description: item.product.description || '',
|
||||||
|
unit: item.product.unit || 'шт',
|
||||||
|
category: item.product.category || 'Расходники',
|
||||||
|
imageUrl: item.product.imageUrl,
|
||||||
|
|
||||||
|
// === СКЛАДСКИЕ ДАННЫЕ ===
|
||||||
|
currentStock: item.currentStock,
|
||||||
|
minStock: item.minStock,
|
||||||
|
usedStock: item.totalUsed || 0,
|
||||||
|
quantity: item.totalReceived,
|
||||||
|
reservedStock: item.reservedStock,
|
||||||
|
|
||||||
|
// === ЦЕНЫ ===
|
||||||
|
price: parseFloat(item.averageCost.toString()),
|
||||||
|
pricePerUnit: item.usagePrice ? parseFloat(item.usagePrice.toString()) : null,
|
||||||
|
|
||||||
|
// === МЕТАДАННЫЕ ===
|
||||||
|
status,
|
||||||
|
isAvailable: item.currentStock > 0,
|
||||||
|
supplier,
|
||||||
|
date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(),
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
updatedAt: item.updatedAt.toISOString(),
|
||||||
|
notes: item.notes,
|
||||||
|
|
||||||
|
// === СПЕЦИФИЧНЫЕ ПОЛЯ ДЛЯ ФУЛФИЛМЕНТА ===
|
||||||
|
warehouseConsumableId: item.id,
|
||||||
|
actualQuantity: item.currentStock,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in V2 all seller inventory resolver:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
@ -1985,4 +1985,48 @@ export const typeDefs = gql`
|
|||||||
message: String!
|
message: String!
|
||||||
order: FulfillmentConsumableSupplyOrder
|
order: FulfillmentConsumableSupplyOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# === V2 SELLER CONSUMABLE INVENTORY SYSTEM ===
|
||||||
|
# Типы для складского учета расходников селлера на складе фулфилмента
|
||||||
|
|
||||||
|
type SellerConsumableInventory {
|
||||||
|
id: ID!
|
||||||
|
|
||||||
|
# Связи
|
||||||
|
sellerId: ID!
|
||||||
|
seller: Organization!
|
||||||
|
fulfillmentCenterId: ID!
|
||||||
|
fulfillmentCenter: Organization!
|
||||||
|
productId: ID!
|
||||||
|
product: Product!
|
||||||
|
|
||||||
|
# Складские данные
|
||||||
|
currentStock: Int!
|
||||||
|
minStock: Int!
|
||||||
|
maxStock: Int
|
||||||
|
reservedStock: Int!
|
||||||
|
totalReceived: Int!
|
||||||
|
totalUsed: Int!
|
||||||
|
|
||||||
|
# Цены
|
||||||
|
averageCost: Float!
|
||||||
|
usagePrice: Float
|
||||||
|
|
||||||
|
# Метаданные
|
||||||
|
lastSupplyDate: DateTime
|
||||||
|
lastUsageDate: DateTime
|
||||||
|
notes: String
|
||||||
|
|
||||||
|
createdAt: DateTime!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
# Расширяем Query для складских остатков селлера
|
||||||
|
extend type Query {
|
||||||
|
# Мои расходники на складе фулфилмента (для селлера)
|
||||||
|
mySellerConsumableInventory: [Supply!]! # Возвращаем в формате Supply для совместимости
|
||||||
|
|
||||||
|
# Все расходники селлеров на складе (для фулфилмента)
|
||||||
|
allSellerConsumableInventory: [Supply!]! # Для таблицы "Детализация по магазинам"
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
import { prisma } from './prisma'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* СИСТЕМА УПРАВЛЕНИЯ СКЛАДСКИМИ ОСТАТКАМИ V2
|
* СИСТЕМА УПРАВЛЕНИЯ СКЛАДСКИМИ ОСТАТКАМИ V2
|
||||||
@ -141,6 +143,51 @@ export async function processSupplyOrderReceipt(
|
|||||||
console.log(`✅ Supply order ${supplyOrderId} processed successfully`)
|
console.log(`✅ Supply order ${supplyOrderId} processed successfully`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обрабатывает поступление заказа расходников селлера
|
||||||
|
* Автоматически пополняет SellerConsumableInventory при статусе DELIVERED
|
||||||
|
*/
|
||||||
|
export async function processSellerConsumableSupplyReceipt(
|
||||||
|
supplyOrderId: string,
|
||||||
|
items: Array<{
|
||||||
|
productId: string
|
||||||
|
receivedQuantity: number
|
||||||
|
unitPrice: number
|
||||||
|
}>,
|
||||||
|
): Promise<void> {
|
||||||
|
console.log(`🔄 Processing seller consumable supply receipt: ${supplyOrderId}`)
|
||||||
|
|
||||||
|
// Получаем информацию о поставке селлера
|
||||||
|
const supplyOrder = await prisma.sellerConsumableSupplyOrder.findUnique({
|
||||||
|
where: { id: supplyOrderId },
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplyOrder) {
|
||||||
|
throw new Error(`Seller supply order not found: ${supplyOrderId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрабатываем каждую позицию расходников селлера
|
||||||
|
for (const item of items) {
|
||||||
|
await updateSellerInventory({
|
||||||
|
sellerId: supplyOrder.sellerId,
|
||||||
|
fulfillmentCenterId: supplyOrder.fulfillmentCenterId,
|
||||||
|
productId: item.productId,
|
||||||
|
quantity: item.receivedQuantity,
|
||||||
|
type: 'INCOMING',
|
||||||
|
sourceId: supplyOrderId,
|
||||||
|
sourceType: 'SELLER_SUPPLY_ORDER',
|
||||||
|
unitCost: item.unitPrice,
|
||||||
|
notes: `Приемка заказа селлера ${supplyOrderId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Seller consumable supply receipt processed: ${items.length} items`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработка отгрузки селлеру
|
* Обработка отгрузки селлеру
|
||||||
*/
|
*/
|
||||||
@ -246,4 +293,94 @@ export async function getInventoryStats(fulfillmentCenterId: string) {
|
|||||||
totalShipped: stats._sum.totalShipped || 0,
|
totalShipped: stats._sum.totalShipped || 0,
|
||||||
lowStockCount,
|
lowStockCount,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет складские остатки расходников селлера
|
||||||
|
* Аналог updateInventory, но для SellerConsumableInventory
|
||||||
|
*/
|
||||||
|
async function updateSellerInventory(operation: {
|
||||||
|
sellerId: string
|
||||||
|
fulfillmentCenterId: string
|
||||||
|
productId: string
|
||||||
|
quantity: number
|
||||||
|
type: 'INCOMING' | 'OUTGOING' | 'USAGE'
|
||||||
|
sourceId: string
|
||||||
|
sourceType: 'SELLER_SUPPLY_ORDER' | 'SELLER_USAGE' | 'SELLER_WRITEOFF'
|
||||||
|
unitCost?: number
|
||||||
|
notes?: string
|
||||||
|
}): Promise<void> {
|
||||||
|
const {
|
||||||
|
sellerId,
|
||||||
|
fulfillmentCenterId,
|
||||||
|
productId,
|
||||||
|
quantity,
|
||||||
|
type,
|
||||||
|
sourceId,
|
||||||
|
sourceType,
|
||||||
|
unitCost,
|
||||||
|
notes,
|
||||||
|
} = operation
|
||||||
|
|
||||||
|
console.log(`📦 Updating seller inventory: ${type} ${quantity} units for product ${productId}`)
|
||||||
|
|
||||||
|
// Находим или создаем запись в инвентаре селлера
|
||||||
|
const existingInventory = await prisma.sellerConsumableInventory.findUnique({
|
||||||
|
where: {
|
||||||
|
sellerId_fulfillmentCenterId_productId: {
|
||||||
|
sellerId,
|
||||||
|
fulfillmentCenterId,
|
||||||
|
productId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingInventory) {
|
||||||
|
// Обновляем существующую запись
|
||||||
|
const stockChange = type === 'INCOMING' ? quantity : -quantity
|
||||||
|
const newCurrentStock = Math.max(0, existingInventory.currentStock + stockChange)
|
||||||
|
|
||||||
|
// Пересчитываем среднюю стоимость при поступлении
|
||||||
|
let newAverageCost = existingInventory.averageCost
|
||||||
|
if (type === 'INCOMING' && unitCost) {
|
||||||
|
const totalCost = parseFloat(existingInventory.averageCost.toString()) * existingInventory.totalReceived + unitCost * quantity
|
||||||
|
const totalQuantity = existingInventory.totalReceived + quantity
|
||||||
|
newAverageCost = totalQuantity > 0 ? new Prisma.Decimal(totalCost / totalQuantity) : new Prisma.Decimal(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.sellerConsumableInventory.update({
|
||||||
|
where: { id: existingInventory.id },
|
||||||
|
data: {
|
||||||
|
currentStock: newCurrentStock,
|
||||||
|
totalReceived: type === 'INCOMING' ? existingInventory.totalReceived + quantity : existingInventory.totalReceived,
|
||||||
|
totalUsed: type === 'OUTGOING' || type === 'USAGE' ? existingInventory.totalUsed + quantity : existingInventory.totalUsed,
|
||||||
|
averageCost: newAverageCost,
|
||||||
|
lastSupplyDate: type === 'INCOMING' ? new Date() : existingInventory.lastSupplyDate,
|
||||||
|
lastUsageDate: type === 'OUTGOING' || type === 'USAGE' ? new Date() : existingInventory.lastUsageDate,
|
||||||
|
notes: notes || existingInventory.notes,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`✅ Updated seller inventory: ${existingInventory.id} → stock: ${newCurrentStock}`)
|
||||||
|
} else if (type === 'INCOMING') {
|
||||||
|
// Создаем новую запись только при поступлении
|
||||||
|
const newInventory = await prisma.sellerConsumableInventory.create({
|
||||||
|
data: {
|
||||||
|
sellerId,
|
||||||
|
fulfillmentCenterId,
|
||||||
|
productId,
|
||||||
|
currentStock: quantity,
|
||||||
|
totalReceived: quantity,
|
||||||
|
totalUsed: 0,
|
||||||
|
averageCost: new Prisma.Decimal(unitCost || 0),
|
||||||
|
lastSupplyDate: new Date(),
|
||||||
|
notes: notes || `Создано при ${sourceType} ${sourceId}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`✅ Created new seller inventory: ${newInventory.id} → stock: ${quantity}`)
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ Cannot perform ${type} operation on non-existent seller inventory`)
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,114 +0,0 @@
|
|||||||
// Скрипт для тестирования фильтрации поставок фулфилмента
|
|
||||||
const testData = [
|
|
||||||
// Тестовая поставка товаров (с услугами)
|
|
||||||
{
|
|
||||||
id: 'order1',
|
|
||||||
consumableType: 'SELLER_CONSUMABLES',
|
|
||||||
status: 'SUPPLIER_APPROVED',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item1',
|
|
||||||
recipe: {
|
|
||||||
services: [
|
|
||||||
{ id: 'service1', name: 'Упаковка' },
|
|
||||||
{ id: 'service2', name: 'Маркировка' }
|
|
||||||
]
|
|
||||||
}, // Есть услуги = товары
|
|
||||||
product: { name: 'Товар 1' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
// Тестовая поставка расходников (без услуг)
|
|
||||||
{
|
|
||||||
id: 'order2',
|
|
||||||
consumableType: 'SELLER_CONSUMABLES',
|
|
||||||
status: 'SUPPLIER_APPROVED',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item2',
|
|
||||||
recipe: {
|
|
||||||
services: []
|
|
||||||
}, // Нет услуг = расходники
|
|
||||||
product: { name: 'Расходник 1' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
// Поставка фулфилмента (не селлер)
|
|
||||||
{
|
|
||||||
id: 'order3',
|
|
||||||
consumableType: 'FULFILLMENT_CONSUMABLES',
|
|
||||||
status: 'SUPPLIER_APPROVED',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item3',
|
|
||||||
recipe: {
|
|
||||||
services: []
|
|
||||||
},
|
|
||||||
product: { name: 'Расходник ФФ' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
// Тест фильтрации товаров (логика из FulfillmentGoodsOrdersTab)
|
|
||||||
function testGoodsFiltering(orders) {
|
|
||||||
return orders.filter((order) => {
|
|
||||||
const isSellerConsumables = order.consumableType === 'SELLER_CONSUMABLES'
|
|
||||||
const hasServices = order.items?.some(item => item.recipe?.services && item.recipe.services.length > 0)
|
|
||||||
const isGoodsOnly = isSellerConsumables && hasServices
|
|
||||||
|
|
||||||
console.log(`📦 ТОВАРЫ - Заказ ${order.id}:`, {
|
|
||||||
isSellerConsumables,
|
|
||||||
hasServices,
|
|
||||||
isGoodsOnly,
|
|
||||||
result: isGoodsOnly ? '✅ ПОКАЗАТЬ' : '❌ СКРЫТЬ'
|
|
||||||
})
|
|
||||||
|
|
||||||
return isGoodsOnly
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Тест фильтрации расходников (логика из FulfillmentConsumablesOrdersTab)
|
|
||||||
function testConsumablesFiltering(orders) {
|
|
||||||
return orders.filter((order) => {
|
|
||||||
const isSellerConsumables = order.consumableType === 'SELLER_CONSUMABLES'
|
|
||||||
const hasServices = order.items?.some(item => item.recipe?.services && item.recipe.services.length > 0)
|
|
||||||
const isConsumablesOnly = isSellerConsumables && !hasServices
|
|
||||||
|
|
||||||
console.log(`🔧 РАСХОДНИКИ - Заказ ${order.id}:`, {
|
|
||||||
isSellerConsumables,
|
|
||||||
hasServices,
|
|
||||||
isConsumablesOnly,
|
|
||||||
result: isConsumablesOnly ? '✅ ПОКАЗАТЬ' : '❌ СКРЫТЬ'
|
|
||||||
})
|
|
||||||
|
|
||||||
return isConsumablesOnly
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🧪 ТЕСТИРОВАНИЕ ФИЛЬТРАЦИИ ПОСТАВОК ФУЛФИЛМЕНТА\n')
|
|
||||||
|
|
||||||
console.log('📋 ИСХОДНЫЕ ДАННЫЕ:')
|
|
||||||
testData.forEach(order => {
|
|
||||||
const servicesCount = order.items[0]?.recipe?.services?.length || 0
|
|
||||||
console.log(`- ${order.id}: ${order.consumableType}, услуг: ${servicesCount}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('\n📦 ТЕСТ ФИЛЬТРАЦИИ ТОВАРОВ:')
|
|
||||||
const goodsResult = testGoodsFiltering(testData)
|
|
||||||
console.log('Результат:', goodsResult.map(o => o.id))
|
|
||||||
|
|
||||||
console.log('\n🔧 ТЕСТ ФИЛЬТРАЦИИ РАСХОДНИКОВ:')
|
|
||||||
const consumablesResult = testConsumablesFiltering(testData)
|
|
||||||
console.log('Результат:', consumablesResult.map(o => o.id))
|
|
||||||
|
|
||||||
console.log('\n✅ ОЖИДАЕМЫЙ РЕЗУЛЬТАТ:')
|
|
||||||
console.log('- Товары должны показать: order1 (есть услуги)')
|
|
||||||
console.log('- Расходники должны показать: order2 (нет услуг)')
|
|
||||||
console.log('- order3 не должен показываться нигде (не SELLER_CONSUMABLES)')
|
|
||||||
|
|
||||||
console.log('\n🎯 ТЕСТ',
|
|
||||||
goodsResult.length === 1 && goodsResult[0].id === 'order1' &&
|
|
||||||
consumablesResult.length === 1 && consumablesResult[0].id === 'order2'
|
|
||||||
? 'ПРОШЕЛ ✅' : 'ПРОВАЛЕН ❌'
|
|
||||||
)
|
|
@ -1,220 +0,0 @@
|
|||||||
// Тест полного workflow от селлера до фулфилмента
|
|
||||||
// Проверяет все этапы: Этап 1.1, 1.2, 1.3
|
|
||||||
|
|
||||||
console.log('🧪 ТЕСТИРОВАНИЕ ПОЛНОГО WORKFLOW СЕЛЛЕР → ПОСТАВЩИК → ФУЛФИЛМЕНТ\n')
|
|
||||||
|
|
||||||
// Этап 1.1: Тест фильтрации товаров и расходников в фулфилменте
|
|
||||||
console.log('📋 ЭТАП 1.1: Тест фильтрации поставок фулфилмента')
|
|
||||||
|
|
||||||
const testData = [
|
|
||||||
// Товары (с услугами) - должны показываться в "Товар/Новые"
|
|
||||||
{
|
|
||||||
id: 'order1',
|
|
||||||
consumableType: 'SELLER_CONSUMABLES',
|
|
||||||
status: 'SUPPLIER_APPROVED',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item1',
|
|
||||||
recipe: {
|
|
||||||
services: [
|
|
||||||
{ id: 'service1', name: 'Упаковка' },
|
|
||||||
{ id: 'service2', name: 'Маркировка' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
product: { name: 'Товар с услугами' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
// Расходники (без услуг) - должны показываться в "Расходники селлера"
|
|
||||||
{
|
|
||||||
id: 'order2',
|
|
||||||
consumableType: 'SELLER_CONSUMABLES',
|
|
||||||
status: 'SUPPLIER_APPROVED',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item2',
|
|
||||||
recipe: {
|
|
||||||
services: []
|
|
||||||
},
|
|
||||||
product: { name: 'Расходники без услуг' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
// Расходники фулфилмента - не должны показываться нигде в селлерских разделах
|
|
||||||
{
|
|
||||||
id: 'order3',
|
|
||||||
consumableType: 'FULFILLMENT_CONSUMABLES',
|
|
||||||
status: 'SUPPLIER_APPROVED',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item3',
|
|
||||||
recipe: {
|
|
||||||
services: []
|
|
||||||
},
|
|
||||||
product: { name: 'Расходник ФФ' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
// Функция фильтрации товаров (из FulfillmentGoodsOrdersTab)
|
|
||||||
function testGoodsFiltering(orders) {
|
|
||||||
return orders.filter((order) => {
|
|
||||||
const isSellerConsumables = order.consumableType === 'SELLER_CONSUMABLES'
|
|
||||||
const hasServices = order.items?.some(item => item.recipe?.services && item.recipe.services.length > 0)
|
|
||||||
const isGoodsOnly = isSellerConsumables && hasServices
|
|
||||||
|
|
||||||
console.log(`📦 ТОВАРЫ - Заказ ${order.id}:`, {
|
|
||||||
isSellerConsumables,
|
|
||||||
hasServices,
|
|
||||||
isGoodsOnly,
|
|
||||||
result: isGoodsOnly ? '✅ ПОКАЗАТЬ' : '❌ СКРЫТЬ'
|
|
||||||
})
|
|
||||||
|
|
||||||
return isGoodsOnly
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Функция фильтрации расходников (из FulfillmentConsumablesOrdersTab)
|
|
||||||
function testConsumablesFiltering(orders) {
|
|
||||||
return orders.filter((order) => {
|
|
||||||
const isSellerConsumables = order.consumableType === 'SELLER_CONSUMABLES'
|
|
||||||
const hasServices = order.items?.some(item => item.recipe?.services && item.recipe.services.length > 0)
|
|
||||||
const isConsumablesOnly = isSellerConsumables && !hasServices
|
|
||||||
|
|
||||||
console.log(`🔧 РАСХОДНИКИ - Заказ ${order.id}:`, {
|
|
||||||
isSellerConsumables,
|
|
||||||
hasServices,
|
|
||||||
isConsumablesOnly,
|
|
||||||
result: isConsumablesOnly ? '✅ ПОКАЗАТЬ' : '❌ СКРЫТЬ'
|
|
||||||
})
|
|
||||||
|
|
||||||
return isConsumablesOnly
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const goodsResult = testGoodsFiltering(testData)
|
|
||||||
const consumablesResult = testConsumablesFiltering(testData)
|
|
||||||
|
|
||||||
console.log('\n📊 РЕЗУЛЬТАТЫ ФИЛЬТРАЦИИ:')
|
|
||||||
console.log('- Товары показать:', goodsResult.map(o => o.id))
|
|
||||||
console.log('- Расходники показать:', consumablesResult.map(o => o.id))
|
|
||||||
|
|
||||||
const stage1_1_passed = goodsResult.length === 1 && goodsResult[0].id === 'order1' &&
|
|
||||||
consumablesResult.length === 1 && consumablesResult[0].id === 'order2'
|
|
||||||
|
|
||||||
console.log(`\n✅ ЭТАП 1.1: ${stage1_1_passed ? 'ПРОЙДЕН' : 'ПРОВАЛЕН'}`)
|
|
||||||
|
|
||||||
// Этап 1.2: Тест отображения статуса у поставщиков
|
|
||||||
console.log('\n📋 ЭТАП 1.2: Тест скрытия статуса у поставщиков')
|
|
||||||
|
|
||||||
// Имитация логики из MultiLevelSuppliesTable
|
|
||||||
function testSupplierStatusDisplay(userRole) {
|
|
||||||
const shouldShowStatus = userRole !== 'WHOLESALE'
|
|
||||||
console.log(`👤 Роль пользователя: ${userRole}`)
|
|
||||||
console.log(`📋 Показывать статус: ${shouldShowStatus ? '✅ ДА' : '❌ НЕТ'}`)
|
|
||||||
return shouldShowStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
const sellerShowsStatus = testSupplierStatusDisplay('SELLER')
|
|
||||||
const supplierShowsStatus = testSupplierStatusDisplay('WHOLESALE')
|
|
||||||
const fulfillmentShowsStatus = testSupplierStatusDisplay('FULFILLMENT')
|
|
||||||
|
|
||||||
const stage1_2_passed = sellerShowsStatus && !supplierShowsStatus && fulfillmentShowsStatus
|
|
||||||
|
|
||||||
console.log(`\n✅ ЭТАП 1.2: ${stage1_2_passed ? 'ПРОЙДЕН' : 'ПРОВАЛЕН'}`)
|
|
||||||
|
|
||||||
// Этап 1.3: Тест формы принятия товаров фулфилментом
|
|
||||||
console.log('\n📋 ЭТАП 1.3: Тест функциональности форм фулфилмента')
|
|
||||||
|
|
||||||
// Имитация логики валидации из FulfillmentGoodsOrdersTab
|
|
||||||
function testAcceptOrderValidation(orderId, selectedEmployee, selectedLogistics) {
|
|
||||||
console.log(`📦 Проверка заказа ${orderId}:`)
|
|
||||||
|
|
||||||
if (!selectedEmployee[orderId]) {
|
|
||||||
console.log('❌ Не выбран ответственный сотрудник')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedLogistics[orderId]) {
|
|
||||||
console.log('❌ Не выбран логистический партнер')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Все поля заполнены корректно')
|
|
||||||
console.log('✅ Вызов мутации assignLogisticsToSupply')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Тест случаев валидации
|
|
||||||
const testCases = [
|
|
||||||
// Случай 1: Не выбраны ни сотрудник, ни логистика
|
|
||||||
{
|
|
||||||
orderId: 'test1',
|
|
||||||
selectedEmployee: {},
|
|
||||||
selectedLogistics: {},
|
|
||||||
expected: false,
|
|
||||||
name: 'Пустые поля'
|
|
||||||
},
|
|
||||||
// Случай 2: Выбран только сотрудник
|
|
||||||
{
|
|
||||||
orderId: 'test2',
|
|
||||||
selectedEmployee: { test2: 'emp1' },
|
|
||||||
selectedLogistics: {},
|
|
||||||
expected: false,
|
|
||||||
name: 'Только сотрудник'
|
|
||||||
},
|
|
||||||
// Случай 3: Выбрана только логистика
|
|
||||||
{
|
|
||||||
orderId: 'test3',
|
|
||||||
selectedEmployee: {},
|
|
||||||
selectedLogistics: { test3: 'log1' },
|
|
||||||
expected: false,
|
|
||||||
name: 'Только логистика'
|
|
||||||
},
|
|
||||||
// Случай 4: Выбраны оба поля
|
|
||||||
{
|
|
||||||
orderId: 'test4',
|
|
||||||
selectedEmployee: { test4: 'emp1' },
|
|
||||||
selectedLogistics: { test4: 'log1' },
|
|
||||||
expected: true,
|
|
||||||
name: 'Все поля заполнены'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
let stage1_3_passed = true
|
|
||||||
|
|
||||||
testCases.forEach(testCase => {
|
|
||||||
console.log(`\n🧪 Тест: ${testCase.name}`)
|
|
||||||
const result = testAcceptOrderValidation(
|
|
||||||
testCase.orderId,
|
|
||||||
testCase.selectedEmployee,
|
|
||||||
testCase.selectedLogistics
|
|
||||||
)
|
|
||||||
const passed = result === testCase.expected
|
|
||||||
console.log(`📊 Результат: ${passed ? '✅ ПРОЙДЕН' : '❌ ПРОВАЛЕН'}`)
|
|
||||||
|
|
||||||
if (!passed) stage1_3_passed = false
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`\n✅ ЭТАП 1.3: ${stage1_3_passed ? 'ПРОЙДЕН' : 'ПРОВАЛЕН'}`)
|
|
||||||
|
|
||||||
// Общий результат тестирования
|
|
||||||
console.log('\n🎯 ОБЩИЙ РЕЗУЛЬТАТ ТЕСТИРОВАНИЯ:')
|
|
||||||
console.log(`- Этап 1.1 (Фильтрация): ${stage1_1_passed ? '✅' : '❌'}`)
|
|
||||||
console.log(`- Этап 1.2 (Статус поставщика): ${stage1_2_passed ? '✅' : '❌'}`)
|
|
||||||
console.log(`- Этап 1.3 (Форма фулфилмента): ${stage1_3_passed ? '✅' : '❌'}`)
|
|
||||||
|
|
||||||
const allPassed = stage1_1_passed && stage1_2_passed && stage1_3_passed
|
|
||||||
|
|
||||||
console.log(`\n🚀 ПОЛНЫЙ WORKFLOW: ${allPassed ? '🟢 ВСЕ ЭТАПЫ ПРОЙДЕНЫ' : '🔴 ЕСТЬ ПРОБЛЕМЫ'}`)
|
|
||||||
|
|
||||||
if (allPassed) {
|
|
||||||
console.log('\n🎉 ПОЗДРАВЛЯЮ! Все критические исправления workflow выполнены:')
|
|
||||||
console.log('✅ Селлер создает поставку')
|
|
||||||
console.log('✅ Поставщик видит только кнопки действий (без статуса)')
|
|
||||||
console.log('✅ Фулфилмент получает товары и расходники в правильных разделах')
|
|
||||||
console.log('✅ Фулфилмент может назначить ответственного и логистику')
|
|
||||||
} else {
|
|
||||||
console.log('\n⚠️ Требуется дополнительная проверка некоторых этапов')
|
|
||||||
}
|
|
Reference in New Issue
Block a user