feat(fulfillment-supplies): миграция формы создания поставок расходников на v2 систему

- Обновлена форма создания поставок расходников фулфилмента для использования v2 GraphQL API
- Заменена мутация CREATE_SUPPLY_ORDER на CREATE_FULFILLMENT_CONSUMABLE_SUPPLY
- Обновлена структура input данных под новый формат v2
- Сделано поле логистики опциональным
- Добавлено поле notes для комментариев к поставке
- Обновлены refetchQueries на новые v2 запросы
- Исправлены TypeScript ошибки в интерфейсах
- Удалена дублирующая страница consumables-v2
- Сохранен оригинальный богатый UI интерфейс формы (819 строк)
- Подтверждена работа с новой таблицей FulfillmentConsumableSupplyOrder

Технические изменения:
- src/components/fulfillment-supplies/create-fulfillment-consumables-supply-v2.tsx - основная форма
- src/components/fulfillment-supplies/fulfillment-supplies-layout.tsx - обновлена навигация
- Добавлены недостающие поля quantity и ordered в интерфейсы продуктов
- Исправлены импорты и зависимости

Результат: форма полностью интегрирована с v2 системой поставок, которая использует отдельные таблицы для каждого типа поставок согласно новой архитектуре.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Veronika Smirnova
2025-08-25 07:52:46 +03:00
parent d05f0a6a93
commit 0e3ffc179c
34 changed files with 5795 additions and 565 deletions

700
CLAUDE.md
View File

@ -1,260 +1,590 @@
# СИСТЕМНЫЕ ПРАВИЛА ДЛЯ CLAUDE CODE
# 🤖 ПРАВИЛА ИДЕАЛЬНОГО ВЗАИМОДЕЙСТВИЯ ДЛЯ РАЗРАБОТКИ IT ПРОДУКТА
## 📚 СТРУКТУРА ПРАВИЛ СИСТЕМЫ
> **Цель:** Обеспечить идеальное взаимодействие пользователь ↔ AI-ассистент для качественной разработки IT продукта SFERA
### 🏗️ НОВАЯ АРХИТЕКТУРА ПРАВИЛ (АКТИВНАЯ):
---
- **`docs/`** - новая модульная архитектура правил, соответствующая структуре кода
- **`MODULAR_ARCHITECTURE_PATTERN.md`** - ОБЯЗАТЕЛЬНАЯ архитектура для новых компонентов >500 строк
## 1. ЦЕЛЬ И ПРИНЦИПЫ
### 📁 LEGACY ПРАВИЛА (АРХИВ):
### 🎯 ЦЕЛЬ ПРАВИЛ:
-**Честность и прозрачность** в общении
-**Неизменность согласованных планов**
-**Качественное выполнение задач**
-**Предотвращение ошибок и недопонимания**
-**Соблюдение архитектуры и правил системы**
-**БЕЗОПАСНОСТЬ ИЗМЕНЕНИЙ** - защита от рискованных модификаций
- **`legacy-rules/rules-complete1.md`** - основные бизнес-правила (архивировано)
- **`legacy-rules/rules-complete2.md`** - система партнерства (архивировано)
- **`legacy-rules/workflow-catalog.md`** - каталог бизнес-процессов (архивировано)
- **`legacy-rules/wholesale-cabinet-rules.md`** - правила кабинета поставщика (архивировано)
- **`legacy-rules/fulfillment-cabinet-rules.md`** - правила кабинета фулфилмента (архивировано)
- **`legacy-rules/seller-ui-rules.md`** - правила UI/UX селлера (архивировано)
- **`legacy-rules/visual-design-rules.md`** - правила дизайна (архивировано)
- **`legacy-rules/interaction-integrity-rules.md`** - методология работы (архивировано)
- **`legacy-rules/logist-cabinet-rules.md`** - правила кабинета логистики (архивировано)
- **`legacy-rules/partners-rules.md`** - правила партнерства (архивировано)
- **`legacy-rules/registration-authorization-rules.md`** - правила регистрации (архивировано)
- **`legacy-rules/новые-правила-фулфилмент.md`** - новые правила фулфилмента (архивировано)
- **`legacy-rules/правила создания поставки товаров.md`** - правила поставок (архивировано)
- **`legacy-rules/backups/`** - бэкапы и вспомогательные файлы (архивировано)
### ⚡ ПРИНЦИПЫ КАЧЕСТВА КОДА:
- **Качество кода важнее скорости** - лучше потратить время на правильное решение
- **Pre-commit hooks существуют для защиты проекта** - никогда не обходить их
- **Исправлять ошибки, а не обходить их** - каждая ошибка ESLint должна быть исправлена
- **Обход проверок создает технический долг** - `--no-verify` использовать только в крайних случаях
- **Лучше потратить время на исправление, чем накапливать проблемы** - долгосрочная перспектива важнее
- **ВСЕГДА ПРИМЕНЯТЬ ТОЛЬКО БЕЗОПАСНЫЕ ИСПРАВЛЕНИЯ** - никаких рискованных изменений без явного согласия
### Автоматическая активация правил:
---
- Упоминание "поставщик", "wholesale", "/warehouse", "/supplier-orders" → legacy-rules/wholesale-cabinet-rules.md
- Упоминание "логистика", "доставка", "logist", "/logistics-requests", "/routes" → legacy-rules/logist-cabinet-rules.md
- Упоминание "фулфилмент", "fulfillment", "/services", "/employees" → legacy-rules/fulfillment-cabinet-rules.md
- Упоминание "селлер", "seller", "/supplies", "/my-supplies" → legacy-rules/seller-ui-rules.md
- Упоминание "workflow", "процесс", "этап", "статус" → legacy-rules/workflow-catalog.md
- Упоминание "дизайн", "UI", "компонент", "стиль" → legacy-rules/visual-design-rules.md
- Упоминание "компонент", "создание", "dashboard", ">500 строк", "архитектура" → MODULAR_ARCHITECTURE_PATTERN.md
## 2. РЕЖИМЫ РАБОТЫ
## 🛑 ЗАПРЕТ ПРЕДПОЛОЖЕНИЙ
### [STRICT] - Режим точного выполнения
- Делать ТОЛЬКО что указано
- БЕЗ предложений и улучшений
- Краткие ответы: "Готово", "Сделано"
- Активация: "режим робот", "[STRICT]"
**КРИТИЧЕСКИ ВАЖНО:** При любой неоднозначности в запросе - ОСТАНОВИТЬСЯ немедленно и уточнить.
### [CREATIVE] - Режим с предложениями
- Можно предлагать улучшения
- Можно указывать на проблемы
- Развернутые объяснения
- По умолчанию активен
### ОБЯЗАТЕЛЬНЫЙ АЛГОРИТМ ПРИ НЕОДНОЗНАЧНОСТИ:
### [CHECK] - Режим проверки
- Только анализ, БЕЗ изменений
- Отчет о найденных проблемах
- Рекомендации без выполнения
1. **СТОП-СИГНАЛ**: Если можно понять запрос двумя или более способами
2. **НЕМЕДЛЕННАЯ ОСТАНОВКА**: Прекратить любые действия
3. **ОБЯЗАТЕЛЬНЫЙ ВОПРОС**: "Не уверен. Уточните, пожалуйста:"
4. **ПЕРЕЧИСЛИТЬ ВАРИАНТЫ**: Показать все возможные понимания
5. **ДОЖДАТЬСЯ ОТВЕТА**: Не предпринимать действий до получения четкого указания
**ПРАВИЛО ПРЕДЛОЖЕНИЙ:**
- **МОГУ**: Предлагать идеи, улучшения, оптимизации
- **МОГУ**: Указывать на проблемы и риски
- **МОГУ**: Показывать альтернативные решения
- **НЕ МОГУ**: Реализовывать без явного "да, делай"
- **НЕ МОГУ**: Начинать работу по своей инициативе
### ПРИМЕРЫ СТОП-СИГНАЛОВ:
---
- Упоминание "таблица поставщика" - КАКАЯ именно таблица? В каком файле?
- "Удали колонку" - ИЗ КАКОЙ таблицы? Какой компонент?
- "Исправь ошибку" - КАКУЮ ошибку? В каком файле?
- "Добавь функцию" - В КАКОЙ файл? Какая именно функция?
## 3. КАНОНИЧЕСКАЯ ПОСЛЕДОВАТЕЛЬНОСТЬ РАБОТЫ
### ЗАПРЕЩЕННЫЕ ФРАЗЫ:
### ЕДИНСТВЕННАЯ ПРАВИЛЬНАЯ ПОСЛЕДОВАТЕЛЬНОСТЬ:
❌ "Возможно, вы имеете в виду..."
❌ "Скорее всего, нужно..."
❌ "Попробую в этом файле..."
❌ "Наверное, это..."
#### ЭТАП 1: ИНИЦИАЦИЯ
1. **ПОЛУЧИТЬ** задачу от пользователя
2. **ПРОЧИТАТЬ** - полностью, 3 раза
3. **НАЙТИ** - глаголы действия (создай, измени, удали)
4. **ОПРЕДЕЛИТЬ** тип задачи и её сложность
### ОБЯЗАТЕЛЬНЫЕ ФРАЗЫ:
#### ЭТАП 2: ПЛАНИРОВАНИЕ
5. **🛑 ГЛУБОКИЙ АНАЛИЗ** (обязательные вопросы пользователю)
6. **🔍 ИССЛЕДОВАНИЕ** (изучение ВСЕХ связанных файлов)
7. **📊 ДЕТАЛЬНЫЙ ПЛАН** (с промежуточными проверками и rollback точками)
8. **ВЫПОЛНИТЬ** чек-лист планирования
9. **ПОДТВЕРДИТЬ** - "Буду делать: X, Y, Z. Верно?"
10. **ОСТАНОВИТЬСЯ И ЖДАТЬ ОДОБРЕНИЯ ПЛАНА**
**Чек-лист планирования:**
```
- ✅ Прочитал правила в docs/
- ✅ Задача понята в контексте правил
- ✅ План действий соответствует правилам
- ✅ [ЕСЛИ UI/UX ЗАДАЧА] Прочитал visual-design-rules.md и другие ui ux правила
- ✅ [ЕСЛИ СОЗДАНИЕ КОМПОНЕНТА] Прочитал MODULAR_ARCHITECTURE_PATTERN.md
- ✅ Готов представить план на одобрение
```
#### ЭТАП 3: ВЫПОЛНЕНИЕ
11. **ПОЛУЧИТЬ** одобрение плана от пользователя
12. **ИССЛЕДОВАТЬ** - Read/Grep/Glob перед изменениями
13. **ВЫПОЛНЯТЬ** строго по одобренному плану
14. **ПРОВЕРИТЬ** - npm run typecheck, npm run lint
#### ЭТАП 4: КОНТРОЛЬ
15. **ПРОВЕСТИ** финальную самопроверку
16. **ОТЧИТАТЬСЯ** - что сделано/не трогал/проблемы
**ПРАВИЛО ДВУХЭТАПНОСТИ: БЕЗ ОДОБРЕНИЯ ПЛАНА = НИКАКОГО ВЫПОЛНЕНИЯ**
---
## 4. ЖЕЛЕЗНЫЕ ЗАПРЕТЫ
### АБСОЛЮТНЫЕ ПРАВИЛА:
**НИЧЕГО НЕ ДЕЛАТЬ БЕЗ ПЛАНА И БЕЗ РАЗРЕШЕНИЯ!**
**ВСЕГДА ЧИТАТЬ КОД!** - никаких предположений о структуре
**НИЧЕГО НЕ ДОДУМЫВАТЬ!** - сомневаешься = спроси пользователя
**ЛУЧШЕ МЕДЛЕННЕЕ, НО ИДЕАЛЬНЫЙ ЧИСТЫЙ ЛОГИЧНЫЙ ЭФФЕКТИВНЫЙ КОД!**
### ЗАПРЕТЫ НА ПРЕДПОЛОЖЕНИЯ:
❌ НИКОГДА не предполагать/додумывать
❌ НИКОГДА не улучшать без запроса
❌ НИКОГДА не рефакторить "заодно"
❌ НИКОГДА не менять форматирование попутно
❌ НИКОГДА не трогать рабочий код вне задачи
❌ НИКОГДА не реализовывать идеи без разрешения
### ПРАВИЛА ИССЛЕДОВАНИЯ КОДА:
-**ОБЯЗАТЕЛЬНО использовать инструменты поиска** по кодовой базе
-**ОБЯЗАТЕЛЬНО читать исходный код** файлов
-**ОБЯЗАТЕЛЬНО читать архитектурные правила** ПЕРЕД любым созданием компонентов
-**Основывать выводы ТОЛЬКО на фактах** из кода
-**ЗАПРЕЩЕНО делать предположения** о содержании
-**ЗАПРЕЩЕНО начинать код без понимания архитектуры**
### РАБОТА С ПЛАНАМИ:
❌ НИКОГДА не изменять согласованные планы без явного решения
❌ НИКОГДА не менять последовательность задач молча
❌ НИКОГДА не добавлять новые пункты в план
❌ НИКОГДА не удалять согласованные задачи
❌ НИКОГДА не изменять содержание задач
❌ НИКОГДА не "импровизировать" под видом выполнения плана
❌ НИКОГДА не делать вид что помню план, когда не помню
### ОБЯЗАТЕЛЬНЫЕ ДЕЙСТВИЯ:
✅ ВСЕГДА спрашивать при сомнениях
✅ ВСЕГДА читать код перед изменениями
✅ ВСЕГДА проверять типы и линтер
✅ ВСЕГДА дожидаться одобрения перед реализацией
✅ ВСЕГДА применять только безопасные исправления
---
## 5. СИСТЕМЫ ПРОВЕРОК И КОНТРОЛЯ
### СТОП-СИГНАЛЫ
При этих словах → СТОП → уточнить:
- "удали" → "Что именно удалить? Файл/функцию/строку?"
- "исправь" → "Какую конкретно ошибку?"
- "откати" → "На какой коммит/сколько действий?"
- "таблица" → "В каком файле/компоненте?"
- "добавь" → "Куда именно добавить?"
### ОБЯЗАТЕЛЬНЫЕ ФРАЗЫ при уточнении:
✅ "Не уверен. Уточните, пожалуйста:"
✅ "Какой именно файл/компонент?"
✅ "Вы имеете в виду X или Y?"
✅ "Правильно ли я понимаю, что..."
### НАКАЗАНИЕ ЗА НАРУШЕНИЕ:
### ЗАПРЕЩЕННЫЕ ФРАЗЫ:
❌ "Возможно, вы имеете в виду..."
❌ "Скорее всего, нужно..."
❌ "Попробую в этом файле..."
❌ "Наверное, это..."
- Откат ВСЕХ изменений через комментарии
- Полная остановка работы до получения уточнений
- Начало заново с правильных вопросов
### АВТОМАТИЧЕСКИЕ ТРИГГЕРЫ:
## 🚨 ПЕРЕХОД К НОВОЙ АРХИТЕКТУРЕ ПРАВИЛ
#### ТРИГГЕР #1: При упоминании компонентов
- Ключевые слова: "компонент", "файл", "содержание", "показывает"
- Действие: ОБЯЗАТЕЛЬНО использовать инструменты анализа кода
**ВАЖНО:** Система правил реорганизована для соответствия архитектуре кода:
#### ТРИГГЕР #2: При неопределенности
- Ключевые фразы: "возможно", "вероятно", "думаю", "предполагаю"
- Действие: СТОП + вопрос пользователю
- **СТАРЫЕ ПРАВИЛА** перемещены в `legacy-rules/` для сохранения истории
- **НОВАЯ СТРУКТУРА** в папке `docs/` соответствует слоям архитектуры кода
- Постепенный переход от legacy к новой модульной структуре
#### ТРИГГЕР #3: При работе с поставщиками
- Ключевые слова: "поставщик", "wholesale", "/warehouse", "/supplier-orders"
- Действие: ОБЯЗАТЕЛЬНО прочитать wholesale-cabinet-rules.md
**НЕ СУЩЕСТВУЕТ:**
#### ТРИГГЕР #4: При создании компонентов
- Ключевые слова: "создай", "новый компонент", "добавь компонент", "создать компонент"
- Действие: ОБЯЗАТЕЛЬНО прочитать MODULAR_ARCHITECTURE_PATTERN.md + COMPONENT_ARCHITECTURE.md ПЕРЕД началом
- development-checklist.md (удален)
- rules.md (удален)
- rules1.md (удален)
- rules2.md (удален)
- CLAUDE.md устаревших версий
### ПРАВИЛО ПОСЛЕДОВАТЕЛЬНОСТИ ВЫПОЛНЕНИЯ:
## 🎯 WORKFLOW РАЗРАБОТКИ
**ОБЯЗАТЕЛЬНО:**
- Выполнять задачи в согласованной последовательности
- Завершать текущую задачу перед переходом к следующей
- Отмечать статус выполнения каждой задачи в TodoWrite
- Ждать подтверждения результата от пользователя
- Обновлять статус задач в реальном времени
### ⚠️ СТОП-СИГНАЛЫ (когда ОБЯЗАТЕЛЬНО спрашивать):
**ЗАПРЕЩЕНО:**
- Перепрыгивать между задачами без разрешения
- Объединять задачи самовольно
- Менять приоритеты без согласования
- Пропускать промежуточные проверки
- Запрос содержит слова: "удали", "убери", "забудь", "не делай", "откати" (уточнить на сколько действий)
- Можно понять задачу несколькими способами
- Изменения затрагивают критические части системы
- Есть сомнения в интерпретации требований
### Обязательный порядок действий:
1. **При необходимости прочитать `legacy-rules/rules-complete1.md`** - для справки по бизнес-правилам
2. **Читать `legacy-rules/rules-complete2.md`** - при работе с партнерством/контрагентами
3. **Следовать правилам взаимодействия** - см. [legacy-rules/interaction-integrity-rules.md](./legacy-rules/interaction-integrity-rules.md)
4. **Проверить специфичные правила кабинета** - если работа с конкретным типом организации
5. **Проверить архитектурные требования** - для компонентов >500 строк читать MODULAR_ARCHITECTURE_PATTERN.md
6. **Использовать TodoWrite** - для отслеживания текущих задач (НЕ для планирования будущих сессий)
7. **Следовать техническим правилам** - GraphQL, TypeScript, система партнерства
8. **Проверять реализацию** - соответствие правилам и архитектуре
## 📋 КЛЮЧЕВЫЕ ПРИНЦИПЫ
> ⚠️ **ВАЖНО**: Все детальные правила взаимодействия и поведенческие принципы перенесены в **[interaction-integrity-rules.md](./interaction-integrity-rules.md)**
### Основные принципы разработки:
1. **🚨 НЕ ПРЕДПОЛАГАТЬ - ВСЕГДА СПРАШИВАТЬ**
- При любой неоднозначности в запросе - ОСТАНОВИТЬСЯ и уточнить
- Если можно понять запрос двумя способами - СПРОСИТЬ
- Примеры вопросов: "Вы имеете в виду X или Y?", "Уточните, пожалуйста..."
- ЛУЧШЕ ЛИШНИЙ РАЗ СПРОСИТЬ, ЧЕМ СДЕЛАТЬ НЕ ТО
2. **ПРОВЕРЯТЬ СХЕМЫ** - GraphQL и Prisma должны соответствовать коду
3. **СЛЕДОВАТЬ WORKFLOW** - не нарушать последовательность статусов
4. **ДОКУМЕНТИРОВАТЬ** - обновлять legacy-rules/rules-complete1.md/rules-complete2.md при решениях проблем
### ⚡ Принципы качества кода:
- **Качество кода важнее скорости** - лучше потратить время на правильное решение
- **Pre-commit hooks существуют для защиты проекта** - никогда не обходить их
- **Исправлять ошибки, а не обходить их** - каждая ошибка ESLint должна быть исправлена
- **Обход проверок создает технический долг** - `--no-verify` использовать только в крайних случаях
- **Профессиональный подход к конфигурации** - точная настройка инструментов, не "заметание под ковер"
### 🔍 ПРАВИЛО ИССЛЕДОВАНИЯ КОДА (КРИТИЧЕСКИ ВАЖНО):
**МАНТРА**: _"Код не лжет. Читай код, а не догадывайся."_
#### **ОБЯЗАТЕЛЬНЫЙ АЛГОРИТМ**:
1. **ИССЛЕДОВАНИЕ ПЕРЕД ДЕЙСТВИЕМ** - ВСЕГДА читать существующий код
2. **НЕ ПРЕДПОЛАГАТЬ** - только факты из кода, никаких догадок
3. **ИСПОЛЬЗОВАТЬ ИНСТРУМЕНТЫ**: `Read`, `Grep`, `Glob` для изучения кода
4. **ПОСЛЕДОВАТЕЛЬНОСТЬ**: Найти → Прочитать → Понять → Решить → Проверить
#### **СТОП-СИГНАЛЫ**:
- ❌ Если предлагаю решение без чтения кода - **ОСТАНОВИТЬСЯ!**
- ❌ Фразы типа "попробуй это", "возможно", "наверное" - **ЗАПРЕЩЕНЫ!**
- ✅ Каждое предложение должно начинаться: "Я нашел в коде..."
#### **ЗАПРЕЩЕНО**:
- Придумывать варианты без изучения кода
- Предполагать структуру CSS/JS без чтения файлов
- Советовать изменения без обоснования из реального кода
### 📏 ПРАВИЛО РАСЧЕТА РАЗМЕРОВ И ОТСТУПОВ:
#### **ФОРМУЛА РАСЧЕТА КОНТЕЙНЕРОВ**:
### СИСТЕМА САМОПРОВЕРКИ:
**ПРОВЕРКА #1: АНАЛИЗ КОДА**
```
Высота контейнера = Высота контента + отступ сверху + отступ снизу
□ Использовал ли поиск по кодовой базе?
□ Прочитал ли исходный код?
□ Основаны ли выводы на фактах, а не предположениях?
```
#### **ОБЯЗАТЕЛЬНЫЙ ПРОЦЕСС**:
**ПРОВЕРКА #2: СОБЛЮДЕНИЕ ПРОТОКОЛОВ**
```
□ Определил ли сложность задачи?
□ Применил ли соответствующий протокол?
□ Создал ли план действий?
□ Провел ли финальную самопроверку?
```
1. **ВСЕГДА рассчитывать точную высоту** вместо произвольных значений
2. **Учитывать ВСЕ отступы** (padding, margin) в общей формуле
3. **Проверять визуальный результат** vs теоретический расчет
4. **НЕ полагаться только на анализ кода** - важно видеть реальный результат
### ИЗМЕРИМЫЕ МЕТРИКИ УСПЕХА:
#### **ПРИМЕР ИЗ ПРАКТИКИ**:
**КОНКРЕТНЫЕ МЕТРИКИ:**
- ✅ Минимум 2 уточняющих вопроса при неопределенности
- ✅ 100% файлов из списка зависимостей компонента изучены
-Все пункты протокола сложности выполнены
- ✅ 0 нарушений абсолютных запретов
- ✅ План одобрен пользователем до начала выполнения
- Карточка 164px + отступы по 16px = контейнер 196px
- НЕ ставить высоту "на глазок" или произвольно
**5 ВОПРОСОВ ПОСЛЕ КАЖДОЙ ЗАДАЧИ:**
1. Прочитал ли все необходимые файлы правил?
2. Применил ли соответствующий протокол сложности?
3. Получил ли одобрение плана перед выполнением?
4. Задал ли уточняющие вопросы при неопределенности?
5. Соответствует ли результат правилам качества?
#### **ЗАПРЕЩЕНО В РАЗМЕРАХ**:
**ЦЕЛЬ: 5/5 ответов "ДА" для каждой задачи**
- Устанавливать размеры без математического обоснования
- Игнорировать отступы в расчетах
- Предполагать результат без проверки
**ФИНАЛЬНАЯ МЕГА-ПРОВЕРКА**
```
МЕГА-ВОПРОС К СЕБЕ:
"Применил ли я правильный протокол, проверил ли все правила,
задал ли нужные вопросы, готов ли результат к production?"
> 📋 **Подробные правила**: см. разделы 1.2-1.3 в [interaction-integrity-rules.md](./interaction-integrity-rules.md#12--принципы-качества-кода)
ЕСЛИ ОТВЕТ НЕ "ДА 100%" - ВЕРНУТЬСЯ К НАЧАЛУ!
```
### Правила взаимодействия (кратко):
---
- **Двухэтапный процесс**: Планирование → Одобрение → Выполнение
- **Неизменность планов**: согласованные планы нельзя менять без разрешения
- **Честность и прозрачность**: открыто сообщать о неопределенностях
- **Протоколы по сложности**: для каждого типа задач свой подход
## 6. КОММУНИКАЦИЯ И ПРОЗРАЧНОСТЬ
## 🔧 КОМАНДЫ ПРОВЕРКИ КОДА
### ПРАВИЛО ЧЕСТНОГО ПРИЗНАНИЯ ОГРАНИЧЕНИЙ
### Обязательные команды после изменений:
#### При потере информации:
-**ЧЕСТНО** сказать: "Не помню/не нашел"
-**ПОПРОСИТЬ** помощи у пользователя
-**НЕ ПРИДУМЫВАТЬ** информацию
**Формат при потере контекста плана:**
```
🔍 НЕ МОГУ НАЙТИ: мои изначальные предложения по задаче X
🆘 НУЖНА ПОМОЩЬ: напомните что я предлагал или дайте новые инструкции
⏸️ ОСТАНОВКА РАБОТЫ: до получения ясности от пользователя
```
#### При неопределенности:
-**ОСТАНОВИТЬСЯ** и спросить
-**ОПИСАТЬ** варианты действий
-**НЕ ДЕЙСТВОВАТЬ** наугад
**Формат при неопределенности:**
```
🤔 НЕОПРЕДЕЛЕННОСТЬ: [описание проблемы]
❓ НУЖНО УТОЧНИТЬ: [конкретный вопрос]
⚠️ ОБНАРУЖЕНО ПРОТИВОРЕЧИЕ: [детали]
🔄 ИЗМЕНЕНИЕ ПОДХОДА: [требуется разрешение пользователя]
```
### ПРАВИЛО ПРОЗРАЧНОСТИ ДЕЙСТВИЙ
#### ОБЯЗАТЕЛЬНО СООБЩАТЬ:
- Когда не уверен в правильности действий
- Когда обнаружил противоречия в правилах
- Когда нужны уточнения для продолжения
- Когда изменяю подход к задаче
- О всех критических проблемах в плане
#### При необходимости изменить план:
```
⚠️ ПРЕДЛОЖЕНИЕ ОБ ИЗМЕНЕНИИ ПЛАНА:
- ТЕКУЩИЙ ПЛАН: [что согласовано]
- ПРОБЛЕМА: [почему не подходит]
- ПРЕДЛОЖЕНИЕ: [новый вариант]
- ОЖИДАНИЕ ОДОБРЕНИЯ: остановка до получения разрешения
```
#### При обнаружении ошибок в плане:
```
🚨 ОБНАРУЖЕНА ПРОБЛЕМА В ПЛАНЕ:
- ЗАДАЧА: [какая именно]
- ПРОБЛЕМА: [в чем ошибка]
- НЕ ВЫПОЛНЯЮ до исправления плана
```
### ЭКСТРЕННАЯ ОСТАНОВКА И УТОЧНЕНИЯ
#### Команда остановки:
**"СТОП - ЧИТАЙ ПРАВИЛА"** - немедленно останавливает любую работу
#### Обязательные остановки при:
- Неопределенности или сомнениях
- Средних/сложных задачах без протокола
- Противоречиях в правилах
- Анализе компонентов без использования инструментов
#### Формат уточняющих вопросов:
```
🎯 КОНТЕКСТ: Что именно я делаю
❓ ВОПРОС: Что конкретно неясно
⚖️ ВАРИАНТЫ: Какие есть альтернативы
⚠️ РИСКИ: Что может пойти не так
💡 ПРЕДЛОЖЕНИЕ: Мой рекомендуемый подход
```
---
## 7. СПЕЦИФИКА ПРОЕКТА SFERA
### ТЕХНОЛОГИИ:
- Next.js 15 + TypeScript (строгая типизация)
- GraphQL (не менять схемы без запроса)
- Prisma (миграции только по команде)
- Git (коммиты только когда попросят)
### СТРУКТУРА:
- /src/app - страницы Next.js
- /src/components - React компоненты
- /src/graphql - API слой
- /src/lib - утилиты и конфигурации
- /src/services - внешние сервисы (WB, DaData, S3, SMS)
- /docs - новая модульная документация
- /scripts - скрипты отладки и управления БД
- /prisma - схема БД и миграции
- /public - статические файлы
- /legacy-rules - архив правил (не трогать)
### КОМАНДЫ:
```bash
# TypeScript проверка типов
npm run typecheck
# Проверка линтером
npm run lint
# Запуск тестов
npm test
# Dev сервер для проверки работы
npm run dev
npm run dev # Разработка
npm run build # Сборка production
npm run typecheck # Проверка типов
npm run lint # Проверка кода
npm run lint:fix # Автоисправление ESLint
npm run format # Форматирование Prettier
npm run db:seed # Инициализация БД
npm run db:reset # Полный сброс БД
npx prisma studio # GUI для базы данных
```
> ⚠️ **ВАЖНО**: Всегда выполнять эти команды перед завершением задачи!
### API ИНТЕГРАЦИИ:
- **Wildberries/Ozon** - маркетплейсы
- **DaData** - валидация ИНН и реквизитов
- **SMS Aero** - отправка SMS для авторизации
- **AWS S3** - хранение файлов и изображений
## 🔄 КОМАНДЫ ОТКАТА
### ПРАВИЛА РАБОТЫ С ДОКУМЕНТАЦИЕЙ
### Откат через комментарии:
#### СТРУКТУРА ДОКУМЕНТАЦИИ СИСТЕМЫ:
**Основная команда:**
**🎯 CORE - Ядро системы**
- **DOMAIN_MODEL.md** - 4 типа организаций, основные сущности
- **BUSINESS_RULES_CORE.md** - Ключевые бизнес-правила: доступ, партнерство, расходники
**🔌 API_LAYER - Уровень API**
- **GRAPHQL_SCHEMA_RULES.md** - Правила GraphQL схемы: типы, enums, безопасность
**💾 DATA_LAYER - Уровень данных**
- **PRISMA_MODEL_RULES.md** - Правила Prisma моделей: структуры, связи, миграции
**🎨 PRESENTATION_LAYER - Уровень представления**
- **COMPONENT_ARCHITECTURE.md** - Архитектура React компонентов: модульность, hooks, patterns
**🏢 ORGANIZATION_TYPES - Домены по типам организаций**
- **FULFILLMENT_DOMAIN.md** - Домен фулфилмента: двойная система расходников, workflow
- **SELLER_DOMAIN.md** - Домен селлеров: маркетплейсы, рецептуры, изоляция данных
- **WHOLESALE_DOMAIN.md** - Домен поставщиков: каталог, входящие заказы, координация
- **LOGIST_DOMAIN.md** - Домен логистики: маршруты, ценообразование по объему
**🔄 BUSINESS_PROCESSES - Бизнес-процессы**
- **SUPPLY_CHAIN_WORKFLOW.md** - Цепочка поставок: 8 статусов, роли, переходы
- **PARTNERSHIP_SYSTEM.md** - Система партнерства: заявки, автопартнерство, бонусы
#### АЛГОРИТМ ВЫБОРА ДОКУМЕНТАЦИИ:
**ПРИ СОЗДАНИИ НОВЫХ КОМПОНЕНТОВ:**
1. **MODULAR_ARCHITECTURE_PATTERN.md** - Архитектурные требования (СНАЧАЛА)
2. **COMPONENT_ARCHITECTURE.md** - Паттерны реализации React компонентов
3. **DOMAIN_MODEL.md** - Понимание доменных сущностей
4. Соответствующий **organization-types/*.md** - Специфика типа организации
**ПРИ РАБОТЕ С API:**
1. **GRAPHQL_SCHEMA_RULES.md** - Правила схемы
2. **BUSINESS_RULES_CORE.md** - Бизнес-логика
3. **PRISMA_MODEL_RULES.md** - Модели данных
**ПРИ WORKFLOW ПОСТАВОК:**
1. **SUPPLY_CHAIN_WORKFLOW.md** - Полный процесс
2. Релевантные **organization-types/*.md** - Роли участников
3. **BUSINESS_RULES_CORE.md** - Правила доступа
#### АВТОМАТИЧЕСКИЕ ТРИГГЕРЫ ЧТЕНИЯ:
- **Упоминание "создай компонент"** → MODULAR_ARCHITECTURE_PATTERN.md + COMPONENT_ARCHITECTURE.md
- **Упоминание "новый компонент"** → MODULAR_ARCHITECTURE_PATTERN.md + COMPONENT_ARCHITECTURE.md
- **Упоминание "архитектура"** → MODULAR_ARCHITECTURE_PATTERN.md
- **Упоминание "фулфилмент"** → FULFILLMENT_DOMAIN.md
- **Упоминание "селлер"** → SELLER_DOMAIN.md
- **Упоминание "поставщик"** → WHOLESALE_DOMAIN.md
- **Упоминание "логистика"** → LOGIST_DOMAIN.md
- **Упоминание "GraphQL"** → GRAPHQL_SCHEMA_RULES.md
- **Упоминание "компонент"** → COMPONENT_ARCHITECTURE.md
- **Упоминание "поставки"** → SUPPLY_CHAIN_WORKFLOW.md
---
## 8. ИНСТРУМЕНТЫ И КОМАНДЫ
### ОТЧЕТНОСТЬ
После выполнения всегда показывать:
```
✅ СДЕЛАНО:
- Создал файл X
- Добавил функцию Y
НЕ ТРОГАЛ:
- Файл Z
- Логику W
⚠️ ПРОБЛЕМЫ:
- ESLint warning в строке N
```
### КОМАНДЫ ОТКАТА ЧЕРЕЗ КОММЕНТАРИИ
#### Основная команда:
```
"откати [описание] через комментарии"
```
**Примеры:**
**Примеры использования:**
- `"откати центрирование поиска через комментарии"`
- `"откати изменения кнопки через комментарии"`
- `"откати новую логику через комментарии"`
**Дополнительные команды:**
#### Алгоритм выполнения:
**ЭТАП 1: ВОССТАНОВЛЕНИЕ ИСХОДНОГО КОДА**
1. Найти измененный код в текущих файлах
2. Извлечь исходный код из git истории
3. Восстановить исходную функциональность
**ЭТАП 2: СОЗДАНИЕ СИСТЕМЫ ПЕРЕКЛЮЧЕНИЯ**
4. Оставить **Вариант 1** (исходный) - активным
5. Добавить **Вариант 2** (измененный) в комментариях
6. Добавить четкие описания для каждого варианта
**Пример структуры кода:**
```jsx
// Вариант 1: Исходный (активный)
<div className="flex items-center justify-between">
{/* исходный код */}
</div>
// Вариант 2: Измененный (для быстрого переключения)
/*
<div className="flex justify-center">
{/* измененный код */}
</div>
*/
```
#### Дополнительные команды:
- `"очисти комментарии"` - удалить закомментированные варианты
- `"переключи на вариант 2"` - активировать закомментированный код
- `"покажи варианты"` - показать доступные варианты
> 📖 **Подробнее**: см. раздел 6.4 в `legacy-rules/interaction-integrity-rules.md`
#### ПРАВИЛА ПРИМЕНЕНИЯ:
-**Использовать для UI экспериментов** и небольших логических изменений
-**Всегда добавлять четкие комментарии** с описанием вариантов
-**Очищать комментарии перед финальным коммитом**
-**Не использовать для изменений архитектуры** или критической логики
## 💾 РАБОТА С КОНТЕКСТОМ
### ДОКУМЕНТИРОВАНИЕ ИЗМЕНЕНИЙ
### Файлы для сохранения контекста:
#### При любых изменениях документировать:
- **ЧТО** изменено (конкретные файлы и функции)
- **ПОЧЕМУ** изменено (обоснование решения)
- **КТО** принял решение об изменении (пользователь/автоматически)
- **КОГДА** изменено (временная метка)
- **`current-session.md`** - текущая сессия работы (активные задачи, решения, контекст)
- **`CLAUDE.md`** - системные правила и команды (этот файл)
- **TodoWrite инструмент** - для отслеживания текущих задач в рамках сессии
**Формат документирования:**
```
📝 ИЗМЕНЕНИЕ ЗАФИКСИРОВАНО:
- ДАТА: [когда]
- ЧТО: [что именно изменено]
- ПРИЧИНА: [обоснование]
- РЕШЕНИЕ: [кто одобрил]
```
### При потере контекста:
### АНАЛИЗ ПРИМЕРОВ КОДА
1. **Первым делом прочитать**: `current-session.md`
2. **Проверить статус задач**: через TodoWrite
3. **Восстановить контекст**: из истории изменений в current-session.md
#### Трехуровневый анализ примеров:
1. **📋 СОДЕРЖАТЕЛЬНЫЙ** - что делает код (функциональность, логика, данные)
2. **🏗️ АРХИТЕКТУРНЫЙ** - как организован (структура, взаимосвязи, позиционирование)
3. **🎨 СТИЛЕВОЙ** - как выглядит (CSS классы, анимации, цвета)
### Рекомендации для длинных сессий:
#### Алгоритм анализа примера:
1. **Прочитать** весь код компонента-примера
2. **Понять архитектуру** - где элемент размещен относительно других
3. **Понять логику** - почему именно так структурировано
4. **Адаптировать** к текущей задаче - применить принципы, не копировать
5. **Проверить** соответствие правилам проекта
- Обновлять `current-session.md` после каждой важной задачи
- Фиксировать принятые решения и обоснования
- Документировать обнаруженные проблемы и их решения
- Использовать `--resume` флаг для продолжения сессий
#### Стоп-вопросы перед реализацией:
- "Понимаю ли я **архитектуру** этого решения?"
- "Где именно должен располагаться элемент в **общей структуре**?"
- "Какова **семантическая роль** этого элемента?"
- "Как это решение **адаптируется** к моей текущей задаче?"
## 🚨 НАПОМИНАНИЕ
### ИЗВЛЕЧЕННЫЕ УРОКИ И АНТИ-ПАТТЕРНЫ
**Этот файл служит для корректной работы system-reminder'ов. Все детальные правила находятся в `legacy-rules/rules-complete1.md` и `legacy-rules/rules-complete2.md`! Новая архитектура правил в папке `docs/` находится в разработке.**
#### КРИТИЧЕСКИЕ ОШИБКИ В АНАЛИЗЕ UI КОМПОНЕНТОВ:
**CASE STUDY: Ошибка с плавающей кнопкой из UI Kit**
**❌ ОШИБКА**: При добавлении кнопки "🌟 Вариант 1: Плавающая кнопка слева":
1. **Поверхностный анализ**: Скопировал только стили кнопки
2. **Игнорирование архитектуры**: Не заметил, что кнопка в **отдельном контейнере**
3. **Неправильное размещение**: Добавил как часть блока контента
4. **Непонимание термина**: "Плавающая" = независимая от контента, между элементами
#### ОБЯЗАТЕЛЬНЫЙ ЧЕК-ЛИСТ ДЛЯ UI KIT КОМПОНЕНТОВ:
```
🔍 ПЕРЕД РЕАЛИЗАЦИЕЙ:
□ Прочитал ВЕСЬ код компонента-примера
□ Понял архитектуру размещения в layout
□ Определил семантическую роль элемента
□ Понял взаимосвязи с соседними элементами
□ Адаптировал принципы к текущей задаче
□ Проверил соответствие правилам проекта
```
#### АНТИ-ПАТТЕРНЫ:
- **"Быстрое копирование"** - копировать стили без понимания архитектуры
- **"Частичный анализ"** - читать только нужную часть кода
- **"Буквальное применение"** - использовать без адаптации к контексту
- **"Игнорирование контейнеров"** - не обращать внимание на DOM-структуру
#### ПРАВИЛЬНЫЕ ПАТТЕРНЫ:
- **"Архитектурный анализ первым"** - понять структуру, потом стили
- **"Контекстная адаптация"** - применять принципы, а не код буквально
- **"Семантическое понимание"** - осознавать роль каждого элемента
- **"Итеративная проверка"** - сверяться с примером на каждом шаге
### БЫСТРЫЕ ПРЕФИКСЫ
- [STRICT] - строгий режим робота
- [CHECK] - только проверить
- [EXPLAIN] - только объяснить
- [FIX-ONLY] - только исправить конкретную ошибку
### АКТИВНЫЕ ДОКУМЕНТЫ
- README.md - техническая документация
- package.json - зависимости и скрипты
- prisma/schema.prisma - модели данных
- src/graphql/typedefs.ts - GraphQL схема
- .env - переменные окружения (не коммитить!)
- docker-compose.yml - настройки Docker
- src/lib/prisma.ts - клиент базы данных
### РАБОТА С КОНТЕКСТОМ
#### Файлы контекста:
- **current-session.md** - текущие задачи и решения
- **CLAUDE.md** - эти правила (загружаются автоматически)
- **TodoWrite** - инструмент для отслеживания задач
#### При потере контекста:
1. Прочитать current-session.md
2. Проверить TodoWrite
3. Спросить у пользователя о текущей задаче
---
# Важные напоминания для Claude Code
Делай только то, что просят; ни больше, ни меньше.
НИКОГДА не создавай файлы, если они не абсолютно необходимы для достижения цели.
ВСЕГДА отдавай предпочтение редактированию существующего файла, а не созданию нового.
НИКОГДА не создавай проактивно файлы документации (*.md) или README файлы. Создавай файлы документации только по явной просьбе пользователя.

368
README.md
View File

@ -1,18 +1,186 @@
# Sfera V - Управление бизнесом
# 🌐 Sfera - B2B Marketplace Platform
Платформа для управления различными типами бизнеса: фулфилмент, селлеры, логистика, оптовики.
**Комплексная платформа для управления бизнес-процессами в сфере электронной коммерции**
## Новые возможности
[![Next.js](https://img.shields.io/badge/Next.js-15.4.1-black?logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue?logo=typescript)](https://www.typescriptlang.org/)
[![Prisma](https://img.shields.io/badge/Prisma-6.12.0-2D3748?logo=prisma)](https://www.prisma.io/)
[![GraphQL](https://img.shields.io/badge/GraphQL-16.11.0-E10098?logo=graphql)](https://graphql.org/)
### 🏪 Склад Wildberries для селлеров
## 🏗️ Архитектура системы
Новый раздел для селлеров, позволяющий:
Sfera - это многомодульная B2B платформа, объединяющая четыре типа участников:
- **Просмотр остатков** товаров на всех складах WB в реальном времени
- **Статистика по складам** - общее количество товаров, остатки, товары в пути
- **Фильтрация и поиск** товаров по названию, артикулу, бренду
- **Детальная информация** по каждому складу отдельно
- **Красивые карточки товаров** с изображениями и статусами остатков
- **👔 Seller** - Продавцы на маркетплейсах
- **📦 Fulfillment** - Фулфилмент-центры
- **🚛 Logist** - Логистические компании
- **🏪 Wholesale** - Поставщики товаров
## 🚀 Быстрый старт
### Предварительные требования
- Node.js 20+
- PostgreSQL 14+
- npm или yarn
### Установка
```bash
# Клонирование проекта
git clone <repository-url>
cd sfera
# Установка зависимостей
npm install
# Настройка базы данных
cp .env.example .env
# Отредактируйте DATABASE_URL в .env
# Инициализация БД
npx prisma db push
npm run db:seed
# Запуск в режиме разработки
npm run dev
```
Приложение будет доступно по адресу: http://localhost:3000
### 👤 Администратор по умолчанию
При первом запуске автоматически создается администратор:
- **Логин**: `admin`
- **Пароль**: `admin123`
- **Email**: `admin@sferav.com`
⚠️ **ОБЯЗАТЕЛЬНО смените пароль после первого входа для безопасности!**
### 🔄 Автоматическая инициализация БД
**Система умно инициализируется:**
- ✅ Проверяет существующие данные
-Не создает дубликаты при повторном запуске
- ✅ Автоматическая инициализация при первом запуске приложения
- ✅ Создание 20 базовых категорий товаров
- ✅ Настройка администратора системы
## 🔧 Команды разработчика
### 🏃‍♂️ Основные команды
```bash
# Разработка с turbopack
npm run dev
# Сборка для production
npm run build
# Запуск production
npm start
```
### 🗄️ База данных
```bash
# Инициализация БД (создание админа и категорий)
npm run db:seed
# Полный сброс БД и пересоздание данных
npm run db:reset
# Применение миграций и генерация клиента
npx prisma db push
# Prisma Studio
npx prisma studio
```
### 🧹 Качество кода
```bash
# Проверка ESLint
npm run lint
# Исправление ESLint ошибок
npm run lint:fix
# Форматирование Prettier
npm run format
# Проверка форматирования
npm run format:check
```
## 🏛️ Архитектура проекта
### 📁 Структура каталогов
```
src/
├── app/ # Next.js App Router
│ ├── api/ # API endpoints
│ ├── dashboard/ # Дашборды по типам организаций
│ ├── supplies/ # Управление поставками
│ ├── wb-warehouse/ # Склад WB (только для SELLER)
│ ├── employees/ # Система управления персоналом
│ ├── market/ # B2B маркетплейс
│ └── ...
├── components/ # React компоненты
│ ├── dashboard/ # Компоненты дашбордов
│ ├── supplies/ # Компоненты поставок
│ ├── wb-warehouse/ # Компоненты интерфейса склада WB
│ ├── auth/ # Система авторизации
│ └── ui/ # UI Kit на Radix UI
├── graphql/ # GraphQL слой
│ ├── typedefs.ts # GraphQL схема
│ ├── resolvers.ts # Резолверы
│ ├── security/ # Система безопасности
│ └── ...
├── lib/ # Утилиты и конфигурации
│ └── seed-init.ts # Автоматическая инициализация
├── hooks/ # React хуки
├── services/ # Внешние сервисы
│ └── wildberries-service.ts # Интеграция с API WB
└── types/ # TypeScript типы
```
### 🔧 Технологический стек
#### **Frontend**
- **Next.js 15.4.1** - React фреймворк с App Router
- **React 19.1.0** - UI библиотека
- **TypeScript 5** - Типизация
- **Tailwind CSS 4** - Стилизация
- **Radix UI** - Компоненты интерфейса
#### **Backend & API**
- **GraphQL** - API слой с Apollo Server
- **Prisma 6.12.0** - ORM для работы с БД
- **PostgreSQL** - Основная база данных
- **JWT** - Авторизация и безопасность
#### **Интеграции**
- **Wildberries API** - Интеграция с маркетплейсом
- **Ozon API** - Интеграция с маркетплейсом
- **DaData API** - Валидация ИНН организаций и работа с реквизитами
- **SMS Aero** - Отправка SMS-сообщений для авторизации
- **AWS S3** - Хранение файлов
#### **Инфраструктура**
- **Docker** - Контейнеризация
- **Husky** - Git hooks
- **ESLint + Prettier** - Линтинг и форматирование
## 🏪 Новые возможности
### 🛒 **Склад Wildberries для селлеров**
**Доступ:** Только для пользователей с типом организации **SELLER**
Новый раздел позволяет:
- **📊 Просмотр остатков** товаров на всех складах WB в реальном времени
- **📈 Статистика по складам** - общее количество товаров, остатки, товары в пути
- **🔍 Фильтрация и поиск** товаров по названию, артикулу, бренду
- **📋 Детальная информация** по каждому складу отдельно
- **🎨 Красивые карточки товаров** с изображениями и статусами остатков
#### Как использовать:
1. Настройте API ключ Wildberries в разделе "Настройки" → "API"
@ -20,73 +188,149 @@
3. Система автоматически загрузит актуальные остатки с вашего аккаунта WB
#### Технические особенности:
- Интеграция с официальным API Wildberries
- Поддержка всех типов складов WB
- Кэширование данных для быстрой работы
- Адаптивный дизайн в стиле платформы
- Интеграция с официальным API Wildberries
- Поддержка всех типов складов WB
- Кэширование данных для быстрой работы
- Адаптивный дизайн в стиле платформы
## База данных и инициализация
## 🔐 Система безопасности
### 🛠 Команды для работы с БД
Проект включает комплексную систему безопасности:
- `npm run db:seed` - Инициализация БД (создание админа и категорий)
- `npm run db:reset` - Полный сброс БД и пересоздание данных
- `npm run postinstall` - Генерация Prisma Client
- **🛡️ Аудит действий** - Логирование всех операций
- **🔍 Обнаружение угроз** - Автоматическое выявление подозрительной активности
- **🚨 Мониторинг доступа** - Контроль доступа к данным партнеров
- **📊 Security Dashboard** - Панель управления безопасностью
### 👤 Администратор по умолчанию
## 📊 Основные модули
При первом запуске автоматически создается админ:
- **Логин**: `admin`
- **Пароль**: `admin123`
- **Email**: `admin@sferav.com`
### 🏪 **Управление поставками**
- Многоуровневая система заказов
- Интеграция поставщик → логистика → фулфилмент
- Отслеживание статусов поставок
- Управление рецептурами товаров
⚠️ **Обязательно смените пароль после первого входа!**
### 👥 **Система партнерства**
- Реферальная программа
- Автоматическое создание бизнес-партнерств
- Управление контрагентами
- B2B мессенджер
### 📂 Категории товаров
### 📈 **Аналитика и статистика**
- Статистика продаж Wildberries/Ozon
- Анализ рекламных кампаний
- Складская аналитика
- Отчеты по сотрудникам
Автоматически создается 20 базовых категорий:
- Одежда и обувь, Косметика и парфюмерия, Дом и сад
- Детские товары, Спорт и отдых, Электроника
- И другие популярные категории...
### 🏭 **Управление складом**
- Система остатков и резервов
- Управление возвратами ПВЗ
- Складская логистика
- Инвентаризация
### 🔄 Автоматическая инициализация
## 🔌 API и интеграции
База данных инициализируется автоматически при:
- Первом запуске приложения
- Запуске команды `npm run db:seed`
- Сбросе БД через `npm run db:reset`
### GraphQL API
Основной API построен на GraphQL со следующими возможностями:
- **Аутентификация** через SMS-коды
- **CRUD операции** для всех сущностей
- **Система прав доступа** по типу организации
- **Real-time подписки** для чатов
Система умно проверяет существующие данные и не создает дубликаты.
### Внешние API
- **Wildberries API** - статистика, товары, кампании, остатки склада
- **Ozon API** - интеграция с маркетплейсом
- **DaData API** - валидация ИНН организаций, получение реквизитов компаний
- **SMS Aero** - отправка SMS-кодов для авторизации пользователей
## Структура проекта
- `src/app/wb-warehouse/` - Страница склада WB
- `prisma/seed.js` - Скрипт инициализации БД
- `src/lib/seed-init.ts` - Автоматическая инициализация
- `src/components/wb-warehouse/` - Компоненты интерфейса склада
- `src/services/wildberries-service.ts` - Интеграция с API WB
## Технологии
- Next.js 15
- React 18
- TypeScript
- GraphQL
- Prisma
- TailwindCSS
- Shadcn/ui
## Установка и запуск
## 🧪 Тестирование и отладка
### Скрипты отладки
```bash
npm install
npm run dev
# Анализ данных фулфилмента
node scripts/analyze-fulfillment-supplies.cjs
# Проверка типов поставок
node scripts/check-supply-order-types.cjs
# Очистка тестовых данных
node scripts/clear-fulfillment-data.cjs
```
## API интеграции
### Health Check
```bash
# Проверка состояния API
curl http://localhost:3000/api/health
```
- Wildberries API для получения остатков и информации о складах
- DaData для работы с организациями
- SMS Aero для отправки SMS
## 📚 Документация
Доступ к разделу "Склад ВБ" имеют только пользователи с типом организации "SELLER".
Детальная документация проекта находится в:
- **`docs/`** - Новая модульная архитектура документации
- **`legacy-rules/`** - Бизнес-правила и процессы
- **`CLAUDE.md`** - Системные правила разработки
- **`prisma/seed.js`** - Скрипт инициализации БД
## 🐳 Docker
```bash
# Сборка образа
docker build -t sfera .
# Запуск с docker-compose
docker-compose up -d
```
## 🤝 Участие в разработке
### Стиль кода
- **ESLint** - обязательное соблюдение правил
- **Prettier** - автоматическое форматирование
- **TypeScript** - строгая типизация
- **Conventional Commits** - стандарт коммитов
### Git Flow
1. Создание feature-ветки
2. Разработка с соблюдением линтинга
3. Тестирование изменений
4. Pull Request с ревью
## 🔧 Переменные окружения
```bash
# База данных
DATABASE_URL="postgresql://user:pass@localhost:5432/sfera"
# JWT
JWT_SECRET="your-secret-key"
# API ключи маркетплейсов
WB_API_TOKEN="wildberries-token"
OZON_CLIENT_ID="ozon-client-id"
OZON_API_KEY="ozon-api-key"
# DaData API
DADATA_API_KEY="dadata-api-key"
DADATA_SECRET="dadata-secret"
# SMS Aero
SMS_AERO_EMAIL="your-email"
SMS_AERO_API_KEY="sms-aero-api-key"
# S3 для файлов
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
```
## 📞 Поддержка
При возникновении проблем:
1. Проверьте статус систем через `/api/health`
2. Просмотрите логи в `dev.log`
3. Обратитесь к документации в `docs/`
---
**Лицензия:** Частная разработка
**Статус:** Активная разработка
**Версия:** 0.2.0

View File

@ -0,0 +1,348 @@
# 🚚 WORKFLOW ЦЕПОЧКИ ПОСТАВОК SFERA v2.0
> **⚠️ ВАЖНО:** Этот документ описывает НОВУЮ архитектуру системы поставок с разделением на отдельные типы. Для старой системы см. SUPPLY_CHAIN_WORKFLOW.md
## 🎯 ОБЗОР НОВОЙ СИСТЕМЫ
Система поставок SFERA v2.0 включает **5 типов поставок**, разделенных на две категории:
### 📦 **ПОСТАВКИ НА ФУЛФИЛМЕНТ**
- `GoodsSupplyOrder` - товары селлера → склад ФФ
- `FulfillmentConsumableSupplyOrder` - расходники ФФ → склад ФФ
- `SellerConsumableSupplyOrder` - расходники селлера → склад ФФ
### 🛒 **ПОСТАВКИ НА МАРКЕТПЛЕЙСЫ**
- `OzonSupplyOrder` - готовые продукты → Ozon
- `WildberriesSupplyOrder` - готовые продукты → Wildberries
---
## 🔄 WORKFLOW ПО ТИПАМ ПОСТАВОК
### 1⃣ **WORKFLOW: Поставки расходников ФФ**
```mermaid
graph TD
A[ФФ создает заказ расходников] --> B[PENDING]
B --> C{Поставщик одобряет?}
C -->|Да| D[SUPPLIER_APPROVED]
C -->|Нет| X[CANCELLED]
D --> E{Логистика назначена?}
E -->|Да| F[LOGISTICS_CONFIRMED]
E -->|Нет| D
F --> G[Поставщик отгружает]
G --> H[SHIPPED]
H --> I[IN_TRANSIT]
I --> J[ФФ принимает на склад]
J --> K[DELIVERED]
style A fill:#e1f5fe
style K fill:#c8e6c9
style X fill:#ffcdd2
```
**Участники:**
- 🏭 **Фулфилмент** - создатель, получатель
- 🏪 **Поставщик (WHOLESALE)** - одобрение, отгрузка
- 🚛 **Логистика (LOGIST)** - доставка
**Особенности:**
- ✅ ФФ видит все детали + устанавливает цены продажи селлерам
- ✅ Поставщик видит товары/количества, НЕ видит цены продажи ФФ
- ✅ Показывается сразу после создания
### 2⃣ **WORKFLOW: Поставки товаров селлера**
```mermaid
graph TD
A[Селлер создает заказ товаров] --> B[PENDING]
B --> C{Поставщик одобряет?}
C -->|Да| D[SUPPLIER_APPROVED]
C -->|Нет| X[CANCELLED]
D --> E{Логистика назначена?}
E -->|Да| F[LOGISTICS_CONFIRMED]
E -->|Нет| D
F --> G[Поставщик отгружает]
G --> H[SHIPPED]
H --> I[IN_TRANSIT]
I --> J[ФФ принимает + обрабатывает]
J --> K[DELIVERED]
style A fill:#fff3e0
style K fill:#c8e6c9
style X fill:#ffcdd2
```
**Участники:**
- 🛒 **Селлер** - создатель, владелец товара
- 🏪 **Поставщик (WHOLESALE)** - поставка
- 🚛 **Логистика (LOGIST)** - доставка
- 🏭 **Фулфилмент** - получатель, обработка
**Особенности:**
- ✅ Селлер видит свои товары + рецептуры + закупочные цены
- ✅ ФФ видит товары + рецептуры + услуги, НЕ видит закупочные цены селлера
- ✅ Поставщик видит товары + количества, НЕ видит рецептуры
- ✅ Расходники селлера идут **в состав продукта**, не отслеживаются отдельно
### 3⃣ **WORKFLOW: Поставки расходников селлера**
```mermaid
graph TD
A[Селлер заказывает свои расходники] --> B[PENDING]
B --> C{Поставщик одобряет?}
C -->|Да| D[SUPPLIER_APPROVED]
C -->|Нет| X[CANCELLED]
D --> E{Логистика назначена?}
E -->|Да| F[LOGISTICS_CONFIRMED]
E -->|Нет| D
F --> G[Поставщик отгружает]
G --> H[SHIPPED]
H --> I[IN_TRANSIT]
I --> J[ФФ принимает НА ХРАНЕНИЕ]
J --> K[DELIVERED]
style A fill:#f3e5f5
style K fill:#c8e6c9
style X fill:#ffcdd2
```
**Участники:**
- 🛒 **Селлер** - создатель, владелец расходников
- 🏪 **Поставщик (WHOLESALE)** - поставка
- 🚛 **Логистика (LOGIST)** - доставка
- 🏭 **Фулфилмент** - хранитель (НЕ владелец)
**Особенности:**
- ✅ Селлер видит свои расходники + закупочные цены
- ✅ ФФ видит факт хранения + количества, НЕ видит закупочные цены селлера
- ✅ Срок хранения + права доступа настраиваются
- ✅ Используются селлером в рецептурах своих товаров
---
## 📊 СТАТУСЫ И ПЕРЕХОДЫ
### **SupplyOrderStatus (поставки НА фулфилмент)**
| Статус | Описание | Ответственный | Действия |
|--------|----------|---------------|----------|
| `PENDING` | Ожидает одобрения поставщика | Поставщик | Одобрить/Отклонить |
| `SUPPLIER_APPROVED` | Одобрено поставщиком | Логистика | Назначить маршрут |
| `LOGISTICS_CONFIRMED` | Логистика подтверждена | Поставщик | Отгрузить товар |
| `SHIPPED` | Отгружено | Система | Автоматический переход |
| `IN_TRANSIT` | В пути | Логистика | Отслеживание доставки |
| `DELIVERED` | Доставлено | ФФ | Принять на склад |
| `CANCELLED` | Отменено | Любой участник | Указать причину |
### **MarketplaceSupplyStatus (поставки НА маркетплейсы)**
| Статус | Описание | Ответственный | Действия |
|--------|----------|---------------|----------|
| `PLANNED` | Запланирована | ФФ | Подготовить товары |
| `PREPARED` | Подготовлена | ФФ | Отгрузить |
| `SHIPPED_TO_MARKETPLACE` | Отгружена | Маркетплейс | Принять товар |
| `ACCEPTED_BY_MARKETPLACE` | Принята | Система | Обновить остатки |
| `CANCELLED` | Отменена | ФФ/Маркетплейс | Указать причину |
---
## 🎭 РОЛИ И ПРАВА ДОСТУПА
### 🏭 **ФУЛФИЛМЕНТ**
**Может создавать:**
- ✅ Поставки расходников ФФ
- ✅ Поставки на маркетплейсы
**Может видеть:**
- ✅ Свои поставки расходников: все детали + цены продажи
- ✅ Товарные поставки селлеров: товары + рецептуры, НЕ закупочные цены
- ✅ Расходники селлеров на хранении: количества, НЕ закупочные цены
### 🛒 **СЕЛЛЕР**
**Может создавать:**
- ✅ Товарные поставки
- ✅ Поставки расходников селлера
**Может видеть:**
- ✅ Свои товарные поставки: все детали + рецептуры + закупочные цены
- ✅ Свои расходники: все детали + закупочные цены
- ❌ Чужие поставки
- ❌ Поставки расходников ФФ
### 🏪 **ПОСТАВЩИК (WHOLESALE)**
**Может видеть:**
- ✅ Заказы к себе: товары + количества
- ❌ Рецептуры товаров
- ❌ Цены продажи ФФ селлерам
- ❌ Услуги ФФ
### 🚛 **ЛОГИСТИКА (LOGIST)**
**Может видеть:**
- ✅ Маршруты + объемы + вес
- ❌ Коммерческие данные (цены, услуги)
- ❌ Рецептуры товаров
---
## 🌐 ИНТЕРФЕЙСЫ СИСТЕМЫ
### 📦 **Кабинет фулфилмента**
**URL:** `/fulfillment-supplies/`
**Вкладки:**
- `ff-consumables` - поставки расходников ФФ (создание + просмотр)
- `seller-consumables` - расходники селлеров на хранении
- `goods?status=new` - новые товарные поставки
- `goods?status=receiving` - товары в приемке
- `goods?status=accepted` - принятые товары
### 🛒 **Кабинет селлера**
**URL:** `/seller-supplies/`
**Вкладки:**
- `my-goods` - мои товарные поставки
- `my-consumables` - мои расходники
- `create-goods` - создать поставку товаров
- `create-consumables` - заказать расходники
### 🛍️ **Кабинет маркетплейсов**
**URL:** `/marketplace-supplies/`
**Вкладки:**
- `ozon` - поставки на Ozon
- `wildberries` - поставки на Wildberries
- `create-ozon` - создать поставку на Ozon
- `create-wildberries` - создать поставку на WB
---
## ⚡ ОСОБЕННОСТИ РЕАЛИЗАЦИИ
### 🔄 **Дополнение данных по этапам**
Каждая поставка - это **одна запись**, которая дополняется участниками:
```typescript
// Создание (селлер/ФФ)
supply = {
id: "...",
status: "PENDING",
sellerId: "...", // кто создал
requestedDate: "...", // когда нужно
items: [...] // что заказано
}
// Одобрение (поставщик)
supply = {
...supply,
status: "SUPPLIER_APPROVED",
supplierId: "...", // кто одобрил
approvedAt: "...", // когда одобрил
packagesCount: 5, // уточненные параметры
estimatedVolume: 2.5
}
// Назначение логистики (ФФ)
supply = {
...supply,
status: "LOGISTICS_CONFIRMED",
logisticsPartnerId: "...", // кто повезет
routeId: "...", // маршрут
logisticsCost: 1500 // стоимость
}
```
### 🔐 **Фильтрация по безопасности**
Каждый resolver применяет фильтрацию по роли:
```typescript
// Пример: поставки расходников ФФ
const supplies = await prisma.fulfillmentConsumableSupplyOrder.findMany({
where: {
// Только свои поставки для ФФ
fulfillmentCenterId: user.organizationId
}
})
// Фильтрация полей по роли
return supplies.map(supply =>
SupplyDataFilter.filterByRole(supply, user.organizationType)
)
```
### 📈 **Масштабируемость**
Новые типы поставок добавляются независимо:
```typescript
// Будущее: Поставки на Яндекс.Маркет
interface YandexMarketSupplyOrder {
// специфичные поля для Яндекс.Маркета
}
// Отдельные операции
createYandexMarketSupply()
myYandexMarketSupplies()
```
---
## ✅ ПРЕИМУЩЕСТВА НОВОЙ АРХИТЕКТУРЫ
### 🎯 **Четкое разделение ответственности**
- Каждый тип поставки имеет свою логику
- Независимая разработка и тестирование
- Простота добавления новых типов
### 🔒 **Надежная безопасность**
- Раздельные правила доступа для каждого типа
- Невозможно случайно показать чужие данные
- Гранулярные права по ролям
### 📈 **Масштабируемость**
- Легко добавлять новые маркетплейсы
- Независимые схемы для разных процессов
- Оптимизация каждого типа отдельно
### 🛡️ **Безопасная миграция**
- Поэтапное внедрение без остановки системы
- Система откатов на каждом этапе
- Сохранение работоспособности старой системы
---
## 🚀 ПЛАН ВНЕДРЕНИЯ
### **Phase 1:** FulfillmentConsumableSupplyOrder ⏳
- Новая модель данных
- GraphQL операции
- Интерфейс создания и просмотра
- Тестирование
### **Phase 2:** SellerConsumableSupplyOrder
- Аналогично Phase 1
- Интеграция с системой хранения
### **Phase 3:** GoodsSupplyOrder
- Самый сложный тип с рецептурами
- Миграция существующих товарных поставок
### **Phase 4:** Поставки на маркетплейсы
- Отдельная система для Ozon/WB
- API интеграции с маркетплейсами
### **Phase 5:** Очистка и оптимизация
- Миграция старых данных
- Удаление устаревшего кода (с одобрения)
- Финальная оптимизация
**Следующий шаг:** Начало реализации Phase 1 - FulfillmentConsumableSupplyOrder

View File

@ -0,0 +1,487 @@
# 🚚 АРХИТЕКТУРА СИСТЕМЫ ПОСТАВОК SFERA
## 🎯 ОБЗОР СИСТЕМЫ
Система поставок SFERA включает **5 типов поставок**, разделенных на две категории:
### 📦 **ПОСТАВКИ НА ФУЛФИЛМЕНТ (3 типа)**
1. **Товарные поставки** - товары селлера → склад ФФ
2. **Поставки расходников ФФ** - расходники ФФ → склад ФФ
3. **Поставки расходников селлеров** - расходники селлера → склад ФФ (на хранение)
### 🛒 **ПОСТАВКИ НА МАРКЕТПЛЕЙСЫ (2+ типов)**
4. **Поставки на Ozon** - готовые продукты со склада ФФ → Ozon
5. **Поставки на Wildberries** - готовые продукты со склада ФФ → Wildberries
---
## 📊 АРХИТЕКТУРА ДАННЫХ
### ✅ **ПРИНЦИП: ОТДЕЛЬНАЯ ТАБЛИЦА ДЛЯ КАЖДОГО ТИПА ПОСТАВКИ**
#### **Преимущества подхода:**
- 🎯 Четкое разделение ответственности
- 🔒 Упрощение системы безопасности
- 📈 Независимые схемы для разных процессов
- 🔧 Простота миграций и изменений
- 📝 Специфичные поля для каждого типа
### 🏗️ **СТРУКТУРА ТАБЛИЦ ПОСТАВОК НА ФУЛФИЛМЕНТ**
#### **1. GoodsSupplyOrder - Товарные поставки**
```typescript
interface GoodsSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id: string
status: SupplyOrderStatus
createdAt: DateTime
updatedAt: DateTime
// === ДАННЫЕ СЕЛЛЕРА (создатель) ===
sellerId: string // кто заказывает
fulfillmentCenterId: string // куда доставить
requestedDeliveryDate: DateTime // когда нужно
// === ДАННЫЕ ПОСТАВЩИКА ===
supplierId: string // кто поставляет
supplierApprovedAt?: DateTime // когда одобрил
packagesCount?: number // количество грузомест
estimatedVolume?: number // объем груза
supplierContractId?: string // номер договора
// === ДАННЫЕ ЛОГИСТИКИ ===
logisticsPartnerId?: string // кто везет
estimatedDeliveryDate?: DateTime // план доставки
routeId?: string // маршрут
logisticsCost?: number // стоимость доставки
// === ДАННЫЕ ОТГРУЗКИ ===
shippedAt?: DateTime // факт отгрузки
// === ДАННЫЕ ПРИЕМКИ ===
receivedAt?: DateTime // факт приемки
receivedById?: string // кто принял (сотрудник ФФ)
actualQuantity?: number // принято количество
defectQuantity?: number // брак
// === УНИКАЛЬНЫЕ ПОЛЯ ДЛЯ ТОВАРОВ ===
hasRecipes: boolean // есть ли рецептуры
totalServicesValue?: number // стоимость услуг ФФ
// === СВЯЗИ ===
items: GoodsSupplyItem[]
}
interface GoodsSupplyItem {
id: string
supplyOrderId: string // связь с поставкой
productId: string // какой товар
requestedQuantity: number // запросили
approvedQuantity?: number // поставщик одобрил
shippedQuantity?: number // отгрузили
receivedQuantity?: number // приняли
defectQuantity?: number // брак
unitPrice: number // цена за единицу
totalPrice: number // общая стоимость
// === РЕЦЕПТУРА (JSON) ===
recipe?: {
services: string[] // ID услуг ФФ
fulfillmentConsumables: string[] // ID расходников ФФ
sellerConsumables: string[] // ID расходников селлера
marketplaceCardId?: string // карточка товара
}
}
```
#### **2. FulfillmentConsumableSupplyOrder - Поставки расходников ФФ**
```typescript
interface FulfillmentConsumableSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id: string
status: SupplyOrderStatus
createdAt: DateTime
updatedAt: DateTime
// === ДАННЫЕ ФФ (создатель) ===
fulfillmentCenterId: string // кто заказывает
requestedDeliveryDate: DateTime // когда нужно
// === ДАННЫЕ ПОСТАВЩИКА ===
supplierId: string // кто поставляет
supplierApprovedAt?: DateTime // когда одобрил
packagesCount?: number // количество грузомест
estimatedVolume?: number // объем груза
supplierContractId?: string // номер договора
// === ДАННЫЕ ЛОГИСТИКИ ===
logisticsPartnerId?: string // кто везет
estimatedDeliveryDate?: DateTime // план доставки
routeId?: string // маршрут
logisticsCost?: number // стоимость доставки
// === ДАННЫЕ ОТГРУЗКИ ===
shippedAt?: DateTime // факт отгрузки
// === ДАННЫЕ ПРИЕМКИ ===
receivedAt?: DateTime // факт приемки
receivedById?: string // кто принял (сотрудник ФФ)
actualQuantity?: number // принято количество
defectQuantity?: number // брак
// === УНИКАЛЬНЫЕ ПОЛЯ ДЛЯ РАСХОДНИКОВ ФФ ===
resalePricePerUnit?: number // цена продажи селлерам
minStockLevel?: number // складские лимиты
// === СВЯЗИ ===
items: FulfillmentConsumableSupplyItem[]
}
interface FulfillmentConsumableSupplyItem {
id: string
supplyOrderId: string // связь с поставкой
productId: string // какой расходник
requestedQuantity: number // запросили
approvedQuantity?: number // поставщик одобрил
shippedQuantity?: number // отгрузили
receivedQuantity?: number // приняли
defectQuantity?: number // брак
unitPrice: number // цена за единицу от поставщика
totalPrice: number // общая стоимость
}
```
#### **3. SellerConsumableSupplyOrder - Поставки расходников селлеров**
```typescript
interface SellerConsumableSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id: string
status: SupplyOrderStatus
createdAt: DateTime
updatedAt: DateTime
// === ДАННЫЕ СЕЛЛЕРА (создатель) ===
sellerId: string // кто заказывает
fulfillmentCenterId: string // где будет храниться
requestedDeliveryDate: DateTime // когда нужно
// === ДАННЫЕ ПОСТАВЩИКА ===
supplierId: string // кто поставляет
supplierApprovedAt?: DateTime // когда одобрил
packagesCount?: number // количество грузомест
estimatedVolume?: number // объем груза
supplierContractId?: string // номер договора
// === ДАННЫЕ ЛОГИСТИКИ ===
logisticsPartnerId?: string // кто везет
estimatedDeliveryDate?: DateTime // план доставки
routeId?: string // маршрут
logisticsCost?: number // стоимость доставки
// === ДАННЫЕ ОТГРУЗКИ ===
shippedAt?: DateTime // факт отгрузки
// === ДАННЫЕ ПРИЕМКИ ===
receivedAt?: DateTime // факт приемки ФФ
receivedById?: string // кто принял (сотрудник ФФ)
actualQuantity?: number // принято количество
defectQuantity?: number // брак
// === УНИКАЛЬНЫЕ ПОЛЯ ДЛЯ РАСХОДНИКОВ СЕЛЛЕРОВ ===
storageTermMonths?: number // срок хранения
accessRights: 'SELLER_ONLY' | 'SHARED_WITH_FF' // кто может использовать
storageCostPerMonth?: number // стоимость хранения
// === СВЯЗИ ===
items: SellerConsumableSupplyItem[]
}
interface SellerConsumableSupplyItem {
id: string
supplyOrderId: string // связь с поставкой
productId: string // какой расходник селлера
requestedQuantity: number // запросили
approvedQuantity?: number // поставщик одобрил
shippedQuantity?: number // отгрузили
receivedQuantity?: number // приняли
defectQuantity?: number // брак
unitPrice: number // цена за единицу
totalPrice: number // общая стоимость
}
```
### 🛒 **СТРУКТУРА ТАБЛИЦ ПОСТАВОК НА МАРКЕТПЛЕЙСЫ**
#### **4. OzonSupplyOrder - Поставки на Ozon**
```typescript
interface OzonSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id: string
status: MarketplaceSupplyStatus
createdAt: DateTime
updatedAt: DateTime
// === СПЕЦИФИЧНЫЕ ПОЛЯ OZON ===
ozonWarehouseId: string // склад Ozon
ozonSupplyId?: string // ID поставки в системе Ozon
// ... дополнительные поля для Ozon API
// === СВЯЗИ ===
items: OzonSupplyItem[]
}
```
#### **5. WildberriesSupplyOrder - Поставки на Wildberries**
```typescript
interface WildberriesSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id: string
status: MarketplaceSupplyStatus
createdAt: DateTime
updatedAt: DateTime
// === СПЕЦИФИЧНЫЕ ПОЛЯ WB ===
wbWarehouseId: string // склад WB
wbSupplyId?: string // ID поставки в системе WB
// ... дополнительные поля для WB API
// === СВЯЗИ ===
items: WildberriesSupplyItem[]
}
```
---
## 🔐 СИСТЕМА БЕЗОПАСНОСТИ
### 📋 **ПРАВИЛА ДОСТУПА К ПОСТАВКАМ РАСХОДНИКОВ ФФ**
**Кто видит и что:**
-**ФФ (создатель):** все детали + цены + остатки
-**Поставщик:** товары, количества, НЕ цены продажи ФФ
-**Логистика:** маршруты, объемы, НЕ коммерческие данные
-**Селлеры:** вообще не видят
### 📋 **ПРАВИЛА ДОСТУПА К ПОСТАВКАМ РАСХОДНИКОВ СЕЛЛЕРОВ**
**Кто видит и что:**
-**Селлер (создатель):** все свои детали + свои цены
-**ФФ (хранитель):** факт хранения + количества, НЕ закупочные цены селлера
-**Поставщик:** товары, количества для селлера
-**Логистика:** маршруты, объемы
-**Другие селлеры:** не видят чужие расходники
### 📋 **ПРАВИЛА ДОСТУПА К ТОВАРНЫМ ПОСТАВКАМ**
**Кто видит и что:**
-**Селлер (создатель):** все детали своих товаров + рецептуры
-**ФФ (получатель):** товары + рецептуры + услуги, НЕ закупочные цены селлера
-**Поставщик:** товары + количества, НЕ рецептуры и НЕ цены ФФ
-**Логистика:** маршруты + объемы, НЕ коммерческие данные
-**Другие селлеры:** не видят чужие поставки
---
## 🌐 URL-СТРУКТУРА ИНТЕРФЕЙСОВ
### 📦 **ПОСТАВКИ НА ФУЛФИЛМЕНТ**
```
/fulfillment-supplies/goods?status=new # Товар → Новые
/fulfillment-supplies/goods?status=receiving # Товар → Приёмка
/fulfillment-supplies/goods?status=accepted # Товар → Принято
/fulfillment-supplies/ff-consumables # Расходники фулфилмента
/fulfillment-supplies/seller-consumables # Расходники селлеров
/fulfillment-supplies/create-consumables # Создание поставки расходников ФФ
```
### 🛒 **ПОСТАВКИ НА МАРКЕТПЛЕЙСЫ**
```
/marketplace-supplies/ozon # Поставки на Ozon
/marketplace-supplies/wildberries # Поставки на Wildberries
/marketplace-supplies/create-ozon # Создание поставки на Ozon
/marketplace-supplies/create-wildberries # Создание поставки на WB
```
---
## ⚡ GRAPHQL API АРХИТЕКТУРА
### 🎯 **ПРИНЦИП: ОТДЕЛЬНЫЕ ОПЕРАЦИИ ДЛЯ КАЖДОГО ТИПА**
#### **Поставки расходников ФФ:**
```graphql
# Queries
myFulfillmentConsumableSupplies: [FulfillmentConsumableSupplyOrder!]!
fulfillmentConsumableSupply(id: ID!): FulfillmentConsumableSupplyOrder
# Mutations
createFulfillmentConsumableSupply(input: CreateFulfillmentConsumableSupplyInput!): CreateSupplyResult!
updateFulfillmentConsumableSupply(id: ID!, input: UpdateFulfillmentConsumableSupplyInput!): UpdateSupplyResult!
```
#### **Поставки расходников селлеров:**
```graphql
# Queries
mySellerConsumableSupplies: [SellerConsumableSupplyOrder!]!
sellerConsumableSupply(id: ID!): SellerConsumableSupplyOrder
# Mutations
createSellerConsumableSupply(input: CreateSellerConsumableSupplyInput!): CreateSupplyResult!
updateSellerConsumableSupply(id: ID!, input: UpdateSellerConsumableSupplyInput!): UpdateSupplyResult!
```
#### **Товарные поставки:**
```graphql
# Queries
myGoodsSupplies: [GoodsSupplyOrder!]!
goodsSupply(id: ID!): GoodsSupplyOrder
# Mutations
createGoodsSupply(input: CreateGoodsSupplyInput!): CreateSupplyResult!
updateGoodsSupply(id: ID!, input: UpdateGoodsSupplyInput!): UpdateSupplyResult!
```
#### **Поставки на маркетплейсы (отдельная система):**
```graphql
# Queries
myOzonSupplies: [OzonSupplyOrder!]!
myWildberriesSupplies: [WildberriesSupplyOrder!]!
# Mutations
createOzonSupply(input: CreateOzonSupplyInput!): CreateMarketplaceSupplyResult!
createWildberriesSupply(input: CreateWildberriesSupplyInput!): CreateMarketplaceSupplyResult!
```
---
## 🔄 ПЛАН ПОЭТАПНОЙ МИГРАЦИИ
### ✅ **ПРИНЦИПЫ БЕЗОПАСНОЙ МИГРАЦИИ**
- 🛡️ Сохранение работоспособности существующей системы
- 🔄 Система откатов на каждом этапе
- 🧪 Тестирование каждого типа поставки отдельно
- 📝 Модульная архитектура
- ❌ **Удаление старого кода ТОЛЬКО после одобрения пользователя**
### **ЭТАП 1: FulfillmentConsumableSupplyOrder**
#### **1.1 Создание новой модели данных**
```typescript
// Новая таблица параллельно со старой
model FulfillmentConsumableSupplyOrder {
// ... вся структура
}
// Старая таблица остается для совместимости
model SupplyOrder {
// ... существующая структура
}
```
#### **1.2 GraphQL API**
```graphql
# Новые операции
createFulfillmentConsumableSupply()
myFulfillmentConsumableSupplies()
# Старые операции остаются работать
createSupplyOrder() # для других типов
mySupplyOrders() # для других типов
```
#### **1.3 Интерфейс**
- Форма `/fulfillment-supplies/create-consumables` → новая мутация
- Вкладка `/fulfillment-supplies/ff-consumables` → новый запрос
#### **1.4 Тестирование**
- ✅ Создание поставки расходников ФФ
- ✅ Отображение в интерфейсе
- ✅ Безопасность доступа
- ✅ Работа старой системы
### **ЭТАП 2: SellerConsumableSupplyOrder**
- Аналогично этапу 1
- Тестирование + интеграция
### **ЭТАП 3: GoodsSupplyOrder**
- Аналогично этапу 1
- Тестирование + интеграция
### **ЭТАП 4: Очистка (ТОЛЬКО после одобрения)**
- Миграция данных из старых таблиц
- Удаление старого кода
- Обновление документации
---
## 🎯 СТАТУСЫ ПОСТАВОК
### **SupplyOrderStatus (для поставок НА фулфилмент)**
```typescript
enum SupplyOrderStatus {
PENDING // Создана, ждет одобрения поставщика
SUPPLIER_APPROVED // Одобрена поставщиком
LOGISTICS_CONFIRMED // Логистика подтверждена
SHIPPED // Отгружена поставщиком
IN_TRANSIT // В пути
DELIVERED // Доставлена на склад ФФ
CANCELLED // Отменена
}
```
### **MarketplaceSupplyStatus (для поставок НА маркетплейсы)**
```typescript
enum MarketplaceSupplyStatus {
PLANNED // Запланирована
PREPARED // Подготовлена к отгрузке
SHIPPED_TO_MARKETPLACE // Отгружена на маркетплейс
ACCEPTED_BY_MARKETPLACE // Принята маркетплейсом
CANCELLED // Отменена
}
```
---
## 📊 ИНТЕГРАЦИИ
### **Поставки НА фулфилмент:**
- 🏪 **DaData API** - валидация ИНН поставщиков
- 📱 **SMS Aero** - уведомления участникам
- ☁️ **AWS S3** - документы поставок
- 🚚 **Логистические партнеры** - трекинг доставки
### **Поставки НА маркетплейсы:**
- 🛒 **Ozon API** - создание поставок
- 🛍️ **Wildberries API** - создание поставок
- 📦 **Трекинг системы** - статусы доставки
- 💰 **Биллинг системы** - расчет комиссий
---
## 🔍 МОНИТОРИНГ И АУДИТ
### **Коммерческие данные:**
- 📊 Логирование доступа к ценам
- 🔐 Аудит прав доступа по ролям
- 📈 Метрики безопасности
- ⚠️ Алерты на подозрительную активность
### **Операционные метрики:**
- ⏱️ Время выполнения поставок
- 📦 Процент успешных доставок
- 🔄 SLA по статусам
- 📊 Аналитика по типам поставок
---
## ✅ ЗАКЛЮЧЕНИЕ
Новая архитектура системы поставок обеспечивает:
- 🎯 **Четкое разделение** типов поставок
- 🔒 **Надежную безопасность** коммерческих данных
- 📈 **Масштабируемость** для новых маркетплейсов
- 🔧 **Простоту развития** каждого типа независимо
- 🛡️ **Безопасную миграцию** без потери данных
**Следующий шаг:** Поэтапная реализация начиная с `FulfillmentConsumableSupplyOrder`

View File

@ -0,0 +1,600 @@
# 🗄️ СХЕМА БАЗЫ ДАННЫХ SFERA v2.0 - СИСТЕМА ПОСТАВОК
> **⚠️ ВАЖНО:** Этот документ описывает НОВЫЕ таблицы для системы поставок v2.0. Существующие таблицы остаются без изменений для обратной совместимости.
## 📦 НОВЫЕ ТАБЛИЦЫ ПОСТАВОК НА ФУЛФИЛМЕНТ
### 1⃣ **FulfillmentConsumableSupplyOrder - Поставки расходников ФФ**
```prisma
model FulfillmentConsumableSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id String @id @default(cuid())
status SupplyOrderStatusV2 @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === ДАННЫЕ ФФ (создатель) ===
fulfillmentCenterId String // кто заказывает (FK: Organization)
requestedDeliveryDate DateTime // когда нужно
resalePricePerUnit Decimal? @db.Decimal(10, 2) // цена продажи селлерам
minStockLevel Int? // минимальный остаток
notes String? // заметки ФФ
// === ДАННЫЕ ПОСТАВЩИКА ===
supplierId String? // кто поставляет (FK: Organization)
supplierApprovedAt DateTime? // когда одобрил
packagesCount Int? // количество грузомест
estimatedVolume Decimal? @db.Decimal(8, 3) // объем груза в м³
supplierContractId String? // номер договора
supplierNotes String? // заметки поставщика
// === ДАННЫЕ ЛОГИСТИКИ ===
logisticsPartnerId String? // кто везет (FK: Organization)
estimatedDeliveryDate DateTime? // план доставки
routeId String? // маршрут (FK: LogisticsRoute)
logisticsCost Decimal? @db.Decimal(10, 2) // стоимость доставки
logisticsNotes String? // заметки логистики
// === ДАННЫЕ ОТГРУЗКИ ===
shippedAt DateTime? // факт отгрузки
trackingNumber String? // номер отслеживания
// === ДАННЫЕ ПРИЕМКИ ===
receivedAt DateTime? // факт приемки
receivedById String? // кто принял (FK: User)
actualQuantity Int? // принято количество
defectQuantity Int? // брак
receiptNotes String? // заметки приемки
// === СВЯЗИ ===
fulfillmentCenter Organization @relation("FFSupplyOrdersFulfillment", fields: [fulfillmentCenterId], references: [id])
supplier Organization? @relation("FFSupplyOrdersSupplier", fields: [supplierId], references: [id])
logisticsPartner Organization? @relation("FFSupplyOrdersLogistics", fields: [logisticsPartnerId], references: [id])
receivedBy User? @relation("FFSupplyOrdersReceiver", fields: [receivedById], references: [id])
items FulfillmentConsumableSupplyItem[]
@@map("fulfillment_consumable_supply_orders")
}
model FulfillmentConsumableSupplyItem {
id String @id @default(cuid())
supplyOrderId String // связь с поставкой
productId String // какой расходник (FK: Product)
// === КОЛИЧЕСТВА ===
requestedQuantity Int // запросили
approvedQuantity Int? // поставщик одобрил
shippedQuantity Int? // отгрузили
receivedQuantity Int? // приняли
defectQuantity Int? @default(0) // брак
// === ЦЕНЫ ===
unitPrice Decimal @db.Decimal(10, 2) // цена за единицу от поставщика
totalPrice Decimal @db.Decimal(12, 2) // общая стоимость
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === СВЯЗИ ===
supplyOrder FulfillmentConsumableSupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade)
product Product @relation("FFSupplyItems", fields: [productId], references: [id])
@@unique([supplyOrderId, productId])
@@map("fulfillment_consumable_supply_items")
}
```
### 2⃣ **SellerConsumableSupplyOrder - Поставки расходников селлеров**
```prisma
model SellerConsumableSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id String @id @default(cuid())
status SupplyOrderStatusV2 @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === ДАННЫЕ СЕЛЛЕРА (создатель) ===
sellerId String // кто заказывает (FK: Organization)
fulfillmentCenterId String // где будет храниться (FK: Organization)
requestedDeliveryDate DateTime // когда нужно
notes String? // заметки селлера
// === ДАННЫЕ ПОСТАВЩИКА ===
supplierId String? // кто поставляет (FK: Organization)
supplierApprovedAt DateTime? // когда одобрил
packagesCount Int? // количество грузомест
estimatedVolume Decimal? @db.Decimal(8, 3) // объем груза в м³
supplierContractId String? // номер договора
supplierNotes String? // заметки поставщика
// === ДАННЫЕ ЛОГИСТИКИ ===
logisticsPartnerId String? // кто везет (FK: Organization)
estimatedDeliveryDate DateTime? // план доставки
routeId String? // маршрут (FK: LogisticsRoute)
logisticsCost Decimal? @db.Decimal(10, 2) // стоимость доставки
logisticsNotes String? // заметки логистики
// === ДАННЫЕ ОТГРУЗКИ ===
shippedAt DateTime? // факт отгрузки
trackingNumber String? // номер отслеживания
// === ДАННЫЕ ПРИЕМКИ ===
receivedAt DateTime? // факт приемки ФФ
receivedById String? // кто принял (FK: User)
actualQuantity Int? // принято количество
defectQuantity Int? // брак
receiptNotes String? // заметки приемки
// === УНИКАЛЬНЫЕ ПОЛЯ ДЛЯ РАСХОДНИКОВ СЕЛЛЕРОВ ===
storageTermMonths Int? // срок хранения в месяцах
accessRights SellerConsumableAccessRights @default(SELLER_ONLY)
storageCostPerMonth Decimal? @db.Decimal(8, 2) // стоимость хранения за месяц
// === СВЯЗИ ===
seller Organization @relation("SellerSupplyOrdersSeller", fields: [sellerId], references: [id])
fulfillmentCenter Organization @relation("SellerSupplyOrdersFulfillment", fields: [fulfillmentCenterId], references: [id])
supplier Organization? @relation("SellerSupplyOrdersSupplier", fields: [supplierId], references: [id])
logisticsPartner Organization? @relation("SellerSupplyOrdersLogistics", fields: [logisticsPartnerId], references: [id])
receivedBy User? @relation("SellerSupplyOrdersReceiver", fields: [receivedById], references: [id])
items SellerConsumableSupplyItem[]
@@map("seller_consumable_supply_orders")
}
model SellerConsumableSupplyItem {
id String @id @default(cuid())
supplyOrderId String // связь с поставкой
productId String // какой расходник селлера (FK: Product)
// === КОЛИЧЕСТВА ===
requestedQuantity Int // запросили
approvedQuantity Int? // поставщик одобрил
shippedQuantity Int? // отгрузили
receivedQuantity Int? // приняли
defectQuantity Int? @default(0) // брак
// === ЦЕНЫ (видит только селлер) ===
unitPrice Decimal @db.Decimal(10, 2) // цена за единицу от поставщика
totalPrice Decimal @db.Decimal(12, 2) // общая стоимость
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === СВЯЗИ ===
supplyOrder SellerConsumableSupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade)
product Product @relation("SellerSupplyItems", fields: [productId], references: [id])
@@unique([supplyOrderId, productId])
@@map("seller_consumable_supply_items")
}
```
### 3⃣ **GoodsSupplyOrder - Товарные поставки**
```prisma
model GoodsSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id String @id @default(cuid())
status SupplyOrderStatusV2 @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === ДАННЫЕ СЕЛЛЕРА (создатель) ===
sellerId String // кто заказывает (FK: Organization)
fulfillmentCenterId String // куда доставить (FK: Organization)
requestedDeliveryDate DateTime // когда нужно
notes String? // заметки селлера
// === ДАННЫЕ ПОСТАВЩИКА ===
supplierId String? // кто поставляет (FK: Organization)
supplierApprovedAt DateTime? // когда одобрил
packagesCount Int? // количество грузомест
estimatedVolume Decimal? @db.Decimal(8, 3) // объем груза в м³
supplierContractId String? // номер договора
supplierNotes String? // заметки поставщика
// === ДАННЫЕ ЛОГИСТИКИ ===
logisticsPartnerId String? // кто везет (FK: Organization)
estimatedDeliveryDate DateTime? // план доставки
routeId String? // маршрут (FK: LogisticsRoute)
logisticsCost Decimal? @db.Decimal(10, 2) // стоимость доставки
logisticsNotes String? // заметки логистики
// === ДАННЫЕ ОТГРУЗКИ ===
shippedAt DateTime? // факт отгрузки
trackingNumber String? // номер отслеживания
// === ДАННЫЕ ПРИЕМКИ ===
receivedAt DateTime? // факт приемки
receivedById String? // кто принял (FK: User)
actualQuantity Int? // принято количество
defectQuantity Int? // брак
receiptNotes String? // заметки приемки
// === УНИКАЛЬНЫЕ ПОЛЯ ДЛЯ ТОВАРОВ ===
hasRecipes Boolean @default(false) // есть ли рецептуры
totalServicesValue Decimal? @db.Decimal(12, 2) // общая стоимость услуг ФФ
// === СВЯЗИ ===
seller Organization @relation("GoodsSupplyOrdersSeller", fields: [sellerId], references: [id])
fulfillmentCenter Organization @relation("GoodsSupplyOrdersFulfillment", fields: [fulfillmentCenterId], references: [id])
supplier Organization? @relation("GoodsSupplyOrdersSupplier", fields: [supplierId], references: [id])
logisticsPartner Organization? @relation("GoodsSupplyOrdersLogistics", fields: [logisticsPartnerId], references: [id])
receivedBy User? @relation("GoodsSupplyOrdersReceiver", fields: [receivedById], references: [id])
items GoodsSupplyItem[]
@@map("goods_supply_orders")
}
model GoodsSupplyItem {
id String @id @default(cuid())
supplyOrderId String // связь с поставкой
productId String // какой товар (FK: Product)
// === КОЛИЧЕСТВА ===
requestedQuantity Int // запросили
approvedQuantity Int? // поставщик одобрил
shippedQuantity Int? // отгрузили
receivedQuantity Int? // приняли
defectQuantity Int? @default(0) // брак
// === ЦЕНЫ (видит только селлер) ===
unitPrice Decimal @db.Decimal(10, 2) // цена за единицу от поставщика
totalPrice Decimal @db.Decimal(12, 2) // общая стоимость
// === РЕЦЕПТУРА (JSON) ===
recipe Json? // полная рецептура в JSON
/*
recipe structure:
{
services: string[] // ID услуг ФФ
fulfillmentConsumables: string[] // ID расходников ФФ
sellerConsumables: string[] // ID расходников селлера
marketplaceCardId?: string // карточка товара
}
*/
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === СВЯЗИ ===
supplyOrder GoodsSupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade)
product Product @relation("GoodsSupplyItems", fields: [productId], references: [id])
@@unique([supplyOrderId, productId])
@@map("goods_supply_items")
}
```
---
## 🛒 ТАБЛИЦЫ ПОСТАВОК НА МАРКЕТПЛЕЙСЫ
### 4⃣ **OzonSupplyOrder - Поставки на Ozon**
```prisma
model OzonSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id String @id @default(cuid())
status MarketplaceSupplyStatus @default(PLANNED)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === ДАННЫЕ ФФ (создатель) ===
fulfillmentCenterId String // кто отгружает (FK: Organization)
plannedShipmentDate DateTime // план отгрузки
notes String? // заметки ФФ
// === СПЕЦИФИЧНЫЕ ПОЛЯ OZON ===
ozonWarehouseId String // склад Ozon
ozonSupplyId String? // ID поставки в системе Ozon
ozonPostingNumber String? // номер отправления Ozon
// === ДАННЫЕ ОТГРУЗКИ ===
preparedAt DateTime? // готово к отгрузке
shippedAt DateTime? // отгружено
trackingNumber String? // номер отслеживания
// === ДАННЫЕ ПРИЕМКИ МАРКЕТПЛЕЙСОМ ===
acceptedAt DateTime? // принято Ozon
acceptedQuantity Int? // принято количество
rejectedQuantity Int? // отклонено
rejectionReason String? // причина отклонения
// === СВЯЗИ ===
fulfillmentCenter Organization @relation("OzonSupplyOrders", fields: [fulfillmentCenterId], references: [id])
items OzonSupplyItem[]
@@map("ozon_supply_orders")
}
model OzonSupplyItem {
id String @id @default(cuid())
supplyOrderId String // связь с поставкой
productId String // какой готовый продукт (FK: Product)
// === КОЛИЧЕСТВА ===
plannedQuantity Int // планируется отгрузить
preparedQuantity Int? // подготовлено
shippedQuantity Int? // отгружено
acceptedQuantity Int? // принято Ozon
rejectedQuantity Int? @default(0) // отклонено
// === OZON СПЕЦИФИЧНЫЕ ПОЛЯ ===
ozonProductId String? // ID товара в Ozon
ozonSku String? // SKU в Ozon
ozonBarcode String? // штрихкод
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === СВЯЗИ ===
supplyOrder OzonSupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade)
product Product @relation("OzonSupplyItems", fields: [productId], references: [id])
@@unique([supplyOrderId, productId])
@@map("ozon_supply_items")
}
```
### 5⃣ **WildberriesSupplyOrder - Поставки на Wildberries**
```prisma
model WildberriesSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id String @id @default(cuid())
status MarketplaceSupplyStatus @default(PLANNED)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === ДАННЫЕ ФФ (создатель) ===
fulfillmentCenterId String // кто отгружает (FK: Organization)
plannedShipmentDate DateTime // план отгрузки
notes String? // заметки ФФ
// === СПЕЦИФИЧНЫЕ ПОЛЯ WILDBERRIES ===
wbWarehouseId String // склад WB
wbSupplyId String? // ID поставки в системе WB
wbStickerId String? // ID стикера WB
// === ДАННЫЕ ОТГРУЗКИ ===
preparedAt DateTime? // готово к отгрузке
shippedAt DateTime? // отгружено
trackingNumber String? // номер отслеживания
// === ДАННЫЕ ПРИЕМКИ МАРКЕТПЛЕЙСОМ ===
acceptedAt DateTime? // принято WB
acceptedQuantity Int? // принято количество
rejectedQuantity Int? // отклонено
rejectionReason String? // причина отклонения
// === СВЯЗИ ===
fulfillmentCenter Organization @relation("WildberriesSupplyOrders", fields: [fulfillmentCenterId], references: [id])
items WildberriesSupplyItem[]
@@map("wildberries_supply_orders")
}
model WildberriesSupplyItem {
id String @id @default(cuid())
supplyOrderId String // связь с поставкой
productId String // какой готовый продукт (FK: Product)
// === КОЛИЧЕСТВА ===
plannedQuantity Int // планируется отгрузить
preparedQuantity Int? // подготовлено
shippedQuantity Int? // отгружено
acceptedQuantity Int? // принято WB
rejectedQuantity Int? @default(0) // отклонено
// === WB СПЕЦИФИЧНЫЕ ПОЛЯ ===
wbNmId String? // Номенклатура WB
wbSku String? // SKU в WB
wbBarcode String? // штрихкод
wbSize String? // размер для WB
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === СВЯЗИ ===
supplyOrder WildberriesSupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade)
product Product @relation("WBSupplyItems", fields: [productId], references: [id])
@@unique([supplyOrderId, productId])
@@map("wildberries_supply_items")
}
```
---
## 📊 НОВЫЕ ENUMS
### **SupplyOrderStatusV2 - Статусы поставок НА фулфилмент**
```prisma
enum SupplyOrderStatusV2 {
PENDING // Ожидает одобрения поставщика
SUPPLIER_APPROVED // Одобрено поставщиком
LOGISTICS_CONFIRMED // Логистика подтверждена
SHIPPED // Отгружено поставщиком
IN_TRANSIT // В пути
DELIVERED // Доставлено и принято
CANCELLED // Отменено
}
```
### **MarketplaceSupplyStatus - Статусы поставок НА маркетплейсы**
```prisma
enum MarketplaceSupplyStatus {
PLANNED // Запланирована
PREPARED // Подготовлена к отгрузке
SHIPPED_TO_MARKETPLACE // Отгружена на маркетплейс
ACCEPTED_BY_MARKETPLACE // Принята маркетплейсом
CANCELLED // Отменена
}
```
### **SellerConsumableAccessRights - Права доступа к расходникам селлеров**
```prisma
enum SellerConsumableAccessRights {
SELLER_ONLY // Только селлер может использовать
SHARED_WITH_FF // ФФ тоже может использовать (с разрешения)
}
```
---
## 🔄 ОБНОВЛЕНИЯ СУЩЕСТВУЮЩИХ ТАБЛИЦ
### **Product - Добавление новых связей**
```prisma
model Product {
// ... существующие поля остаются без изменений
// === НОВЫЕ СВЯЗИ С ПОСТАВКАМИ V2 ===
fulfillmentSupplyItems FulfillmentConsumableSupplyItem[] @relation("FFSupplyItems")
sellerSupplyItems SellerConsumableSupplyItem[] @relation("SellerSupplyItems")
goodsSupplyItems GoodsSupplyItem[] @relation("GoodsSupplyItems")
ozonSupplyItems OzonSupplyItem[] @relation("OzonSupplyItems")
wildberriesSupplyItems WildberriesSupplyItem[] @relation("WBSupplyItems")
// ... остальные поля остаются без изменений
}
```
### **Organization - Добавление новых связей**
```prisma
model Organization {
// ... существующие поля остаются без изменений
// === НОВЫЕ СВЯЗИ С ПОСТАВКАМИ V2 ===
// Поставки расходников ФФ
fulfillmentSupplyOrdersAsFulfillment FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersFulfillment")
fulfillmentSupplyOrdersAsSupplier FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersSupplier")
fulfillmentSupplyOrdersAsLogistics FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersLogistics")
// Поставки расходников селлеров
sellerSupplyOrdersAsSeller SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersSeller")
sellerSupplyOrdersAsFulfillment SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersFulfillment")
sellerSupplyOrdersAsSupplier SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersSupplier")
sellerSupplyOrdersAsLogistics SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersLogistics")
// Товарные поставки
goodsSupplyOrdersAsSeller GoodsSupplyOrder[] @relation("GoodsSupplyOrdersSeller")
goodsSupplyOrdersAsFulfillment GoodsSupplyOrder[] @relation("GoodsSupplyOrdersFulfillment")
goodsSupplyOrdersAsSupplier GoodsSupplyOrder[] @relation("GoodsSupplyOrdersSupplier")
goodsSupplyOrdersAsLogistics GoodsSupplyOrder[] @relation("GoodsSupplyOrdersLogistics")
// Поставки на маркетплейсы
ozonSupplyOrders OzonSupplyOrder[] @relation("OzonSupplyOrders")
wildberriesSupplyOrders WildberriesSupplyOrder[] @relation("WildberriesSupplyOrders")
// ... остальные поля остаются без изменений
}
```
### **User - Добавление новых связей**
```prisma
model User {
// ... существующие поля остаются без изменений
// === НОВЫЕ СВЯЗИ С ПРИЕМКОЙ ПОСТАВОК V2 ===
fulfillmentSupplyOrdersReceived FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersReceiver")
sellerSupplyOrdersReceived SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersReceiver")
goodsSupplyOrdersReceived GoodsSupplyOrder[] @relation("GoodsSupplyOrdersReceiver")
// ... остальные поля остаются без изменений
}
```
---
## 🔍 ИНДЕКСЫ ДЛЯ ПРОИЗВОДИТЕЛЬНОСТИ
```prisma
// Индексы для быстрого поиска поставок по статусу и организации
@@index([fulfillmentCenterId, status])
@@index([sellerId, status])
@@index([supplierId, status])
@@index([logisticsPartnerId, status])
// Индексы для поиска по датам
@@index([createdAt])
@@index([requestedDeliveryDate])
@@index([estimatedDeliveryDate])
// Индексы для отслеживания
@@index([trackingNumber])
@@index([supplierContractId])
// Маркетплейс-специфичные индексы
@@index([ozonSupplyId])
@@index([ozonProductId])
@@index([wbSupplyId])
@@index([wbNmId])
```
---
## 📋 ПЛАН МИГРАЦИИ
### **Phase 1: Создание новых таблиц**
```sql
-- Создание таблиц параллельно с существующими
CREATE TABLE fulfillment_consumable_supply_orders (...);
CREATE TABLE fulfillment_consumable_supply_items (...);
-- и т.д.
```
### **Phase 2: Новые enum значения**
```sql
-- Добавление новых enum без затрагивания старых
CREATE TYPE "SupplyOrderStatusV2" AS ENUM (...);
CREATE TYPE "MarketplaceSupplyStatus" AS ENUM (...);
```
### **Phase 3: Обновление связей (без удаления старых)**
```sql
-- Добавление новых foreign key связей
ALTER TABLE "Product" ADD COLUMN ...;
ALTER TABLE "Organization" ADD COLUMN ...;
```
### **Phase 4: Данные миграции (только с одобрения)**
```sql
-- Миграция данных из старых таблиц в новые
-- ТОЛЬКО после полного тестирования новой системы
```
---
## ⚠️ ВАЖНЫЕ ПРИНЦИПЫ
### ✅ **ОБРАТНАЯ СОВМЕСТИМОСТЬ**
- Все существующие таблицы остаются без изменений
- Новые таблицы создаются параллельно
- Старая система продолжает работать
### 🔒 **БЕЗОПАСНОСТЬ ДАННЫХ**
- Каждый тип поставки изолирован в отдельной таблице
- Связи защищены foreign key constraints
- Каскадное удаление только для зависимых записей
### 📈 **МАСШТАБИРУЕМОСТЬ**
- Легко добавлять новые типы поставок
- Оптимизированные индексы для каждого use case
- Независимые схемы для разных процессов
### 🛡️ **ЦЕЛОСТНОСТЬ ДАННЫХ**
- Строгие ограничения на связи между таблицами
- Валидация через enum значения
- Уникальные индексы предотвращают дублирование
**Следующий шаг:** Создание Prisma миграций для FulfillmentConsumableSupplyOrder

View File

@ -0,0 +1,817 @@
# 🛡️ ДЕТАЛЬНЫЙ БЕЗОПАСНЫЙ ПЛАН РЕАЛИЗАЦИИ СИСТЕМЫ ПОСТАВОК v2.0
> **🎯 ЦЕЛЬ:** Поэтапная миграция на новую архитектуру системы поставок без нарушения работы существующей системы
## 🔒 ПРИНЦИПЫ БЕЗОПАСНОЙ РЕАЛИЗАЦИИ
### ✅ **ЗОЛОТЫЕ ПРАВИЛА:**
1. **НИКОГДА НЕ ЛОМАТЬ СУЩЕСТВУЮЩИЙ ФУНКЦИОНАЛ** - старая система работает на 100%
2. **КАЖДЫЙ ЭТАП ИМЕЕТ ТОЧКУ ОТКАТА** - можно вернуться к предыдущему состоянию
3. **ТЕСТИРОВАНИЕ ПЕРЕД ПРОДАКШЕНОМ** - каждое изменение проверяется отдельно
4. **МОДУЛЬНОСТЬ** - изменения изолированы друг от друга
5. **ОДОБРЕНИЕ ПОЛЬЗОВАТЕЛЯ** - удаление старого кода ТОЛЬКО с разрешения
6. **МОНИТОРИНГ** - отслеживание работы на каждом этапе
### 🛠️ **ИНСТРУМЕНТЫ БЕЗОПАСНОСТИ:**
- **Feature Flags** - включение/отключение новой функциональности
- **Database Migrations** - обратимые изменения схемы БД
- **Parallel Testing** - новая система тестируется параллельно со старой
- **Gradual Rollout** - постепенное включение для пользователей
- **Automated Rollback** - автоматический откат при критических ошибках
---
## 📋 PHASE 1: FULFILLMENT CONSUMABLE SUPPLY ORDERS
> **Сроки:** 2-3 недели
> **Риск:** НИЗКИЙ (новая функциональность параллельно со старой)
### 🎯 **ЦЕЛЬ PHASE 1:**
Создать полностью рабочую систему для поставок расходников фулфилмента, которая работает параллельно со старой системой.
---
## 📊 STEP 1.1: ПОДГОТОВКА ИНФРАСТРУКТУРЫ
### **Задачи:**
1. ✅ Создание feature flag системы
2. ✅ Настройка параллельного тестирования
3. ✅ Подготовка инструментов мониторинга
### **1.1.1 Feature Flags**
```typescript
// /src/lib/featureFlags.ts
export const FEATURE_FLAGS = {
FULFILLMENT_CONSUMABLE_SUPPLY_V2: process.env.FULFILLMENT_CONSUMABLE_V2 === 'true',
ENABLE_PARALLEL_TESTING: process.env.PARALLEL_TESTING === 'true',
} as const
export function isFeatureEnabled(flag: keyof typeof FEATURE_FLAGS): boolean {
return FEATURE_FLAGS[flag] || false
}
```
### **1.1.2 Мониторинг**
```typescript
// /src/lib/monitoring/supplySystemMonitor.ts
export class SupplySystemMonitor {
static logMigrationEvent(event: string, data: any) {
console.log(`[SUPPLY_V2_MIGRATION] ${event}:`, data)
// Отправка в систему мониторинга
}
static logError(error: Error, context: string) {
console.error(`[SUPPLY_V2_ERROR] ${context}:`, error)
// Алерты в систему мониторинга
}
}
```
### **1.1.3 Тестовая база данных**
```bash
# Создание отдельной БД для тестирования миграций
npm run db:create:test-migration
npm run db:migrate:test
```
### **⚠️ ТОЧКА ПРОВЕРКИ 1.1:**
- [ ] Feature flags работают
- [ ] Мониторинг настроен
- [ ] Тестовая БД создана
- [ ] **ОТКАТ:** Отключить feature flags
**🔒 ROLLBACK PLAN 1.1:**
```bash
# Отключение feature flags
export FULFILLMENT_CONSUMABLE_V2=false
# Остановка мониторинга новых метрик
```
---
## 🗄️ STEP 1.2: СОЗДАНИЕ СХЕМЫ БАЗЫ ДАННЫХ
### **Задачи:**
1. ✅ Создание новых Prisma моделей
2. ✅ Генерация и проверка миграций
3. ✅ Применение миграций в test окружении
### **1.2.1 Prisma Schema Update**
```prisma
// Добавление в /prisma/schema.prisma
model FulfillmentConsumableSupplyOrder {
// ... полная схема из DATABASE_SCHEMA_V2.md
}
model FulfillmentConsumableSupplyItem {
// ... полная схема из DATABASE_SCHEMA_V2.md
}
enum SupplyOrderStatusV2 {
PENDING
SUPPLIER_APPROVED
LOGISTICS_CONFIRMED
SHIPPED
IN_TRANSIT
DELIVERED
CANCELLED
}
```
### **1.2.2 Создание миграции**
```bash
# Генерация миграции
npx prisma migrate dev --name "add_fulfillment_consumable_supply_v2"
# Проверка SQL миграции ПЕРЕД применением
cat prisma/migrations/xxx_add_fulfillment_consumable_supply_v2/migration.sql
```
### **1.2.3 Тестирование миграции**
```bash
# Применение в тестовой БД
npx prisma migrate deploy --schema=./prisma/schema.test.prisma
# Проверка создания таблиц
npx prisma studio --schema=./prisma/schema.test.prisma
```
### **⚠️ ТОЧКА ПРОВЕРКИ 1.2:**
- [ ] Миграция создана успешно
- [ ] SQL миграции проверен вручную
- [ ] Таблицы созданы в тестовой БД
- [ ] Все связи (FK) работают корректно
- [ ] **ОТКАТ:** Revert migration
**🔒 ROLLBACK PLAN 1.2:**
```bash
# Откат миграции
npx prisma migrate reset --force --skip-seed
# ИЛИ ручной DROP TABLE если нужно
```
---
## ⚡ STEP 1.3: GRAPHQL API
### **Задачи:**
1. ✅ Создание GraphQL типов
2. ✅ Создание resolvers с feature flag
3. ✅ Тестирование API отдельно
### **1.3.1 GraphQL Types**
```typescript
// /src/graphql/typedefs.ts - ДОБАВИТЬ К СУЩЕСТВУЮЩИМ
const fulfillmentConsumableTypes = `
type FulfillmentConsumableSupplyOrder {
id: ID!
status: SupplyOrderStatusV2!
fulfillmentCenterId: ID!
supplierId: ID
requestedDeliveryDate: DateTime!
resalePricePerUnit: Float
# ... все остальные поля
items: [FulfillmentConsumableSupplyItem!]!
}
type FulfillmentConsumableSupplyItem {
id: ID!
productId: ID!
requestedQuantity: Int!
unitPrice: Float!
totalPrice: Float!
product: Product!
}
enum SupplyOrderStatusV2 {
PENDING
SUPPLIER_APPROVED
LOGISTICS_CONFIRMED
SHIPPED
IN_TRANSIT
DELIVERED
CANCELLED
}
input CreateFulfillmentConsumableSupplyInput {
supplierId: ID!
requestedDeliveryDate: DateTime!
items: [FulfillmentConsumableSupplyItemInput!]!
notes: String
}
input FulfillmentConsumableSupplyItemInput {
productId: ID!
requestedQuantity: Int!
}
type CreateFulfillmentConsumableSupplyResult {
success: Boolean!
message: String!
supplyOrder: FulfillmentConsumableSupplyOrder
}
extend type Query {
# Новые запросы с feature flag
myFulfillmentConsumableSupplies: [FulfillmentConsumableSupplyOrder!]!
fulfillmentConsumableSupply(id: ID!): FulfillmentConsumableSupplyOrder
}
extend type Mutation {
# Новые мутации с feature flag
createFulfillmentConsumableSupply(
input: CreateFulfillmentConsumableSupplyInput!
): CreateFulfillmentConsumableSupplyResult!
}
`
```
### **1.3.2 Resolvers с Feature Flags**
```typescript
// /src/graphql/resolvers.ts - ДОБАВИТЬ К СУЩЕСТВУЮЩИМ
const fulfillmentConsumableResolvers = {
Query: {
myFulfillmentConsumableSupplies: async (_: unknown, __: unknown, context: Context) => {
// 🚨 FEATURE FLAG ПРОВЕРКА
if (!isFeatureEnabled('FULFILLMENT_CONSUMABLE_SUPPLY_V2')) {
throw new GraphQLError('Feature not enabled')
}
SupplySystemMonitor.logMigrationEvent('API_CALL', {
operation: 'myFulfillmentConsumableSupplies',
userId: context.user?.id
})
try {
// Полная реализация с системой безопасности
// ... код реализации
} catch (error) {
SupplySystemMonitor.logError(error, 'myFulfillmentConsumableSupplies')
throw error
}
},
fulfillmentConsumableSupply: async (_: unknown, args: { id: string }, context: Context) => {
if (!isFeatureEnabled('FULFILLMENT_CONSUMABLE_SUPPLY_V2')) {
throw new GraphQLError('Feature not enabled')
}
// ... реализация
}
},
Mutation: {
createFulfillmentConsumableSupply: async (_: unknown, args: any, context: Context) => {
if (!isFeatureEnabled('FULFILLMENT_CONSUMABLE_SUPPLY_V2')) {
return { success: false, message: 'Feature not enabled' }
}
SupplySystemMonitor.logMigrationEvent('CREATE_SUPPLY', {
supplierId: args.input.supplierId,
itemsCount: args.input.items.length,
userId: context.user?.id
})
try {
// Полная реализация создания поставки
// ... код реализации
return { success: true, message: 'Supply created', supplyOrder: result }
} catch (error) {
SupplySystemMonitor.logError(error, 'createFulfillmentConsumableSupply')
return { success: false, message: error.message }
}
}
}
}
```
### **1.3.3 API Testing**
```typescript
// /tests/api/fulfillmentConsumableSupply.test.ts
describe('FulfillmentConsumableSupply API', () => {
beforeAll(() => {
// Включаем feature flag для тестов
process.env.FULFILLMENT_CONSUMABLE_V2 = 'true'
})
test('should create supply order', async () => {
const mutation = `
mutation CreateFulfillmentConsumableSupply($input: CreateFulfillmentConsumableSupplyInput!) {
createFulfillmentConsumableSupply(input: $input) {
success
message
supplyOrder {
id
status
}
}
}
`
// ... тестирование
})
test('should not work when feature disabled', async () => {
process.env.FULFILLMENT_CONSUMABLE_V2 = 'false'
// Проверка что API недоступно
})
})
```
### **⚠️ ТОЧКА ПРОВЕРКИ 1.3:**
- [ ] GraphQL типы корректны
- [ ] Resolvers работают с feature flag
- [ ] API тесты проходят
- [ ] Feature flag блокирует доступ когда выключен
- [ ] **ОТКАТ:** Отключить feature flag
**🔒 ROLLBACK PLAN 1.3:**
```bash
# Отключение API через feature flag
export FULFILLMENT_CONSUMABLE_V2=false
# API станет недоступно мгновенно
```
---
## 🎨 STEP 1.4: FRONTEND ИНТЕРФЕЙС
### **Задачи:**
1. ✅ Создание формы создания поставки
2. ✅ Создание страницы просмотра поставок
3. ✅ Интеграция с существующим интерфейсом
### **1.4.1 Feature Flag в Frontend**
```typescript
// /src/lib/featureFlags.client.ts
export function useFeatureFlag(flag: string) {
return process.env.NEXT_PUBLIC_FULFILLMENT_CONSUMABLE_V2 === 'true'
}
```
### **1.4.2 Форма создания (с feature flag)**
```typescript
// /src/components/fulfillment-supplies/create-consumables-v2.tsx
export function CreateConsumablesV2Page() {
const featureEnabled = useFeatureFlag('FULFILLMENT_CONSUMABLE_V2')
if (!featureEnabled) {
// Показываем старую версию или заглушку
return <CreateConsumablesV1 />
}
// Новая реализация
return (
<div>
<h1>Создать поставку расходников ФФ (v2.0)</h1>
{/* Полная форма */}
</div>
)
}
```
### **1.4.3 Список поставок (с feature flag)**
```typescript
// /src/components/fulfillment-supplies/ff-consumables-v2.tsx
export function FFConsumablesV2Tab() {
const featureEnabled = useFeatureFlag('FULFILLMENT_CONSUMABLE_V2')
if (!featureEnabled) {
return <FFConsumablesV1Tab />
}
const { data, loading, error } = useQuery(GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES)
// Новая реализация отображения
}
```
### **1.4.4 Интеграция в роутинг**
```typescript
// /src/app/fulfillment-supplies/ff-consumables/page.tsx
export default function FFConsumablesPage() {
const featureEnabled = useFeatureFlag('FULFILLMENT_CONSUMABLE_V2')
return featureEnabled ? <FFConsumablesV2Tab /> : <FFConsumablesV1Tab />
}
```
### **⚠️ ТОЧКА ПРОВЕРКИ 1.4:**
- [ ] Форма создания работает
- [ ] Список поставок отображается
- [ ] Feature flag переключает версии
- [ ] Нет поломок в старом интерфейсе
- [ ] **ОТКАТ:** Отключить frontend feature flag
**🔒 ROLLBACK PLAN 1.4:**
```bash
# Отключение новых компонентов
export NEXT_PUBLIC_FULFILLMENT_CONSUMABLE_V2=false
# Пользователи увидят старый интерфейс
```
---
## 🔗 STEP 1.5: ИНТЕГРАЦИЯ И END-TO-END ТЕСТИРОВАНИЕ
### **Задачи:**
1. ✅ Полное тестирование workflow поставки
2. ✅ Тестирование безопасности доступа
3. ✅ Нагрузочное тестирование
4. ✅ Проверка совместимости со старой системой
### **1.5.1 End-to-End тесты**
```typescript
// /tests/e2e/fulfillmentConsumableSupply.e2e.ts
describe('Fulfillment Consumable Supply E2E', () => {
test('Complete supply workflow', async () => {
// 1. ФФ создает поставку
// 2. Поставщик одобряет
// 3. Логистика назначается
// 4. Отгрузка
// 5. Приемка
// Проверяем каждый этап
})
test('Security access control', async () => {
// Проверяем что селлеры не видят поставки ФФ
// Проверяем фильтрацию данных по ролям
})
})
```
### **1.5.2 Параллельное тестирование**
```typescript
// /tests/parallel/oldVsNewSystem.test.ts
describe('Old vs New System Compatibility', () => {
test('Both systems work independently', async () => {
// Создаем поставку в старой системе
// Создаем поставку в новой системе
// Проверяем что они не мешают друг другу
})
})
```
### **1.5.3 Performance Testing**
```bash
# Нагрузочное тестирование API
npm run test:load -- --scenario=fulfillment-consumable-v2
```
### **⚠️ ТОЧКА ПРОВЕРКИ 1.5:**
- [ ] E2E тесты проходят
- [ ] Система безопасности работает
- [ ] Производительность приемлемая
- [ ] Старая система не затронута
- [ ] **ОТКАТ:** Полное отключение feature flags
**🔒 ROLLBACK PLAN 1.5:**
```bash
# Полное отключение новой системы
export FULFILLMENT_CONSUMABLE_V2=false
export NEXT_PUBLIC_FULFILLMENT_CONSUMABLE_V2=false
npm run restart
```
---
## 🚀 STEP 1.6: PRODUCTION DEPLOYMENT
### **Задачи:**
1. ✅ Постепенное включение для пользователей
2. ✅ Мониторинг в реальном времени
3. ✅ Готовность к быстрому откату
### **1.6.1 Gradual Rollout**
```typescript
// Включение для 10% пользователей
export function shouldUseV2ForUser(userId: string): boolean {
if (!isFeatureEnabled('FULFILLMENT_CONSUMABLE_V2')) return false
// Хэш от userId, включаем для 10%
const hash = hashCode(userId)
return (hash % 10) === 0
}
```
### **1.6.2 Real-time мониторинг**
```typescript
// Метрики в реальном времени
export const supplyV2Metrics = {
createSuccess: 0,
createError: 0,
querySuccess: 0,
queryError: 0,
rollbackTriggered: false
}
// Автоматический откат при критических ошибках
if (supplyV2Metrics.createError > 10) {
// Экстренное отключение
process.env.FULFILLMENT_CONSUMABLE_V2 = 'false'
supplyV2Metrics.rollbackTriggered = true
}
```
### **1.6.3 Production Checklist**
```markdown
## PRODUCTION DEPLOYMENT CHECKLIST
- [ ] Database migration applied successfully
- [ ] Feature flags configured
- [ ] Monitoring dashboards active
- [ ] Rollback procedures tested
- [ ] Support team notified
- [ ] Documentation updated
- [ ] Gradual rollout configured (10% users)
```
### **⚠️ ТОЧКА ПРОВЕРКИ 1.6:**
- [ ] Deployment прошел успешно
- [ ] 10% пользователей используют новую систему
- [ ] Метрики показывают стабильность
- [ ] Нет критических ошибок
- [ ] **ОТКАТ:** Экстренное отключение
**🔒 ROLLBACK PLAN 1.6:**
```bash
# ЭКСТРЕННЫЙ ОТКАТ
kubectl set env deployment/app FULFILLMENT_CONSUMABLE_V2=false
# ИЛИ через admin panel
curl -X POST /admin/feature-flags/disable/FULFILLMENT_CONSUMABLE_V2
```
---
## 📊 STEP 1.7: МОНИТОРИНГ И СТАБИЛИЗАЦИЯ
### **Задачи:**
1. ✅ Анализ метрик производительности
2.Сбор фидбека пользователей
3. ✅ Исправление найденных проблем
4. ✅ Подготовка к Phase 2
### **1.7.1 Мониторинг (1-2 недели)**
```typescript
// Ключевые метрики для отслеживания
const metricsToWatch = {
// Функциональные
supplyCreationRate: 'число создаваемых поставок/день',
successRate: 'процент успешных операций',
averageProcessingTime: 'среднее время обработки',
// Технические
databasePerformance: 'время ответа БД',
apiLatency: 'задержка API',
errorRate: 'процент ошибок',
// Бизнесовые
userAdoption: 'процент пользователей использующих v2',
userSatisfaction: 'оценка пользователей',
supportTickets: 'количество тикетов поддержки'
}
```
### **1.7.2 Feedback Collection**
```typescript
// Встроенная система сбора фидбека
export function FeedbackWidget() {
return (
<div className="feedback-widget">
<p>Как вам новая система поставок?</p>
<button onClick={() => sendFeedback('positive')}>👍</button>
<button onClick={() => sendFeedback('negative')}>👎</button>
</div>
)
}
```
### **1.7.3 Анализ и улучшения**
```markdown
## КРИТЕРИИ УСПЕХА PHASE 1:
✅ Успешность операций > 98%
✅ Время отклика < 2 сек
Пользовательская оценка > 4/5
✅ Количество багов < 5 критических
Готовность инфраструктуры к Phase 2
```
### **⚠️ ТОЧКА ПРОВЕРКИ 1.7:**
- [ ] Метрики в пределах нормы
- [ ] Пользователи довольны
- [ ] Критические баги исправлены
- [ ] Система готова к расширению
- [ ] **РЕШЕНИЕ:** Переход к Phase 2 или доработка
---
## 🎯 КРИТЕРИИ ГОТОВНОСТИ К PHASE 2
### ✅ **ОБЯЗАТЕЛЬНЫЕ УСЛОВИЯ:**
1. **Функциональность:** Все features Phase 1 работают стабильно
2. **Производительность:** Нет деградации по сравнению со старой системой
3. **Безопасность:** Нет утечек данных, доступ контролируется корректно
4. **Пользователи:** Положительные отзывы от 80%+ пользователей
5. **Мониторинг:** Налаженная система отслеживания метрик
### 📈 **ЧИСЛЕННЫЕ ПОКАЗАТЕЛИ:**
- Успешность создания поставок: **> 98%**
- Время загрузки списка поставок: **< 2 сек**
- Время создания поставки: **< 5 сек**
- Uptime системы: **> 99.5%**
- Количество критических багов: **= 0**
---
## 📋 PHASE 2: SELLER CONSUMABLE SUPPLY ORDERS
> **Сроки:** 2-3 недели после стабилизации Phase 1
> **Риск:** СРЕДНИЙ (более сложная логика с хранением)
### **Ключевые особенности Phase 2:**
1. **Мульти-организационность:** Селлер создает, ФФ хранит
2. **Права доступа:** Сложная система доступа к данным
3. **Сроки хранения:** Временные ограничения на хранение
4. **Интеграция с Phase 1:** Должна работать вместе с поставками ФФ
### **Plan Phase 2:**
- Step 2.1: Database Schema для селлерских расходников
- Step 2.2: GraphQL API с правами доступа
- Step 2.3: Frontend для селлеров и ФФ
- Step 2.4: Интеграция с системой хранения
- Step 2.5: Тестирование совместимости с Phase 1
---
## 📋 PHASE 3: GOODS SUPPLY ORDERS
> **Сроки:** 3-4 недели после стабилизации Phase 2
> **Риск:** ВЫСОКИЙ (самая сложная система с рецептурами)
### **Ключевые особенности Phase 3:**
1. **Рецептуры:** Сложная JSON структура с услугами
2. **Мульти-компонентность:** Товары + услуги + расходники
3. **Marketplace интеграция:** Связь с карточками товаров
4. **Миграция данных:** Перенос существующих товарных поставок
---
## 📋 PHASE 4: ОЧИСТКА И ОПТИМИЗАЦИЯ
> **Сроки:** 1-2 недели
> **Риск:** НИЗКИЙ (только после полного одобрения)
### **Задачи Phase 4:**
1. **Миграция старых данных** (ТОЛЬКО с одобрения)
2. **Удаление устаревшего кода** (ТОЛЬКО с одобрения)
3. **Оптимизация производительности**
4. **Финальное тестирование**
---
## 🚨 EMERGENCY PROCEDURES
### **🔴 CRITICAL ROLLBACK (при критических ошибках)**
```bash
#!/bin/bash
# emergency-rollback.sh
echo "🚨 EMERGENCY ROLLBACK INITIATED"
# Отключение всех feature flags
export FULFILLMENT_CONSUMABLE_V2=false
export SELLER_CONSUMABLE_V2=false
export GOODS_SUPPLY_V2=false
export NEXT_PUBLIC_FULFILLMENT_CONSUMABLE_V2=false
# Перезапуск сервисов
kubectl rollout restart deployment/app
kubectl rollout restart deployment/api
# Уведомления
curl -X POST "$SLACK_WEBHOOK" -d '{"text": "🚨 Supply System V2 Emergency Rollback Executed"}'
echo "✅ Rollback completed. System reverted to V1."
```
### **🟡 PARTIAL ROLLBACK (при локальных проблемах)**
```bash
# Откат конкретной фазы
rollback_phase_1() {
export FULFILLMENT_CONSUMABLE_V2=false
kubectl set env deployment/app FULFILLMENT_CONSUMABLE_V2=false
}
rollback_phase_2() {
export SELLER_CONSUMABLE_V2=false
kubectl set env deployment/app SELLER_CONSUMABLE_V2=false
}
```
### **📊 MONITORING ALERTS**
```typescript
// Автоматические алерты
const ALERT_THRESHOLDS = {
ERROR_RATE: 5, // > 5% ошибок = алерт
RESPONSE_TIME: 5000, // > 5 сек = алерт
FAILED_CREATES: 10, // > 10 неудачных создания = алерт
DATABASE_ERRORS: 3 // > 3 ошибки БД = критический алерт
}
// Автоматическое отключение при превышении порогов
if (currentMetrics.errorRate > ALERT_THRESHOLDS.ERROR_RATE) {
triggerEmergencyRollback('HIGH_ERROR_RATE')
}
```
---
## 📈 SUCCESS METRICS
### **📊 KPI для каждой фазы:**
#### **Phase 1 Success Criteria:**
- ✅ 100% функций поставок расходников ФФ работают
- ✅ 0 критических багов в production
-< 2 сек время отклика API
- > 90% положительных отзывов пользователей
- ✅ 0 инцидентов безопасности
#### **Phase 2 Success Criteria:**
- ✅ Поставки расходников селлеров работают корректно
- ✅ Права доступа работают (селлер видит свое, ФФ видит ограниченно)
- ✅ Интеграция с Phase 1 без конфликтов
#### **Phase 3 Success Criteria:**
- ✅ Товарные поставки с рецептурами работают
- ✅ Миграция существующих данных (если одобрена)
- ✅ Интеграция с маркетплейсами
#### **Overall Success Criteria:**
-**Производительность:** Не хуже старой системы
-**Стабильность:** 99.9% uptime
-**Безопасность:** 0 утечек данных
-**Пользователи:** > 85% satisfaction rate
-**Бизнес:** Увеличение эффективности процессов поставок
---
## ⏰ TIMELINE
```mermaid
gantt
title Supply System V2 Implementation Timeline
dateFormat YYYY-MM-DD
section Phase 1
Infrastructure Setup :2024-08-25, 3d
Database Schema :2024-08-28, 2d
GraphQL API :2024-08-30, 4d
Frontend Interface :2024-09-03, 4d
Integration Testing :2024-09-07, 3d
Production Deployment :2024-09-10, 2d
Monitoring & Stabilization :2024-09-12, 7d
section Phase 2
Seller Consumables Schema :2024-09-19, 3d
Complex Access Control :2024-09-22, 5d
Multi-org Frontend :2024-09-27, 4d
Integration Testing :2024-10-01, 4d
section Phase 3
Goods Supply Schema :2024-10-05, 4d
Recipe System :2024-10-09, 6d
Data Migration Prep :2024-10-15, 3d
Testing & Validation :2024-10-18, 5d
section Phase 4
Data Migration :2024-10-23, 3d
Code Cleanup :2024-10-26, 2d
Final Testing :2024-10-28, 2d
```
### **🎯 MILESTONE DATES:**
- **Phase 1 Complete:** September 19, 2024
- **Phase 2 Complete:** October 4, 2024
- **Phase 3 Complete:** October 23, 2024
- **Full Migration Complete:** October 30, 2024
---
## ✅ ЗАКЛЮЧЕНИЕ
Данный план обеспечивает:
### 🛡️ **МАКСИМАЛЬНУЮ БЕЗОПАСНОСТЬ:**
- Поэтапное внедрение без риска для работающей системы
- Множественные точки отката на каждом этапе
- Тщательное тестирование перед каждым шагом
### 📈 **КОНТРОЛИРУЕМЫЙ РОСТ:**
- Постепенное увеличение сложности (ФФ → Селлер → Товары)
- Feature flags для контроля доступа
- Мониторинг метрик на каждом этапе
### 🔧 **ГИБКОСТЬ РЕАЛИЗАЦИИ:**
- Возможность приостановить на любом этапе
- Адаптация планов по результатам предыдущих фаз
- Учет пожеланий пользователей
**Система готова к безопасной поэтапной реализации!**

View File

@ -24,6 +24,9 @@ model User {
smsCodes SmsCode[]
organization Organization? @relation(fields: [organizationId], references: [id])
// === НОВЫЕ СВЯЗИ С ПРИЕМКОЙ ПОСТАВОК V2 ===
fulfillmentSupplyOrdersReceived FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersReceiver")
@@map("users")
}
@ -121,6 +124,12 @@ model Organization {
wbWarehouseCaches WBWarehouseCache[] @relation("WBWarehouseCaches")
wildberriesSupplies WildberriesSupply[]
// === НОВЫЕ СВЯЗИ С ПОСТАВКАМИ V2 ===
// Поставки расходников ФФ
fulfillmentSupplyOrdersAsFulfillment FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersFulfillment")
fulfillmentSupplyOrdersAsSupplier FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersSupplier")
fulfillmentSupplyOrdersAsLogistics FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersLogistics")
@@index([referralCode])
@@index([referredById])
@@map("organizations")
@ -284,6 +293,9 @@ model Product {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
supplyOrderItems SupplyOrderItem[]
// === НОВЫЕ СВЯЗИ С ПОСТАВКАМИ V2 ===
fulfillmentSupplyItems FulfillmentConsumableSupplyItem[] @relation("FFSupplyItems")
@@unique([organizationId, article])
@@map("products")
}
@ -732,3 +744,96 @@ enum SecurityAlertSeverity {
HIGH
CRITICAL
}
// ===============================================
// НОВАЯ СИСТЕМА ПОСТАВОК V2.0
// ===============================================
// Новый enum для статусов поставок v2
enum SupplyOrderStatusV2 {
PENDING // Ожидает одобрения поставщика
SUPPLIER_APPROVED // Одобрено поставщиком
LOGISTICS_CONFIRMED // Логистика подтверждена
SHIPPED // Отгружено поставщиком
IN_TRANSIT // В пути
DELIVERED // Доставлено и принято
CANCELLED // Отменено
}
// Модель для поставок расходников фулфилмента
model FulfillmentConsumableSupplyOrder {
// === БАЗОВЫЕ ПОЛЯ ===
id String @id @default(cuid())
status SupplyOrderStatusV2 @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === ДАННЫЕ ФФ (создатель) ===
fulfillmentCenterId String // кто заказывает (FK: Organization)
requestedDeliveryDate DateTime // когда нужно
resalePricePerUnit Decimal? @db.Decimal(10, 2) // цена продажи селлерам
minStockLevel Int? // минимальный остаток
notes String? // заметки ФФ
// === ДАННЫЕ ПОСТАВЩИКА ===
supplierId String? // кто поставляет (FK: Organization)
supplierApprovedAt DateTime? // когда одобрил
packagesCount Int? // количество грузомест
estimatedVolume Decimal? @db.Decimal(8, 3) // объем груза в м³
supplierContractId String? // номер договора
supplierNotes String? // заметки поставщика
// === ДАННЫЕ ЛОГИСТИКИ ===
logisticsPartnerId String? // кто везет (FK: Organization)
estimatedDeliveryDate DateTime? // план доставки
routeId String? // маршрут (FK: LogisticsRoute)
logisticsCost Decimal? @db.Decimal(10, 2) // стоимость доставки
logisticsNotes String? // заметки логистики
// === ДАННЫЕ ОТГРУЗКИ ===
shippedAt DateTime? // факт отгрузки
trackingNumber String? // номер отслеживания
// === ДАННЫЕ ПРИЕМКИ ===
receivedAt DateTime? // факт приемки
receivedById String? // кто принял (FK: User)
actualQuantity Int? // принято количество
defectQuantity Int? // брак
receiptNotes String? // заметки приемки
// === СВЯЗИ ===
fulfillmentCenter Organization @relation("FFSupplyOrdersFulfillment", fields: [fulfillmentCenterId], references: [id])
supplier Organization? @relation("FFSupplyOrdersSupplier", fields: [supplierId], references: [id])
logisticsPartner Organization? @relation("FFSupplyOrdersLogistics", fields: [logisticsPartnerId], references: [id])
receivedBy User? @relation("FFSupplyOrdersReceiver", fields: [receivedById], references: [id])
items FulfillmentConsumableSupplyItem[]
@@map("fulfillment_consumable_supply_orders")
}
model FulfillmentConsumableSupplyItem {
id String @id @default(cuid())
supplyOrderId String // связь с поставкой
productId String // какой расходник (FK: Product)
// === КОЛИЧЕСТВА ===
requestedQuantity Int // запросили
approvedQuantity Int? // поставщик одобрил
shippedQuantity Int? // отгрузили
receivedQuantity Int? // приняли
defectQuantity Int? @default(0) // брак
// === ЦЕНЫ ===
unitPrice Decimal @db.Decimal(10, 2) // цена за единицу от поставщика
totalPrice Decimal @db.Decimal(12, 2) // общая стоимость
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// === СВЯЗИ ===
supplyOrder FulfillmentConsumableSupplyOrder @relation(fields: [supplyOrderId], references: [id], onDelete: Cascade)
product Product @relation("FFSupplyItems", fields: [productId], references: [id])
@@unique([supplyOrderId, productId])
@@map("fulfillment_consumable_supply_items")
}

View File

@ -0,0 +1,5 @@
import CreateFulfillmentConsumablesSupplyV2Page from '@/components/fulfillment-supplies/create-fulfillment-consumables-supply-v2'
export default function Page() {
return <CreateFulfillmentConsumablesSupplyV2Page />
}

View File

@ -0,0 +1,9 @@
import { FulfillmentConsumablesOrdersTab } from '@/components/fulfillment-supplies/fulfillment-supplies/fulfillment-consumables-orders-tab'
export default function ConsumablesPage() {
return (
<div className="h-full overflow-hidden">
<FulfillmentConsumablesOrdersTab />
</div>
)
}

View File

@ -0,0 +1,9 @@
import { FulfillmentDetailedSuppliesTab } from '@/components/fulfillment-supplies/fulfillment-supplies/fulfillment-detailed-supplies-tab'
export default function DetailedSuppliesPage() {
return (
<div className="h-full overflow-hidden">
<FulfillmentDetailedSuppliesTab />
</div>
)
}

View File

@ -0,0 +1,10 @@
import { FulfillmentGoodsOrdersTab } from '@/components/fulfillment-supplies/fulfillment-supplies/fulfillment-goods-orders-tab'
export default function GoodsNewPage() {
return (
<div className="h-full overflow-hidden">
<h3 className="text-white font-semibold mb-4">Новые товары</h3>
<FulfillmentGoodsOrdersTab />
</div>
)
}

View File

@ -0,0 +1,8 @@
export default function GoodsReceivedPage() {
return (
<div className="h-full overflow-hidden">
<h3 className="text-white font-semibold mb-4">Принятые товары</h3>
<div className="text-white/80">Здесь отображаются принятые на склад товары</div>
</div>
)
}

View File

@ -0,0 +1,8 @@
export default function GoodsReceivingPage() {
return (
<div className="h-full overflow-hidden">
<h3 className="text-white font-semibold mb-4">Товары в приёмке</h3>
<div className="text-white/80">Здесь отображаются товары в процессе приёмки на склад фулфилмента</div>
</div>
)
}

View File

@ -0,0 +1,16 @@
import { AuthGuard } from '@/components/auth-guard'
import { FulfillmentSuppliesLayout } from '@/components/fulfillment-supplies/fulfillment-supplies-layout'
export default function FulfillmentSuppliesLayoutPage({
children,
}: {
children: React.ReactNode
}) {
return (
<AuthGuard>
<FulfillmentSuppliesLayout>
{children}
</FulfillmentSuppliesLayout>
</AuthGuard>
)
}

View File

@ -1,10 +1,6 @@
import { AuthGuard } from '@/components/auth-guard'
import { FulfillmentSuppliesDashboard } from '@/components/fulfillment-supplies/fulfillment-supplies-dashboard'
import { redirect } from 'next/navigation'
export default function FulfillmentSuppliesPage() {
return (
<AuthGuard>
<FulfillmentSuppliesDashboard />
</AuthGuard>
)
// Редирект на дефолтный таб - товары новые
redirect('/fulfillment-supplies/goods/new')
}

View File

@ -0,0 +1,9 @@
import { PvzReturnsTab } from '@/components/fulfillment-supplies/fulfillment-supplies/pvz-returns-tab'
export default function ReturnsPage() {
return (
<div className="h-full overflow-hidden">
<PvzReturnsTab />
</div>
)
}

View File

@ -0,0 +1,5 @@
import CreateFulfillmentConsumablesSupplyV2Page from '@/components/fulfillment-supplies/create-fulfillment-consumables-supply-v2'
export default function Page() {
return <CreateFulfillmentConsumablesSupplyV2Page />
}

View File

@ -218,7 +218,7 @@ export function Sidebar({ isRootInstance = false }: { isRootInstance?: boolean }
// Для каждого типа кабинета свой роут
switch (user?.organization?.type) {
case 'FULFILLMENT':
router.push('/fulfillment-supplies')
router.push('/fulfillment-supplies/goods/new')
break
case 'SELLER':
router.push('/supplies')

View File

@ -52,6 +52,8 @@ interface FulfillmentConsumableProduct {
}
stock?: number
unit?: string
quantity?: number
ordered?: number
}
interface SelectedFulfillmentConsumable {

View File

@ -0,0 +1,821 @@
'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_FULFILLMENT_CONSUMABLE_SUPPLY, GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES } from '@/graphql/queries/fulfillment-consumables-v2'
import {
GET_MY_COUNTERPARTIES,
GET_ORGANIZATION_PRODUCTS,
} 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 default function CreateFulfillmentConsumablesSupplyV2Page() {
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 [notes, setNotes] = 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)
},
})
// Мутация для создания заказа поставки расходников v2
const [createSupply] = useMutation(CREATE_FULFILLMENT_CONSUMABLE_SUPPLY)
// Фильтруем только поставщиков расходников (поставщиков)
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(`❌ Недостаточно остатков!\оступно: ${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) {
toast.error('Заполните все обязательные поля: поставщик, расходники и дата доставки')
return
}
setIsCreatingSupply(true)
try {
// Новый формат для системы v2
const input = {
supplierId: selectedSupplier.id,
requestedDeliveryDate: deliveryDate,
items: selectedConsumables.map((consumable) => ({
productId: consumable.id,
requestedQuantity: consumable.selectedQuantity,
})),
notes: notes || undefined,
}
console.warn('🚀 СОЗДАНИЕ ПОСТАВКИ v2 - INPUT:', input)
const result = await createSupply({
variables: { input },
refetchQueries: [
{ query: GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES }, // Обновляем новый v2 запрос
],
})
console.warn('🎯 РЕЗУЛЬТАТ СОЗДАНИЯ ПОСТАВКИ v2:', result)
if (result.data?.createFulfillmentConsumableSupply?.success) {
toast.success('Поставка расходников создана успешно!')
// Очищаем форму
setSelectedSupplier(null)
setSelectedLogistics(null)
setSelectedConsumables([])
setDeliveryDate('')
setProductSearchQuery('')
setSearchQuery('')
setNotes('')
// Перенаправляем на страницу детальных поставок
router.push('/fulfillment-supplies/detailed-supplies')
} else {
toast.error(result.data?.createFulfillmentConsumableSupply?.message || 'Ошибка при создании поставки')
}
} catch (error) {
console.error('Error creating fulfillment consumables supply v2:', 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/detailed-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="mb-3">
<label className="text-white/60 text-xs mb-1 block">Заметки (необязательно):</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Дополнительная информация о поставке"
rows={3}
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm placeholder-white/40 focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent resize-none"
/>
</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
}
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>
)
}

View File

@ -0,0 +1,308 @@
'use client'
import { useMutation, useQuery } from '@apollo/client'
import { Calendar, Plus, Trash2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import React, { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { CREATE_FULFILLMENT_CONSUMABLE_SUPPLY } from '@/graphql/queries/fulfillment-consumables-v2'
import { GET_MY_COUNTERPARTIES, GET_ORGANIZATION_PRODUCTS } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
interface Product {
id: string
name: string
article: string
price: number
quantity: number
type: string
}
interface Organization {
id: string
name: string
inn: string
type: string
}
interface SupplyItem {
productId: string
requestedQuantity: number
product?: Product
}
export default function CreateFulfillmentConsumablesSupplyV2Page() {
const router = useRouter()
const { user } = useAuth()
const [selectedSupplierId, setSelectedSupplierId] = useState('')
const [requestedDeliveryDate, setRequestedDeliveryDate] = useState('')
const [notes, setNotes] = useState('')
const [items, setItems] = useState<SupplyItem[]>([])
// Получаем список контрагентов-поставщиков
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery<{
myCounterparties: Organization[]
}>(GET_MY_COUNTERPARTIES)
// Получаем товары выбранного поставщика
const { data: productsData, loading: productsLoading } = useQuery<{
organizationProducts: Product[]
}>(GET_ORGANIZATION_PRODUCTS, {
variables: { organizationId: selectedSupplierId, type: 'CONSUMABLE' },
skip: !selectedSupplierId,
})
const [createSupply, { loading: creating }] = useMutation(CREATE_FULFILLMENT_CONSUMABLE_SUPPLY, {
onCompleted: (data) => {
if (data.createFulfillmentConsumableSupply.success) {
toast.success('Поставка успешно создана')
router.push('/fulfillment-supplies')
} else {
toast.error(data.createFulfillmentConsumableSupply.message)
}
},
onError: (error) => {
toast.error(error.message)
},
})
const suppliers = counterpartiesData?.myCounterparties.filter(
(org) => org.type === 'WHOLESALE'
) || []
const consumableProducts = productsData?.organizationProducts || []
const addItem = () => {
setItems([...items, { productId: '', requestedQuantity: 1 }])
}
const removeItem = (index: number) => {
setItems(items.filter((_, i) => i !== index))
}
const updateItem = (index: number, field: keyof SupplyItem, value: string | number) => {
const newItems = [...items]
if (field === 'productId') {
const product = consumableProducts.find(p => p.id === value)
newItems[index] = { ...newItems[index], [field]: value, product }
} else {
newItems[index] = { ...newItems[index], [field]: value }
}
setItems(newItems)
}
const handleSubmit = () => {
// Валидация
if (!selectedSupplierId) {
toast.error('Выберите поставщика')
return
}
if (!requestedDeliveryDate) {
toast.error('Укажите желаемую дату доставки')
return
}
if (items.length === 0) {
toast.error('Добавьте хотя бы один товар')
return
}
const invalidItems = items.filter(item => !item.productId || item.requestedQuantity <= 0)
if (invalidItems.length > 0) {
toast.error('Заполните все товары корректно')
return
}
// Создаем поставку
createSupply({
variables: {
input: {
supplierId: selectedSupplierId,
requestedDeliveryDate,
items: items.map(item => ({
productId: item.productId,
requestedQuantity: item.requestedQuantity,
})),
notes: notes || undefined,
},
},
})
}
const totalAmount = items.reduce((sum, item) => {
if (item.product) {
return sum + (item.product.price * item.requestedQuantity)
}
return sum
}, 0)
return (
<div className="container mx-auto p-6 max-w-4xl">
<div className="mb-6">
<h1 className="text-3xl font-bold text-white">Создать поставку расходников ФФ (v2)</h1>
<p className="text-white/70 mt-2">Новая система поставок</p>
</div>
<Card className="p-6 bg-white/10 backdrop-blur-xl border border-white/20">
<div className="space-y-6">
{/* Выбор поставщика */}
<div>
<Label htmlFor="supplier" className="text-white font-medium">Поставщик</Label>
<Select
value={selectedSupplierId}
onValueChange={setSelectedSupplierId}
>
<SelectTrigger>
<SelectValue placeholder="Выберите поставщика" />
</SelectTrigger>
<SelectContent>
{counterpartiesLoading ? (
<SelectItem value="loading" disabled>Загрузка...</SelectItem>
) : suppliers.length === 0 ? (
<SelectItem value="empty" disabled>Нет доступных поставщиков</SelectItem>
) : (
suppliers.map((supplier) => (
<SelectItem key={supplier.id} value={supplier.id}>
{supplier.name} (ИНН: {supplier.inn})
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* Дата доставки */}
<div>
<Label htmlFor="deliveryDate" className="text-white font-medium">Желаемая дата доставки</Label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="deliveryDate"
type="date"
value={requestedDeliveryDate}
onChange={(e) => setRequestedDeliveryDate(e.target.value)}
className="pl-10"
min={new Date().toISOString().split('T')[0]}
/>
</div>
</div>
{/* Товары */}
<div>
<div className="flex items-center justify-between mb-4">
<Label className="text-white font-medium">Товары</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
disabled={!selectedSupplierId || productsLoading}
>
<Plus className="h-4 w-4 mr-1" />
Добавить товар
</Button>
</div>
{items.length === 0 ? (
<div className="text-center py-8 text-white/60 border-2 border-dashed border-white/20 rounded-lg">
{selectedSupplierId ? 'Нажмите "Добавить товар" для начала' : 'Сначала выберите поставщика'}
</div>
) : (
<div className="space-y-3">
{items.map((item, index) => (
<div key={index} className="flex gap-3 items-end">
<div className="flex-1">
<Label className="text-white font-medium">Товар</Label>
<Select
value={item.productId}
onValueChange={(value) => updateItem(index, 'productId', value)}
>
<SelectTrigger>
<SelectValue placeholder="Выберите товар" />
</SelectTrigger>
<SelectContent>
{consumableProducts.map((product) => (
<SelectItem key={product.id} value={product.id}>
{product.name} ({product.article}) - {product.price} ₽
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-32">
<Label className="text-white font-medium">Количество</Label>
<Input
type="number"
min="1"
value={item.requestedQuantity}
onChange={(e) => updateItem(index, 'requestedQuantity', parseInt(e.target.value) || 0)}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeItem(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Заметки */}
<div>
<Label htmlFor="notes" className="text-white font-medium">Заметки (необязательно)</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Дополнительная информация о поставке"
rows={3}
/>
</div>
{/* Итого */}
{items.length > 0 && (
<div className="bg-white/10 rounded-lg p-4 border border-white/20">
<div className="flex justify-between items-center">
<span className="text-lg font-medium text-white">Итого:</span>
<span className="text-2xl font-bold text-white">{totalAmount.toLocaleString('ru-RU')} ₽</span>
</div>
</div>
)}
{/* Кнопки */}
<div className="flex gap-3 justify-end">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
Отмена
</Button>
<Button
onClick={handleSubmit}
disabled={creating || items.length === 0}
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white"
>
{creating ? 'Создание...' : 'Создать поставку'}
</Button>
</div>
</div>
</Card>
</div>
)
}

View File

@ -103,7 +103,7 @@ export function FulfillmentSuppliesDashboard() {
{/* УРОВЕНЬ 2: Подтабы */}
{activeTab === 'fulfillment' && (
<div className="ml-4 mb-3">
<div className="grid w-full grid-cols-4 bg-white/8 backdrop-blur border-white/20 h-9 rounded-lg p-1">
<div className="grid w-full grid-cols-5 bg-white/8 backdrop-blur border-white/20 h-9 rounded-lg p-1">
<button
onClick={() => setActiveSubTab('goods')}
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
@ -156,6 +156,18 @@ export function FulfillmentSuppliesDashboard() {
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
<span className="sm:hidden">В</span>
</button>
<button
onClick={() => setActiveSubTab('consumables-v2')}
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
activeSubTab === 'consumables-v2'
? 'bg-white/15 text-white border-white/20'
: 'text-white/60 hover:text-white/80'
}`}
>
<Building2 className="h-3 w-3" />
<span className="hidden sm:inline">ФФ v2</span>
<span className="sm:hidden">V2</span>
</button>
</div>
</div>
)}
@ -369,6 +381,13 @@ export function FulfillmentSuppliesDashboard() {
</div>
)}
{/* КОНТЕНТ ДЛЯ НОВОЙ СИСТЕМЫ ПОСТАВОК V2 */}
{activeTab === 'fulfillment' && activeSubTab === 'consumables-v2' && (
<div className="h-full overflow-hidden">
<div className="text-white/80">Контент V2 системы (удален)</div>
</div>
)}
{/* КОНТЕНТ ДЛЯ МАРКЕТПЛЕЙСОВ */}
{activeTab === 'marketplace' && (
<div className="text-white/80">Содержимое поставок на маркетплейсы</div>

View File

@ -0,0 +1,309 @@
'use client'
import { useQuery } from '@apollo/client'
import { Building2, ShoppingCart, Package, Wrench, RotateCcw, Clock, FileText, CheckCircle, ChevronRight, Home } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import React from 'react'
import { Sidebar } from '@/components/dashboard/sidebar'
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
import { useRealtime } from '@/hooks/useRealtime'
import { useSidebar } from '@/hooks/useSidebar'
// Компонент для отображения бейджа с уведомлениями
function NotificationBadge({ count }: { count: number }) {
if (count === 0) return null
return (
<div className="ml-1 bg-red-500 text-white text-xs font-bold rounded-full min-w-[16px] h-4 flex items-center justify-center px-1">
{count > 99 ? '99+' : count}
</div>
)
}
// Breadcrumbs компонент
function Breadcrumbs({ pathname }: { pathname: string }) {
const getBreadcrumbs = (path: string) => {
const segments = path.split('/').filter(Boolean)
const breadcrumbs = [
{ name: 'Главная', href: '/dashboard', icon: Home }
]
if (segments[0] === 'fulfillment-supplies') {
breadcrumbs.push({ name: 'Входящие поставки', href: '/fulfillment-supplies', icon: Building2 })
if (segments[1]) {
const categoryMap: Record<string, string> = {
'goods': 'Товары',
'detailed-supplies': 'Расходники фулфилмента',
'consumables': 'Расходники селлеров',
'returns': 'Возвраты с ПВЗ'
}
const category = categoryMap[segments[1]]
if (category) {
breadcrumbs.push({
name: category,
href: segments[2] ? `/fulfillment-supplies/${segments[1]}` : path,
icon: Package
})
if (segments[1] === 'goods' && segments[2]) {
const subcategoryMap: Record<string, string> = {
'new': 'Новые',
'receiving': 'Приёмка',
'received': 'Принято'
}
const subcategory = subcategoryMap[segments[2]]
if (subcategory) {
breadcrumbs.push({ name: subcategory, href: path, icon: Clock })
}
}
}
}
}
return breadcrumbs
}
const breadcrumbs = getBreadcrumbs(pathname)
return (
<nav className="flex items-center space-x-2 text-sm text-white/60 mb-4">
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.href}>
{index > 0 && <ChevronRight className="h-4 w-4" />}
{index === breadcrumbs.length - 1 ? (
<span className="text-white font-medium flex items-center gap-1">
{breadcrumb.icon && <breadcrumb.icon className="h-4 w-4" />}
{breadcrumb.name}
</span>
) : (
<Link
href={breadcrumb.href}
className="hover:text-white transition-colors flex items-center gap-1"
>
{breadcrumb.icon && <breadcrumb.icon className="h-4 w-4" />}
{breadcrumb.name}
</Link>
)}
</React.Fragment>
))}
</nav>
)
}
export function FulfillmentSuppliesLayout({ children }: { children: React.ReactNode }) {
const { getSidebarMargin } = useSidebar()
const pathname = usePathname()
// Загружаем данные о непринятых поставках
const { data: pendingData, error: pendingError, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
fetchPolicy: 'cache-first',
errorPolicy: 'ignore',
onError: (error) => {
console.error('❌ GET_PENDING_SUPPLIES_COUNT Error:', error)
},
})
// Realtime: обновление бейджа
useRealtime({
onEvent: (evt) => {
if (evt.type === 'supply-order:new' || evt.type === 'supply-order:updated') {
refetchPending()
}
},
})
// Логируем ошибку для диагностики
React.useEffect(() => {
if (pendingError) {
console.error('🚨 Ошибка загрузки счетчиков поставок:', pendingError)
}
}, [pendingError])
// Для фулфилмента считаем только поставки, НЕ заявки на партнерство
const pendingCount = pendingData?.pendingSuppliesCount?.supplyOrders || 0
const ourSupplyOrdersCount = pendingData?.pendingSuppliesCount?.ourSupplyOrders || 0
const sellerSupplyOrdersCount = pendingData?.pendingSuppliesCount?.sellerSupplyOrders || 0
// Определяем активные табы на основе pathname
const getActiveTab = () => {
if (pathname.startsWith('/fulfillment-supplies/goods')) return 'fulfillment'
if (pathname.includes('/detailed-supplies')) return 'fulfillment'
if (pathname.includes('/consumables')) return 'fulfillment'
if (pathname.includes('/returns')) return 'fulfillment'
return 'fulfillment'
}
const getActiveSubTab = () => {
if (pathname.startsWith('/fulfillment-supplies/goods')) return 'goods'
if (pathname.includes('/detailed-supplies')) return 'detailed-supplies'
if (pathname.includes('/consumables')) return 'consumables'
if (pathname.includes('/returns')) return 'returns'
return 'goods'
}
const getActiveThirdTab = () => {
if (pathname.includes('/goods/new')) return 'new'
if (pathname.includes('/goods/receiving')) return 'receiving'
if (pathname.includes('/goods/received')) return 'received'
return 'new'
}
const activeTab = getActiveTab()
const activeSubTab = getActiveSubTab()
const activeThirdTab = getActiveThirdTab()
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
<div className="h-full w-full flex flex-col space-y-4">
{/* Breadcrumbs */}
<Breadcrumbs pathname={pathname} />
{/* БЛОК 1: ТАБЫ ВСЕХ УРОВНЕЙ */}
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6">
{/* УРОВЕНЬ 1: Главные табы */}
<div className="mb-4">
<div className="grid w-full grid-cols-2 bg-white/15 backdrop-blur border-white/30 rounded-xl h-11 p-2">
<Link
href="/fulfillment-supplies/goods/new"
className={`flex items-center gap-2 text-sm font-semibold transition-all duration-200 rounded-lg px-3 ${
activeTab === 'fulfillment'
? 'bg-gradient-to-r from-purple-500/40 to-pink-500/40 text-white shadow-lg'
: 'text-white/80 hover:text-white'
}`}
>
<Building2 className="h-4 w-4" />
<span className="hidden sm:inline">Поставки на фулфилмент</span>
<span className="sm:hidden">Фулфилмент</span>
<NotificationBadge count={pendingCount} />
</Link>
<button
className="flex items-center gap-2 text-sm font-semibold transition-all duration-200 rounded-lg px-3 text-white/40 cursor-not-allowed"
disabled
>
<ShoppingCart className="h-4 w-4" />
<span className="hidden sm:inline">Поставки на маркетплейсы</span>
<span className="sm:hidden">Маркетплейсы</span>
</button>
</div>
</div>
{/* УРОВЕНЬ 2: Подтабы */}
{activeTab === 'fulfillment' && (
<div className="ml-4 mb-3">
<div className="grid w-full grid-cols-4 bg-white/8 backdrop-blur border-white/20 h-9 rounded-lg p-1">
<Link
href="/fulfillment-supplies/goods/new"
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
activeSubTab === 'goods'
? 'bg-white/15 text-white border-white/20'
: 'text-white/60 hover:text-white/80'
}`}
>
<Package className="h-3 w-3" />
<span className="hidden sm:inline">Товар</span>
<span className="sm:hidden">Т</span>
</Link>
<Link
href="/fulfillment-supplies/detailed-supplies"
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 relative ${
activeSubTab === 'detailed-supplies'
? 'bg-white/15 text-white border-white/20'
: 'text-white/60 hover:text-white/80'
}`}
>
<Building2 className="h-3 w-3" />
<span className="hidden md:inline">Расходники фулфилмента</span>
<span className="md:hidden hidden sm:inline">Фулфилмент</span>
<span className="sm:hidden">Ф</span>
<NotificationBadge count={ourSupplyOrdersCount} />
</Link>
<Link
href="/fulfillment-supplies/consumables"
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 relative ${
activeSubTab === 'consumables'
? 'bg-white/15 text-white border-white/20'
: 'text-white/60 hover:text-white/80'
}`}
>
<Wrench className="h-3 w-3" />
<span className="hidden md:inline">Расходники селлеров</span>
<span className="md:hidden hidden sm:inline">Селлеры</span>
<span className="sm:hidden">С</span>
<NotificationBadge count={sellerSupplyOrdersCount} />
</Link>
<Link
href="/fulfillment-supplies/returns"
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
activeSubTab === 'returns'
? 'bg-white/15 text-white border-white/20'
: 'text-white/60 hover:text-white/80'
}`}
>
<RotateCcw className="h-3 w-3" />
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
<span className="sm:hidden">В</span>
</Link>
</div>
</div>
)}
{/* УРОВЕНЬ 3: Подподтабы для товаров */}
{activeTab === 'fulfillment' && activeSubTab === 'goods' && (
<div className="ml-8">
<div className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border-white/15 h-8 rounded-md p-1">
<Link
href="/fulfillment-supplies/goods/new"
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
activeThirdTab === 'new' ? 'bg-white/10 text-white' : 'text-white/50 hover:text-white/70'
}`}
>
<Clock className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Новые</span>
<span className="sm:hidden">Н</span>
</Link>
<Link
href="/fulfillment-supplies/goods/receiving"
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
activeThirdTab === 'receiving' ? 'bg-white/10 text-white' : 'text-white/50 hover:text-white/70'
}`}
>
<FileText className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Приёмка</span>
<span className="sm:hidden">П</span>
</Link>
<Link
href="/fulfillment-supplies/goods/received"
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
activeThirdTab === 'received' ? 'bg-white/10 text-white' : 'text-white/50 hover:text-white/70'
}`}
>
<CheckCircle className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Принято</span>
<span className="sm:hidden">Пр</span>
</Link>
</div>
</div>
)}
</div>
{/* БЛОК 2: ОСНОВНОЙ КОНТЕНТ */}
<div className="flex-1 overflow-hidden">
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl h-full overflow-hidden p-6">
<div className="h-full">
{children}
</div>
</div>
</div>
</div>
</main>
</div>
)
}

View File

@ -207,34 +207,34 @@ export function FulfillmentConsumablesOrdersTab() {
// Получаем данные заказов поставок
const supplyOrders: SupplyOrder[] = data?.supplyOrders || []
// Фильтруем заказы для фулфилмента (ТОЛЬКО расходники селлеров)
// Фильтруем заказы для фулфилмента (ТОЛЬКО расходники фулфилмента)
const fulfillmentOrders = supplyOrders.filter((order) => {
// Показываем только заказы где текущий фулфилмент-центр является получателем
// Показываем только заказы созданные САМИМ фулфилментом для своих расходников
const isCreatedBySelf = order.organization?.id === user?.organization?.id
// И получатель тоже мы (фулфилмент заказывает расходники для себя)
const isRecipient = order.fulfillmentCenter?.id === user?.organization?.id
// НО создатель заказа НЕ мы (т.е. селлер создал заказ для нас)
const isCreatedByOther = order.organization?.id !== user?.organization?.id
// И статус не PENDING и не CANCELLED (одобренные поставщиком заявки)
const isApproved = order.status !== 'CANCELLED' && order.status !== 'PENDING'
// ✅ КРИТИЧНОЕ ИСПРАВЛЕНИЕ: Показывать только расходники селлеров (НЕ товары)
const isSellerConsumables = order.consumableType === 'SELLER_CONSUMABLES'
// ✅ КРИТИЧНОЕ ИСПРАВЛЕНИЕ: Показывать только расходники ФУЛФИЛМЕНТА (НЕ селлеров и НЕ товары)
const isFulfillmentConsumables = order.consumableType === 'FULFILLMENT_CONSUMABLES'
// Проверяем, что это НЕ товары (товары содержат услуги в рецептуре)
const hasServices = order.items?.some(item => item.recipe?.services && item.recipe.services.length > 0)
const isConsumablesOnly = isSellerConsumables && !hasServices
const isConsumablesOnly = isFulfillmentConsumables && !hasServices
console.warn('🔍 Фильтрация расходников селлера:', {
console.warn('🔍 Фильтрация расходников фулфилмента:', {
orderId: order.id.slice(-8),
isRecipient,
isCreatedByOther,
isCreatedBySelf,
isApproved,
isSellerConsumables,
isFulfillmentConsumables,
hasServices,
isConsumablesOnly,
consumableType: order.consumableType,
itemsWithServices: order.items?.filter(item => item.recipe?.services && item.recipe.services.length > 0).length || 0,
finalResult: isRecipient && isCreatedByOther && isApproved && isConsumablesOnly,
finalResult: isRecipient && isCreatedBySelf && isApproved && isConsumablesOnly,
})
return isRecipient && isCreatedByOther && isApproved && isConsumablesOnly
return isRecipient && isCreatedBySelf && isApproved && isConsumablesOnly
})
// Генерируем порядковые номера для заказов

View File

@ -1,120 +1,87 @@
'use client'
import { useQuery, useMutation } from '@apollo/client'
import { TrendingUp, Wrench, Plus, Package2, Calendar } from 'lucide-react'
import { useQuery } from '@apollo/client'
import { TrendingUp, Wrench, Plus, Package2, Clock, CheckCircle, XCircle, Truck } from 'lucide-react'
import { useRouter } from 'next/navigation'
import React from 'react'
import { toast } from 'sonner'
import React, { useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { FULFILLMENT_RECEIVE_ORDER } from '@/graphql/mutations'
import { GET_MY_SUPPLY_ORDERS, GET_MY_SUPPLIES, GET_WAREHOUSE_PRODUCTS } from '@/graphql/queries'
import { GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES } from '@/graphql/queries/fulfillment-consumables-v2'
import { useAuth } from '@/hooks/useAuth'
import { MultiLevelSuppliesTable } from '../../supplies/multilevel-supplies-table'
import { StatsCard } from '../../supplies/ui/stats-card'
import { StatsGrid } from '../../supplies/ui/stats-grid'
// Интерфейс для заказа (совместимый с SupplyOrderFromGraphQL)
interface SupplyOrder {
// Интерфейс для новой системы поставок v2
interface FulfillmentConsumableSupply {
id: string
organizationId: string
partnerId: string
deliveryDate: string
createdAt: string
updatedAt: string
totalItems: number
totalAmount: number
status: string
status: 'PENDING' | 'SUPPLIER_APPROVED' | 'LOGISTICS_CONFIRMED' | 'SHIPPED' | 'IN_TRANSIT' | 'DELIVERED' | 'CANCELLED'
fulfillmentCenterId: string
logisticsPartnerId?: string
packagesCount?: number
volume?: number
responsibleEmployee?: string
notes?: string
number?: number // Порядковый номер
organization: {
fulfillmentCenter: {
id: string
name?: string
fullName?: string
type: string
market?: string
}
partner: {
id: string
name?: string
fullName?: string
name: string
inn: string
address?: string
addressFull?: string
market?: string
phones?: Array<{ value: string }>
emails?: Array<{ value: string }>
type: string
}
fulfillmentCenter?: {
supplierId?: string
supplier?: {
id: string
name?: string
fullName?: string
address?: string
addressFull?: string
type: string
name: string
inn: string
}
requestedDeliveryDate: string
resalePricePerUnit?: number
minStockLevel?: number
notes?: string
supplierApprovedAt?: string
packagesCount?: number
estimatedVolume?: number
supplierContractId?: string
supplierNotes?: string
logisticsPartnerId?: string
logisticsPartner?: {
id: string
name?: string
fullName?: string
type: string
name: string
inn: string
}
routes: Array<{
estimatedDeliveryDate?: string
routeId?: string
logisticsCost?: number
logisticsNotes?: string
shippedAt?: string
trackingNumber?: string
receivedAt?: string
receivedById?: string
receivedBy?: {
id: string
fromLocation: string
toLocation: string
fromAddress?: string
toAddress?: string
distance?: number
estimatedTime?: number
price?: number
status?: string
createdDate: string
}>
managerName: string
phone: string
}
actualQuantity?: number
defectQuantity?: number
receiptNotes?: string
items: Array<{
id: string
productId: string
quantity: number
price: number
totalPrice: number
product: {
id: string
name: string
article: string
description?: string
category?: {
id: string
name: string
}
}
recipe?: {
services?: Array<{
id: string
name: string
price: number
}>
fulfillmentConsumables?: Array<{
id: string
name: string
price: number
}>
sellerConsumables?: Array<{
id: string
name: string
price: number
}>
marketplaceCardId?: string
price: number
quantity: number
mainImage?: string
}
requestedQuantity: number
approvedQuantity?: number
shippedQuantity?: number
receivedQuantity?: number
defectQuantity?: number
unitPrice: number
totalPrice: number
}>
createdAt: string
updatedAt: string
}
// Функция для форматирования валюты
@ -126,146 +93,110 @@ const formatCurrency = (amount: number) => {
}).format(amount)
}
// Функция для форматирования даты
const _formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU')
}
// Функция для отображения статуса
const _getStatusBadge = (status: string) => {
// Функция для отображения статуса v2
const getStatusBadge = (status: string) => {
const statusConfig = {
PENDING: {
label: 'Ожидает одобрения поставщика',
color: 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30',
icon: Clock,
},
SUPPLIER_APPROVED: {
label: 'Ожидает подтверждения логистики',
label: 'Одобрено поставщиком',
color: 'bg-blue-500/20 text-blue-300 border-blue-500/30',
icon: CheckCircle,
},
LOGISTICS_CONFIRMED: {
label: 'Ожидает отправки поставщиком',
label: 'Логистика подтверждена',
color: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30',
icon: Truck,
},
SHIPPED: {
label: 'Отправлено',
color: 'bg-orange-500/20 text-orange-300 border-orange-500/30',
icon: Truck,
},
IN_TRANSIT: {
label: 'В пути',
color: 'bg-orange-500/20 text-orange-300 border-orange-500/30',
icon: Truck,
},
DELIVERED: {
label: 'Доставлено',
color: 'bg-green-500/20 text-green-300 border-green-500/30',
icon: CheckCircle,
},
CANCELLED: {
label: 'Отменено',
color: 'bg-red-500/20 text-red-300 border-red-500/30',
},
// Устаревшие статусы для обратной совместимости
CONFIRMED: {
label: 'Подтверждён (устаревший)',
color: 'bg-blue-500/20 text-blue-300 border-blue-500/30',
},
IN_TRANSIT: {
label: 'В пути (устаревший)',
color: 'bg-orange-500/20 text-orange-300 border-orange-500/30',
icon: XCircle,
},
}
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.PENDING
const IconComponent = config.icon
return <Badge className={config.color}>{config.label}</Badge>
return (
<Badge className={`${config.color} flex items-center gap-1`}>
<IconComponent className="h-3 w-3" />
{config.label}
</Badge>
)
}
// Функция для форматирования даты
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
export function FulfillmentDetailedSuppliesTab() {
const router = useRouter()
const { user } = useAuth()
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(new Set())
// Убираем устаревшую мутацию updateSupplyOrderStatus
const [fulfillmentReceiveOrder] = useMutation(FULFILLMENT_RECEIVE_ORDER, {
refetchQueries: [{ query: GET_MY_SUPPLY_ORDERS }, { query: GET_MY_SUPPLIES }, { query: GET_WAREHOUSE_PRODUCTS }],
onCompleted: (data) => {
if (data.fulfillmentReceiveOrder.success) {
toast.success(data.fulfillmentReceiveOrder.message)
} else {
toast.error(data.fulfillmentReceiveOrder.message)
}
},
onError: (error) => {
console.error('Error receiving supply order:', error)
toast.error('Ошибка при приеме заказа поставки')
},
})
// Загружаем реальные данные заказов расходников с многоуровневой структурой
const { data, loading, error } = useQuery(GET_MY_SUPPLY_ORDERS, {
fetchPolicy: 'cache-and-network', // Принудительно проверяем сервер
// Загружаем поставки расходников через новый API v2
const { data, loading, error, refetch } = useQuery(GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES, {
fetchPolicy: 'cache-and-network',
notifyOnNetworkStatusChange: true,
errorPolicy: 'all',
onError: (error) => {
console.error('❌ GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES Error:', error)
},
})
// Получаем ID текущей организации (фулфилмент-центра)
const currentOrganizationId = user?.organization?.id
// Получаем поставки из нового API
const supplies: FulfillmentConsumableSupply[] = data?.myFulfillmentConsumableSupplies || []
// Получаем поставки с многоуровневой структурой для фулфилмента
// Фильтруем поставки где мы являемся получателем (фулфилмент-центром)
// И это расходники фулфилмента (FULFILLMENT_CONSUMABLES)
const ourSupplyOrders: SupplyOrder[] = (data?.mySupplyOrders || []).filter((order: SupplyOrder) => {
// Проверяем что order существует и имеет нужные поля
if (!order || !order.fulfillmentCenterId) return false
// Фильтруем только расходники фулфилмента
const isFulfillmentConsumables = (order as any).consumableType === 'FULFILLMENT_CONSUMABLES'
const isOurFulfillmentCenter = order.fulfillmentCenterId === currentOrganizationId
console.warn('🔍 Фильтрация расходников фулфилмента:', {
orderId: order.id?.slice(-8),
consumableType: (order as any).consumableType,
isFulfillmentConsumables,
isOurFulfillmentCenter,
result: isFulfillmentConsumables && isOurFulfillmentCenter,
})
return isFulfillmentConsumables && isOurFulfillmentCenter
})
// Обработчик действий фулфилмента для многоуровневой таблицы
const handleFulfillmentAction = async (supplyId: string, action: string) => {
try {
switch (action) {
case 'accept':
// Принять поставку от поставщика (переход из SUPPLIER_APPROVED в CONFIRMED)
await fulfillmentReceiveOrder({ variables: { id: supplyId } })
break
case 'cancel':
// Отменить поставку (если разрешено)
console.warn('Отмена поставки:', supplyId)
toast.info('Функция отмены поставки в разработке')
break
default:
console.warn('Неизвестное действие фулфилмента:', action, supplyId)
}
} catch (error) {
console.error('Ошибка при выполнении действия фулфилмента:', error)
toast.error('Ошибка при выполнении действия')
// Функция для переключения развернутого состояния поставки
const toggleExpanded = (supplyId: string) => {
const newExpanded = new Set(expandedSupplies)
if (newExpanded.has(supplyId)) {
newExpanded.delete(supplyId)
} else {
newExpanded.add(supplyId)
}
setExpandedSupplies(newExpanded)
}
// Функция для приема заказа фулфилментом
const _handleReceiveOrder = async (orderId: string) => {
try {
await fulfillmentReceiveOrder({
variables: { id: orderId },
})
} catch (error) {
console.error('Error receiving order:', error)
}
}
// Убираем устаревшие функции проверки статусов
// Вычисляем статистику
const totalSupplies = supplies.length
const totalAmount = supplies.reduce((sum, supply) => {
return sum + supply.items.reduce((itemSum, item) => itemSum + item.totalPrice, 0)
}, 0)
const totalItems = supplies.reduce((sum, supply) => {
return sum + supply.items.reduce((itemSum, item) => itemSum + item.requestedQuantity, 0)
}, 0)
const deliveredCount = supplies.filter(supply => supply.status === 'DELIVERED').length
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
<span className="ml-3 text-white/60">Загрузка наших расходников...</span>
<span className="ml-3 text-white/60">Загрузка расходников фулфилмента...</span>
</div>
)
}
@ -277,6 +208,13 @@ export function FulfillmentDetailedSuppliesTab() {
<Wrench className="h-12 w-12 text-red-400 mx-auto mb-4" />
<p className="text-red-400 font-medium">Ошибка загрузки расходников</p>
<p className="text-white/60 text-sm mt-2">{error.message}</p>
<Button
onClick={() => refetch()}
variant="outline"
className="mt-4 border-white/20 text-white/80 hover:bg-white/10"
>
Повторить попытку
</Button>
</div>
</div>
)
@ -288,10 +226,10 @@ export function FulfillmentDetailedSuppliesTab() {
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white mb-1">Расходники фулфилмента</h2>
<p className="text-white/60 text-sm">Поставки расходников, поступающие на склад фулфилмент-центра</p>
<p className="text-white/60 text-sm">Поставки расходников для вашего фулфилмент-центра (система v2)</p>
</div>
<Button
onClick={() => router.push('/fulfillment-supplies/create-consumables')}
onClick={() => router.push('/supplies/create-fulfillment-consumables-v2')}
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white shadow-lg"
>
<Plus className="h-4 w-4 mr-2" />
@ -302,8 +240,8 @@ export function FulfillmentDetailedSuppliesTab() {
{/* Статистика наших расходников */}
<StatsGrid>
<StatsCard
title="Наши поставки"
value={ourSupplyOrders.length}
title="Всего поставок"
value={totalSupplies}
icon={Package2}
iconColor="text-orange-400"
iconBg="bg-orange-500/20"
@ -312,9 +250,7 @@ export function FulfillmentDetailedSuppliesTab() {
<StatsCard
title="Общая сумма"
value={formatCurrency(
ourSupplyOrders.reduce((sum: number, order: SupplyOrder) => sum + (order.totalAmount || 0), 0),
)}
value={formatCurrency(totalAmount)}
icon={TrendingUp}
iconColor="text-green-400"
iconBg="bg-green-500/20"
@ -323,7 +259,7 @@ export function FulfillmentDetailedSuppliesTab() {
<StatsCard
title="Всего единиц"
value={ourSupplyOrders.reduce((sum: number, order: SupplyOrder) => sum + (order.totalItems || 0), 0)}
value={totalItems}
icon={Wrench}
iconColor="text-blue-400"
iconBg="bg-blue-500/20"
@ -331,36 +267,135 @@ export function FulfillmentDetailedSuppliesTab() {
/>
<StatsCard
title="Завершено"
value={ourSupplyOrders.filter((order: SupplyOrder) => order.status === 'DELIVERED').length}
icon={Calendar}
title="Доставлено"
value={deliveredCount}
icon={CheckCircle}
iconColor="text-purple-400"
iconBg="bg-purple-500/20"
subtitle="Доставленные поставки"
subtitle="Завершенные поставки"
/>
</StatsGrid>
{/* Многоуровневая таблица поставок для фулфилмента */}
{ourSupplyOrders.length === 0 ? (
{/* Список поставок */}
{supplies.length === 0 ? (
<Card className="bg-white/10 backdrop-blur border-white/20 p-8">
<div className="text-center">
<Wrench className="h-16 w-16 text-white/20 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Пока нет поставок расходников</h3>
<p className="text-white/60">
Здесь будут отображаться поставки расходников, поступающие на ваш склад. Создайте заказ через кнопку
&quot;Создать поставку&quot; или ожидайте поставки от партнеров.
Здесь будут отображаться поставки расходников для вашего фулфилмент-центра.
Создайте новую поставку через кнопку &quot;Создать поставку&quot;.
</p>
</div>
</Card>
) : (
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden p-6">
<MultiLevelSuppliesTable
supplies={ourSupplyOrders as any}
userRole="FULFILLMENT"
onSupplyAction={handleFulfillmentAction}
loading={loading}
/>
</Card>
<div className="space-y-4">
{supplies.map((supply) => (
<Card key={supply.id} className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
{/* Основная информация о поставке */}
<div
className="p-6 cursor-pointer hover:bg-white/5 transition-colors"
onClick={() => toggleExpanded(supply.id)}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-4 mb-2">
<h3 className="text-lg font-semibold text-white">
Поставка #{supply.id.slice(-8)}
</h3>
{getStatusBadge(supply.status)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-white/60">Поставщик</p>
<p className="text-white font-medium">{supply.supplier?.name || 'Не указан'}</p>
</div>
<div>
<p className="text-white/60">Дата поставки</p>
<p className="text-white">{formatDate(supply.requestedDeliveryDate)}</p>
</div>
<div>
<p className="text-white/60">Товаров</p>
<p className="text-white">{supply.items.length}</p>
</div>
<div>
<p className="text-white/60">Сумма</p>
<p className="text-white font-medium">
{formatCurrency(supply.items.reduce((sum, item) => sum + item.totalPrice, 0))}
</p>
</div>
</div>
</div>
<div className="ml-4">
<Clock className={`h-5 w-5 transition-transform ${
expandedSupplies.has(supply.id) ? 'rotate-180' : ''
} text-white/60`} />
</div>
</div>
</div>
{/* Развернутая информация */}
{expandedSupplies.has(supply.id) && (
<div className="border-t border-white/20 p-6 bg-white/5">
{/* Товары в поставке */}
<div className="mb-6">
<h4 className="text-white font-semibold mb-4">Товары в поставке</h4>
<div className="space-y-3">
{supply.items.map((item) => (
<div key={item.id} className="flex items-center gap-4 p-4 bg-white/5 rounded-lg">
{item.product.mainImage && (
<img
src={item.product.mainImage}
alt={item.product.name}
className="h-12 w-12 object-cover rounded-lg"
/>
)}
<div className="flex-1">
<h5 className="text-white font-medium">{item.product.name}</h5>
<p className="text-white/60 text-sm">Артикул: {item.product.article}</p>
</div>
<div className="text-right">
<p className="text-white">Количество: {item.requestedQuantity}</p>
<p className="text-white/60 text-sm">
{formatCurrency(item.unitPrice)} × {item.requestedQuantity} = {formatCurrency(item.totalPrice)}
</p>
</div>
</div>
))}
</div>
</div>
{/* Дополнительная информация */}
{(supply.notes || supply.supplierNotes || supply.logisticsNotes) && (
<div>
<h4 className="text-white font-semibold mb-4">Дополнительная информация</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{supply.notes && (
<div className="p-4 bg-white/5 rounded-lg">
<h5 className="text-white/80 font-medium mb-2">Заметки ФФ</h5>
<p className="text-white/60 text-sm">{supply.notes}</p>
</div>
)}
{supply.supplierNotes && (
<div className="p-4 bg-white/5 rounded-lg">
<h5 className="text-white/80 font-medium mb-2">Заметки поставщика</h5>
<p className="text-white/60 text-sm">{supply.supplierNotes}</p>
</div>
)}
{supply.logisticsNotes && (
<div className="p-4 bg-white/5 rounded-lg">
<h5 className="text-white/80 font-medium mb-2">Заметки логистики</h5>
<p className="text-white/60 text-sm">{supply.logisticsNotes}</p>
</div>
)}
</div>
</div>
)}
</div>
)}
</Card>
))}
</div>
)}
</div>
)

View File

@ -9,6 +9,7 @@ import {
XCircle,
Hash,
Settings,
Truck,
} from 'lucide-react'
import React, { useState } from 'react'
import { toast } from 'sonner'

View File

@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { SUPPLIER_APPROVE_ORDER, SUPPLIER_REJECT_ORDER, SUPPLIER_SHIP_ORDER, UPDATE_SUPPLY_PARAMETERS } from '@/graphql/mutations'
import { GET_MY_SUPPLY_ORDERS } from '@/graphql/queries'
import { GET_MY_SUPPLIER_CONSUMABLE_SUPPLIES } from '@/graphql/queries/fulfillment-consumables-v2'
import { useAuth } from '@/hooks/useAuth'
@ -126,6 +127,11 @@ export function SupplierOrdersTabs() {
fetchPolicy: 'cache-and-network',
})
// Загружаем новые заявки v2 на расходники фулфилмента
const { data: v2Data, loading: v2Loading, error: v2Error } = useQuery(GET_MY_SUPPLIER_CONSUMABLE_SUPPLIES, {
fetchPolicy: 'cache-and-network',
})
// Мутации для действий поставщика
const [supplierApproveOrder] = useMutation(SUPPLIER_APPROVE_ORDER, {
refetchQueries: [{ query: GET_MY_SUPPLY_ORDERS }],
@ -438,6 +444,19 @@ export function SupplierOrdersTabs() {
<Badge className="ml-2 bg-white/20 text-white/70 border-white/30">{getTabBadgeCount('all')}</Badge>
)}
</TabsTrigger>
<TabsTrigger
value="consumables-v2"
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-4 data-[state=active]:bg-white/15 data-[state=active]:text-white"
>
<Package className="h-4 w-4 mr-2" />
Расходники v2
{v2Data?.mySupplierConsumableSupplies.length > 0 && (
<Badge className="ml-2 bg-blue-500/20 text-blue-300 border-blue-400/30">
{v2Data.mySupplierConsumableSupplies.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
@ -452,30 +471,79 @@ export function SupplierOrdersTabs() {
onDateFilterChange={setDateFilter}
/>
{/* Многоуровневая таблица поставок для поставщика */}
{/* Отображение контента */}
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl">
<div className="p-6">
{getCurrentOrders().length === 0 ? (
<div className="text-center py-12">
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">
{activeTab === 'new' ? 'Нет новых заявок' : 'Заявки не найдены'}
</h3>
<p className="text-white/60">
{activeTab === 'new'
? 'Новые заявки от заказчиков будут отображаться здесь'
: 'Попробуйте изменить фильтры поиска'}
</p>
</div>
{activeTab === 'consumables-v2' ? (
// Отображение новых заявок v2
v2Data?.mySupplierConsumableSupplies.length === 0 ? (
<div className="text-center py-12">
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">
Нет заявок на расходники v2
</h3>
<p className="text-white/60">
Заявки на расходники от фулфилмент-центров будут отображаться здесь
</p>
</div>
) : (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white mb-4">
Заявки на расходники v2 ({v2Data?.mySupplierConsumableSupplies.length || 0})
</h3>
{v2Data?.mySupplierConsumableSupplies.map((supply: any) => (
<div key={supply.id} className="bg-white/5 rounded-lg p-4 border border-white/10">
<div className="flex justify-between items-start mb-2">
<div>
<h4 className="font-medium text-white">
Заявка #{supply.id.slice(-8)}
</h4>
<p className="text-white/60 text-sm">
От: {supply.fulfillmentCenter.name}
</p>
</div>
<Badge className={
supply.status === 'PENDING' ? 'bg-yellow-100 text-yellow-800' :
supply.status === 'SUPPLIER_APPROVED' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
}>
{supply.status === 'PENDING' ? 'Ожидает одобрения' :
supply.status === 'SUPPLIER_APPROVED' ? 'Одобрено' : supply.status}
</Badge>
</div>
<div className="text-white/80 text-sm">
<p>Дата доставки: {new Date(supply.requestedDeliveryDate).toLocaleDateString('ru-RU')}</p>
<p>Товаров: {supply.items.length}</p>
{supply.notes && <p>Заметки: {supply.notes}</p>}
</div>
</div>
))}
</div>
)
) : (
<MultiLevelSuppliesTable
supplies={getCurrentOrders()}
userRole="WHOLESALE"
activeTab={activeTab}
onSupplyAction={handleSupplierAction}
onVolumeChange={handleVolumeChange}
onPackagesChange={handlePackagesChange}
/>
// Обычные заявки (существующая логика)
getCurrentOrders().length === 0 ? (
<div className="text-center py-12">
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">
{activeTab === 'new' ? 'Нет новых заявок' : 'Заявки не найдены'}
</h3>
<p className="text-white/60">
{activeTab === 'new'
? 'Новые заявки от заказчиков будут отображаться здесь'
: 'Попробуйте изменить фильтры поиска'}
</p>
</div>
) : (
<MultiLevelSuppliesTable
supplies={getCurrentOrders()}
userRole="WHOLESALE"
activeTab={activeTab}
onSupplyAction={handleSupplierAction}
onVolumeChange={handleVolumeChange}
onPackagesChange={handlePackagesChange}
/>
)
)}
</div>
</div>

View File

@ -738,8 +738,13 @@ export function MultiLevelSuppliesTable({
)}
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Цена товаров</TableHead>
{/* 🆕 НОВЫЕ КОЛОНКИ: Объём и грузовые места между "Цена товаров" и "Статус" */}
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Объём</TableHead>
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Грузовые места</TableHead>
{/* 🔒 ВИДИМОСТЬ: Только для WHOLESALE, FULFILLMENT, LOGIST (НЕ для SELLER) */}
{(userRole === 'WHOLESALE' || userRole === 'FULFILLMENT' || userRole === 'LOGIST') && (
<>
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Объём</TableHead>
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Грузовые места</TableHead>
</>
)}
{/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE не видит услуги ФФ, расходники и логистику */}
{userRole !== 'WHOLESALE' && (
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap hidden lg:table-cell">
@ -844,73 +849,78 @@ export function MultiLevelSuppliesTable({
</span>
</TableCell>
{/* 🆕 НОВЫЕ ЯЧЕЙКИ: Объём и грузовые места с инпутами для WHOLESALE */}
<TableCell>
{userRole === 'WHOLESALE' && supply.status === 'PENDING' ? (
<input
type="number"
step="0.01"
placeholder="0.0 м³"
value={inputValues[supply.id]?.volume ?? ''}
onChange={(e) => {
const value = e.target.value
// Устанавливаем pending состояние
setPendingUpdates(prev => new Set(prev).add(`${supply.id}-volume`))
// Обновляем локальное состояние немедленно
setInputValues(prev => ({
...prev,
[supply.id]: {
...prev[supply.id],
volume: value,
},
}))
// Вызываем обработчик с преобразованным значением
const numValue = value === '' ? null : parseFloat(value)
onVolumeChange?.(supply.id, numValue)
}}
onFocus={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="w-20 bg-white/10 border border-white/20 rounded px-2 py-1 text-white text-sm placeholder:text-white/40 focus:outline-none focus:border-white/40"
/>
) : (
<span className="text-white/80 text-sm">
{supply.volume ? `${supply.volume} м³` : '—'}
</span>
)}
</TableCell>
<TableCell>
{userRole === 'WHOLESALE' && supply.status === 'PENDING' ? (
<input
type="number"
placeholder="0 мест"
value={inputValues[supply.id]?.packages ?? ''}
onChange={(e) => {
const value = e.target.value
// Устанавливаем pending состояние
setPendingUpdates(prev => new Set(prev).add(`${supply.id}-packages`))
// Обновляем локальное состояние немедленно
setInputValues(prev => ({
...prev,
[supply.id]: {
...prev[supply.id],
packages: value,
},
}))
// Вызываем обработчик с преобразованным значением
const numValue = value === '' ? null : parseInt(value)
onPackagesChange?.(supply.id, numValue)
}}
onFocus={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="w-20 bg-white/10 border border-white/20 rounded px-2 py-1 text-white text-sm placeholder:text-white/40 focus:outline-none focus:border-white/40"
/>
) : (
<span className="text-white/80 text-sm">
{supply.packagesCount ? `${supply.packagesCount} мест` : '—'}
</span>
)}
</TableCell>
{/* 🔒 ВИДИМОСТЬ: Только для WHOLESALE, FULFILLMENT, LOGIST (НЕ для SELLER) */}
{(userRole === 'WHOLESALE' || userRole === 'FULFILLMENT' || userRole === 'LOGIST') && (
<>
<TableCell>
{userRole === 'WHOLESALE' && supply.status === 'PENDING' ? (
<input
type="number"
step="0.01"
placeholder="0.0 м³"
value={inputValues[supply.id]?.volume ?? ''}
onChange={(e) => {
const value = e.target.value
// Устанавливаем pending состояние
setPendingUpdates(prev => new Set(prev).add(`${supply.id}-volume`))
// Обновляем локальное состояние немедленно
setInputValues(prev => ({
...prev,
[supply.id]: {
...prev[supply.id],
volume: value,
},
}))
// Вызываем обработчик с преобразованным значением
const numValue = value === '' ? null : parseFloat(value)
onVolumeChange?.(supply.id, numValue)
}}
onFocus={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="w-20 bg-white/10 border border-white/20 rounded px-2 py-1 text-white text-sm placeholder:text-white/40 focus:outline-none focus:border-white/40"
/>
) : (
<span className="text-white/80 text-sm">
{supply.volume ? `${supply.volume} м³` : '—'}
</span>
)}
</TableCell>
<TableCell>
{userRole === 'WHOLESALE' && supply.status === 'PENDING' ? (
<input
type="number"
placeholder="0 мест"
value={inputValues[supply.id]?.packages ?? ''}
onChange={(e) => {
const value = e.target.value
// Устанавливаем pending состояние
setPendingUpdates(prev => new Set(prev).add(`${supply.id}-packages`))
// Обновляем локальное состояние немедленно
setInputValues(prev => ({
...prev,
[supply.id]: {
...prev[supply.id],
packages: value,
},
}))
// Вызываем обработчик с преобразованным значением
const numValue = value === '' ? null : parseInt(value)
onPackagesChange?.(supply.id, numValue)
}}
onFocus={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="w-20 bg-white/10 border border-white/20 rounded px-2 py-1 text-white text-sm placeholder:text-white/40 focus:outline-none focus:border-white/40"
/>
) : (
<span className="text-white/80 text-sm">
{supply.packagesCount ? `${supply.packagesCount} мест` : '—'}
</span>
)}
</TableCell>
</>
)}
{/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE не видит услуги ФФ, расходники и логистику */}
{userRole !== 'WHOLESALE' && (
<TableCell className="hidden lg:table-cell">

View File

@ -0,0 +1,252 @@
import { gql } from '@apollo/client'
export const GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES = gql`
query GetMyFulfillmentConsumableSupplies {
myFulfillmentConsumableSupplies {
id
status
fulfillmentCenterId
fulfillmentCenter {
id
name
inn
}
requestedDeliveryDate
resalePricePerUnit
minStockLevel
notes
supplierId
supplier {
id
name
inn
}
supplierApprovedAt
packagesCount
estimatedVolume
supplierContractId
supplierNotes
logisticsPartnerId
logisticsPartner {
id
name
inn
}
estimatedDeliveryDate
routeId
logisticsCost
logisticsNotes
shippedAt
trackingNumber
receivedAt
receivedById
receivedBy {
id
managerName
phone
}
actualQuantity
defectQuantity
receiptNotes
items {
id
productId
product {
id
name
article
price
quantity
mainImage
}
requestedQuantity
approvedQuantity
shippedQuantity
receivedQuantity
defectQuantity
unitPrice
totalPrice
}
createdAt
updatedAt
}
}
`
export const GET_FULFILLMENT_CONSUMABLE_SUPPLY = gql`
query GetFulfillmentConsumableSupply($id: ID!) {
fulfillmentConsumableSupply(id: $id) {
id
status
fulfillmentCenterId
fulfillmentCenter {
id
name
inn
}
requestedDeliveryDate
resalePricePerUnit
minStockLevel
notes
supplierId
supplier {
id
name
inn
}
supplierApprovedAt
packagesCount
estimatedVolume
supplierContractId
supplierNotes
logisticsPartnerId
logisticsPartner {
id
name
inn
}
estimatedDeliveryDate
routeId
logisticsCost
logisticsNotes
shippedAt
trackingNumber
receivedAt
receivedById
receivedBy {
id
managerName
phone
}
actualQuantity
defectQuantity
receiptNotes
items {
id
productId
product {
id
name
article
price
quantity
mainImage
}
requestedQuantity
approvedQuantity
shippedQuantity
receivedQuantity
defectQuantity
unitPrice
totalPrice
}
createdAt
updatedAt
}
}
`
export const GET_MY_SUPPLIER_CONSUMABLE_SUPPLIES = gql`
query GetMySupplierConsumableSupplies {
mySupplierConsumableSupplies {
id
status
fulfillmentCenterId
fulfillmentCenter {
id
name
inn
}
requestedDeliveryDate
resalePricePerUnit
minStockLevel
notes
supplierId
supplier {
id
name
inn
}
supplierApprovedAt
packagesCount
estimatedVolume
supplierContractId
supplierNotes
logisticsPartnerId
logisticsPartner {
id
name
inn
}
estimatedDeliveryDate
routeId
logisticsCost
logisticsNotes
shippedAt
trackingNumber
receivedAt
receivedById
receivedBy {
id
managerName
phone
}
actualQuantity
defectQuantity
receiptNotes
items {
id
productId
product {
id
name
article
price
quantity
mainImage
}
requestedQuantity
approvedQuantity
shippedQuantity
receivedQuantity
defectQuantity
unitPrice
totalPrice
}
createdAt
updatedAt
}
}
`
export const CREATE_FULFILLMENT_CONSUMABLE_SUPPLY = gql`
mutation CreateFulfillmentConsumableSupply($input: CreateFulfillmentConsumableSupplyInput!) {
createFulfillmentConsumableSupply(input: $input) {
success
message
supplyOrder {
id
status
createdAt
}
}
}
`

View File

@ -12,6 +12,9 @@ import { WildberriesService } from '@/services/wildberries-service'
import '@/lib/seed-init' // Автоматическая инициализация БД
// Импорт новых resolvers для системы поставок v2
import { fulfillmentConsumableV2Queries, fulfillmentConsumableV2Mutations } from './resolvers/fulfillment-consumables-v2'
// 🔒 СИСТЕМА БЕЗОПАСНОСТИ - импорты
import { CommercialDataAudit } from './security/commercial-data-audit'
import { createSecurityContext } from './security/index'
@ -2762,6 +2765,7 @@ export const resolvers = {
return {
...item,
price: item.price || 0, // Исправлено: защита от null значения в существующих данных
recipe,
}
}),
@ -2792,6 +2796,9 @@ export const resolvers = {
throw new GraphQLError(`Ошибка получения поставок: ${error instanceof Error ? error.message : String(error)}`)
}
},
// Новая система поставок v2
...fulfillmentConsumableV2Queries,
},
Mutation: {
@ -5148,7 +5155,7 @@ export const resolvers = {
return {
productId: item.productId,
quantity: item.quantity,
price: product.price,
price: product.price || 0, // Исправлено: защита от null значения
totalPrice: new Prisma.Decimal(itemTotal),
// Извлечение данных рецептуры из объекта recipe
services: item.recipe?.services || [],
@ -10231,6 +10238,10 @@ resolvers.Mutation = {
}
},
// Добавляем v2 mutations через spread
...fulfillmentConsumableV2Mutations
}
/* // Резолвер для парсинга JSON рецептуры в SupplyOrderItem
SupplyOrderItem: {
recipe: (parent: any) => {
@ -10248,6 +10259,9 @@ resolvers.Mutation = {
},
},
*/
}
// ===============================================
// НОВАЯ СИСТЕМА ПОСТАВОК V2.0 - RESOLVERS
// ===============================================
export default resolvers

View File

@ -0,0 +1,268 @@
import { GraphQLError } from 'graphql'
import { Context } from '../context'
import { prisma } from '@/lib/prisma'
import { notifyOrganization } from '@/lib/realtime'
export const fulfillmentConsumableV2Queries = {
myFulfillmentConsumableSupplies: async (_: unknown, __: unknown, context: Context) => {
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 supplies = await prisma.fulfillmentConsumableSupplyOrder.findMany({
where: {
fulfillmentCenterId: user.organizationId!,
},
include: {
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
receivedBy: true,
items: {
include: {
product: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
})
return supplies
} catch (error) {
console.error('Error fetching fulfillment consumable supplies:', error)
return [] // Возвращаем пустой массив вместо throw
}
},
fulfillmentConsumableSupply: async (_: unknown, args: { id: string }, context: Context) => {
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) {
throw new GraphQLError('Организация не найдена')
}
const supply = await prisma.fulfillmentConsumableSupplyOrder.findUnique({
where: { id: args.id },
include: {
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
receivedBy: true,
items: {
include: {
product: true,
},
},
},
})
if (!supply) {
throw new GraphQLError('Поставка не найдена')
}
// Проверка доступа
if (
user.organization.type === 'FULFILLMENT' &&
supply.fulfillmentCenterId !== user.organizationId
) {
throw new GraphQLError('Нет доступа к этой поставке')
}
if (
user.organization.type === 'WHOLESALE' &&
supply.supplierId !== user.organizationId
) {
throw new GraphQLError('Нет доступа к этой поставке')
}
return supply
} catch (error) {
console.error('Error fetching fulfillment consumable supply:', error)
throw new GraphQLError('Ошибка получения поставки')
}
},
// Заявки на поставки для поставщиков (новая система v2)
mySupplierConsumableSupplies: async (_: unknown, __: unknown, context: Context) => {
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 !== 'WHOLESALE') {
return []
}
const supplies = await prisma.fulfillmentConsumableSupplyOrder.findMany({
where: {
supplierId: user.organizationId!,
},
include: {
fulfillmentCenter: true,
supplier: true,
logisticsPartner: true,
receivedBy: true,
items: {
include: {
product: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
})
return supplies
} catch (error) {
console.error('Error fetching supplier consumable supplies:', error)
return []
}
},
}
export const fulfillmentConsumableV2Mutations = {
createFulfillmentConsumableSupply: async (
_: unknown,
args: {
input: {
supplierId: string
requestedDeliveryDate: string
items: Array<{
productId: string
requestedQuantity: number
}>
notes?: string
}
},
context: Context
) => {
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('Только фулфилмент-центры могут создавать поставки расходников')
}
// Проверяем что поставщик существует и является WHOLESALE
const supplier = await prisma.organization.findUnique({
where: { id: args.input.supplierId },
})
if (!supplier || supplier.type !== 'WHOLESALE') {
throw new GraphQLError('Поставщик не найден или не является оптовиком')
}
// Проверяем что все товары существуют и принадлежат поставщику
const productIds = args.input.items.map(item => item.productId)
const products = await prisma.product.findMany({
where: {
id: { in: productIds },
organizationId: supplier.id,
type: 'CONSUMABLE',
},
})
if (products.length !== productIds.length) {
throw new GraphQLError('Некоторые товары не найдены или не принадлежат поставщику')
}
// Создаем поставку с items
const supplyOrder = await prisma.fulfillmentConsumableSupplyOrder.create({
data: {
fulfillmentCenterId: user.organizationId!,
supplierId: supplier.id,
requestedDeliveryDate: new Date(args.input.requestedDeliveryDate),
notes: args.input.notes,
items: {
create: args.input.items.map(item => {
const product = products.find(p => p.id === item.productId)!
return {
productId: item.productId,
requestedQuantity: item.requestedQuantity,
unitPrice: product.price,
totalPrice: product.price.mul(item.requestedQuantity),
}
}),
},
},
include: {
fulfillmentCenter: true,
supplier: true,
items: {
include: {
product: true,
},
},
},
})
// Отправляем уведомление поставщику о новой заявке
await notifyOrganization(supplier.id, {
type: 'supply-order:new',
title: 'Новая заявка на поставку расходников',
message: `Фулфилмент-центр "${user.organization.name}" создал заявку на поставку расходников`,
data: {
supplyOrderId: supplyOrder.id,
supplyOrderType: 'FULFILLMENT_CONSUMABLES_V2',
fulfillmentCenterName: user.organization.name,
itemsCount: args.input.items.length,
requestedDeliveryDate: args.input.requestedDeliveryDate,
},
})
return {
success: true,
message: 'Поставка расходников создана успешно',
supplyOrder,
}
} catch (error) {
console.error('Error creating fulfillment consumable supply:', error)
return {
success: false,
message: error instanceof Error ? error.message : 'Ошибка создания поставки',
supplyOrder: null,
}
}
},
}

View File

@ -8,6 +8,7 @@ import { referralResolvers } from './referrals'
import { integrateSecurityWithExistingResolvers } from './secure-integration'
import { secureSuppliesResolvers } from './secure-supplies'
import { suppliesResolvers } from './supplies'
import { fulfillmentConsumableV2Queries, fulfillmentConsumableV2Mutations } from './fulfillment-consumables-v2'
// Типы для резолверов
interface ResolverObject {
@ -104,6 +105,12 @@ const mergedResolvers = mergeResolvers(
// БЕЗОПАСНЫЕ резолверы поставок
secureSuppliesResolvers,
// НОВЫЕ резолверы для системы поставок v2
{
Query: fulfillmentConsumableV2Queries,
Mutation: fulfillmentConsumableV2Mutations,
},
)
// Применяем middleware безопасности ко всем резолверам

View File

@ -1655,4 +1655,114 @@ export const typeDefs = gql`
REFERRAL
AUTO_BUSINESS
}
# ===============================================
# НОВАЯ СИСТЕМА ПОСТАВОК V2.0
# ===============================================
# Новый enum для статусов поставок v2
enum SupplyOrderStatusV2 {
PENDING # Ожидает одобрения поставщика
SUPPLIER_APPROVED # Одобрено поставщиком
LOGISTICS_CONFIRMED # Логистика подтверждена
SHIPPED # Отгружено поставщиком
IN_TRANSIT # В пути
DELIVERED # Доставлено и принято
CANCELLED # Отменено
}
# Типы для поставок расходников фулфилмента
type FulfillmentConsumableSupplyOrder {
id: ID!
status: SupplyOrderStatusV2!
fulfillmentCenterId: ID!
fulfillmentCenter: Organization!
requestedDeliveryDate: DateTime!
resalePricePerUnit: Float
minStockLevel: Int
notes: String
# Данные поставщика
supplierId: ID
supplier: Organization
supplierApprovedAt: DateTime
packagesCount: Int
estimatedVolume: Float
supplierContractId: String
supplierNotes: String
# Данные логистики
logisticsPartnerId: ID
logisticsPartner: Organization
estimatedDeliveryDate: DateTime
routeId: ID
logisticsCost: Float
logisticsNotes: String
# Данные отгрузки
shippedAt: DateTime
trackingNumber: String
# Данные приемки
receivedAt: DateTime
receivedById: ID
receivedBy: User
actualQuantity: Int
defectQuantity: Int
receiptNotes: String
items: [FulfillmentConsumableSupplyItem!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type FulfillmentConsumableSupplyItem {
id: ID!
productId: ID!
product: Product!
requestedQuantity: Int!
approvedQuantity: Int
shippedQuantity: Int
receivedQuantity: Int
defectQuantity: Int
unitPrice: Float!
totalPrice: Float!
createdAt: DateTime!
updatedAt: DateTime!
}
# Input типы для создания поставок
input CreateFulfillmentConsumableSupplyInput {
supplierId: ID!
requestedDeliveryDate: DateTime!
items: [FulfillmentConsumableSupplyItemInput!]!
notes: String
}
input FulfillmentConsumableSupplyItemInput {
productId: ID!
requestedQuantity: Int!
}
# Response типы
type CreateFulfillmentConsumableSupplyResult {
success: Boolean!
message: String!
supplyOrder: FulfillmentConsumableSupplyOrder
}
# Расширяем Query и Mutation для новой системы
extend type Query {
# Новые запросы для системы поставок v2
myFulfillmentConsumableSupplies: [FulfillmentConsumableSupplyOrder!]!
mySupplierConsumableSupplies: [FulfillmentConsumableSupplyOrder!]!
fulfillmentConsumableSupply(id: ID!): FulfillmentConsumableSupplyOrder
}
extend type Mutation {
# Новые мутации для системы поставок v2
createFulfillmentConsumableSupply(
input: CreateFulfillmentConsumableSupplyInput!
): CreateFulfillmentConsumableSupplyResult!
}
`