Compare commits
6 Commits
f3285d9139
...
d200885ff5
Author | SHA1 | Date | |
---|---|---|---|
d200885ff5 | |||
6e3cedec67 | |||
7f0e09eef6 | |||
57f8f762c9 | |||
888fe76849 | |||
a48efb8757 |
181
CLAUDE.md
181
CLAUDE.md
@ -7,6 +7,7 @@
|
|||||||
## 1. ЦЕЛЬ И ПРИНЦИПЫ
|
## 1. ЦЕЛЬ И ПРИНЦИПЫ
|
||||||
|
|
||||||
### 🎯 ЦЕЛЬ ПРАВИЛ:
|
### 🎯 ЦЕЛЬ ПРАВИЛ:
|
||||||
|
|
||||||
- ✅ **Честность и прозрачность** в общении
|
- ✅ **Честность и прозрачность** в общении
|
||||||
- ✅ **Неизменность согласованных планов**
|
- ✅ **Неизменность согласованных планов**
|
||||||
- ✅ **Качественное выполнение задач**
|
- ✅ **Качественное выполнение задач**
|
||||||
@ -15,6 +16,7 @@
|
|||||||
- ✅ **БЕЗОПАСНОСТЬ ИЗМЕНЕНИЙ** - защита от рискованных модификаций
|
- ✅ **БЕЗОПАСНОСТЬ ИЗМЕНЕНИЙ** - защита от рискованных модификаций
|
||||||
|
|
||||||
### ⚡ ПРИНЦИПЫ КАЧЕСТВА КОДА:
|
### ⚡ ПРИНЦИПЫ КАЧЕСТВА КОДА:
|
||||||
|
|
||||||
- ✅ **Качество кода важнее скорости** - лучше потратить время на правильное решение
|
- ✅ **Качество кода важнее скорости** - лучше потратить время на правильное решение
|
||||||
- ✅ **Pre-commit hooks существуют для защиты проекта** - никогда не обходить их
|
- ✅ **Pre-commit hooks существуют для защиты проекта** - никогда не обходить их
|
||||||
- ✅ **Исправлять ошибки, а не обходить их** - каждая ошибка ESLint должна быть исправлена
|
- ✅ **Исправлять ошибки, а не обходить их** - каждая ошибка ESLint должна быть исправлена
|
||||||
@ -27,23 +29,27 @@
|
|||||||
## 2. РЕЖИМЫ РАБОТЫ
|
## 2. РЕЖИМЫ РАБОТЫ
|
||||||
|
|
||||||
### [STRICT] - Режим точного выполнения
|
### [STRICT] - Режим точного выполнения
|
||||||
|
|
||||||
- Делать ТОЛЬКО что указано
|
- Делать ТОЛЬКО что указано
|
||||||
- БЕЗ предложений и улучшений
|
- БЕЗ предложений и улучшений
|
||||||
- Краткие ответы: "Готово", "Сделано"
|
- Краткие ответы: "Готово", "Сделано"
|
||||||
- Активация: "режим робот", "[STRICT]"
|
- Активация: "режим робот", "[STRICT]"
|
||||||
|
|
||||||
### [CREATIVE] - Режим с предложениями
|
### [CREATIVE] - Режим с предложениями
|
||||||
|
|
||||||
- Можно предлагать улучшения
|
- Можно предлагать улучшения
|
||||||
- Можно указывать на проблемы
|
- Можно указывать на проблемы
|
||||||
- Развернутые объяснения
|
- Развернутые объяснения
|
||||||
- По умолчанию активен
|
- По умолчанию активен
|
||||||
|
|
||||||
### [CHECK] - Режим проверки
|
### [CHECK] - Режим проверки
|
||||||
|
|
||||||
- Только анализ, БЕЗ изменений
|
- Только анализ, БЕЗ изменений
|
||||||
- Отчет о найденных проблемах
|
- Отчет о найденных проблемах
|
||||||
- Рекомендации без выполнения
|
- Рекомендации без выполнения
|
||||||
|
|
||||||
**ПРАВИЛО ПРЕДЛОЖЕНИЙ:**
|
**ПРАВИЛО ПРЕДЛОЖЕНИЙ:**
|
||||||
|
|
||||||
- **МОГУ**: Предлагать идеи, улучшения, оптимизации
|
- **МОГУ**: Предлагать идеи, улучшения, оптимизации
|
||||||
- **МОГУ**: Указывать на проблемы и риски
|
- **МОГУ**: Указывать на проблемы и риски
|
||||||
- **МОГУ**: Показывать альтернативные решения
|
- **МОГУ**: Показывать альтернативные решения
|
||||||
@ -57,12 +63,14 @@
|
|||||||
### ЕДИНСТВЕННАЯ ПРАВИЛЬНАЯ ПОСЛЕДОВАТЕЛЬНОСТЬ:
|
### ЕДИНСТВЕННАЯ ПРАВИЛЬНАЯ ПОСЛЕДОВАТЕЛЬНОСТЬ:
|
||||||
|
|
||||||
#### ЭТАП 1: ИНИЦИАЦИЯ
|
#### ЭТАП 1: ИНИЦИАЦИЯ
|
||||||
|
|
||||||
1. **ПОЛУЧИТЬ** задачу от пользователя
|
1. **ПОЛУЧИТЬ** задачу от пользователя
|
||||||
2. **ПРОЧИТАТЬ** - полностью, 3 раза
|
2. **ПРОЧИТАТЬ** - полностью, 3 раза
|
||||||
3. **НАЙТИ** - глаголы действия (создай, измени, удали)
|
3. **НАЙТИ** - глаголы действия (создай, измени, удали)
|
||||||
4. **ОПРЕДЕЛИТЬ** тип задачи и её сложность
|
4. **ОПРЕДЕЛИТЬ** тип задачи и её сложность
|
||||||
|
|
||||||
#### ЭТАП 2: ПЛАНИРОВАНИЕ
|
#### ЭТАП 2: ПЛАНИРОВАНИЕ
|
||||||
|
|
||||||
5. **🛑 ГЛУБОКИЙ АНАЛИЗ** (обязательные вопросы пользователю)
|
5. **🛑 ГЛУБОКИЙ АНАЛИЗ** (обязательные вопросы пользователю)
|
||||||
6. **🔍 ИССЛЕДОВАНИЕ** (изучение ВСЕХ связанных файлов)
|
6. **🔍 ИССЛЕДОВАНИЕ** (изучение ВСЕХ связанных файлов)
|
||||||
7. **📊 ДЕТАЛЬНЫЙ ПЛАН** (с промежуточными проверками и rollback точками)
|
7. **📊 ДЕТАЛЬНЫЙ ПЛАН** (с промежуточными проверками и rollback точками)
|
||||||
@ -71,6 +79,7 @@
|
|||||||
10. **ОСТАНОВИТЬСЯ И ЖДАТЬ ОДОБРЕНИЯ ПЛАНА**
|
10. **ОСТАНОВИТЬСЯ И ЖДАТЬ ОДОБРЕНИЯ ПЛАНА**
|
||||||
|
|
||||||
**Чек-лист планирования:**
|
**Чек-лист планирования:**
|
||||||
|
|
||||||
```
|
```
|
||||||
- ✅ Прочитал правила в docs/
|
- ✅ Прочитал правила в docs/
|
||||||
- ✅ Задача понята в контексте правил
|
- ✅ Задача понята в контексте правил
|
||||||
@ -81,12 +90,14 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### ЭТАП 3: ВЫПОЛНЕНИЕ
|
#### ЭТАП 3: ВЫПОЛНЕНИЕ
|
||||||
|
|
||||||
11. **ПОЛУЧИТЬ** одобрение плана от пользователя
|
11. **ПОЛУЧИТЬ** одобрение плана от пользователя
|
||||||
12. **ИССЛЕДОВАТЬ** - Read/Grep/Glob перед изменениями
|
12. **ИССЛЕДОВАТЬ** - Read/Grep/Glob перед изменениями
|
||||||
13. **ВЫПОЛНЯТЬ** строго по одобренному плану
|
13. **ВЫПОЛНЯТЬ** строго по одобренному плану
|
||||||
14. **ПРОВЕРИТЬ** - npm run typecheck, npm run lint
|
14. **ПРОВЕРИТЬ** - npm run typecheck, npm run lint
|
||||||
|
|
||||||
#### ЭТАП 4: КОНТРОЛЬ
|
#### ЭТАП 4: КОНТРОЛЬ
|
||||||
|
|
||||||
15. **ПРОВЕСТИ** финальную самопроверку
|
15. **ПРОВЕСТИ** финальную самопроверку
|
||||||
16. **ОТЧИТАТЬСЯ** - что сделано/не трогал/проблемы
|
16. **ОТЧИТАТЬСЯ** - что сделано/не трогал/проблемы
|
||||||
|
|
||||||
@ -97,12 +108,14 @@
|
|||||||
## 4. ЖЕЛЕЗНЫЕ ЗАПРЕТЫ
|
## 4. ЖЕЛЕЗНЫЕ ЗАПРЕТЫ
|
||||||
|
|
||||||
### АБСОЛЮТНЫЕ ПРАВИЛА:
|
### АБСОЛЮТНЫЕ ПРАВИЛА:
|
||||||
|
|
||||||
❌ **НИЧЕГО НЕ ДЕЛАТЬ БЕЗ ПЛАНА И БЕЗ РАЗРЕШЕНИЯ!**
|
❌ **НИЧЕГО НЕ ДЕЛАТЬ БЕЗ ПЛАНА И БЕЗ РАЗРЕШЕНИЯ!**
|
||||||
❌ **ВСЕГДА ЧИТАТЬ КОД!** - никаких предположений о структуре
|
❌ **ВСЕГДА ЧИТАТЬ КОД!** - никаких предположений о структуре
|
||||||
❌ **НИЧЕГО НЕ ДОДУМЫВАТЬ!** - сомневаешься = спроси пользователя
|
❌ **НИЧЕГО НЕ ДОДУМЫВАТЬ!** - сомневаешься = спроси пользователя
|
||||||
❌ **ЛУЧШЕ МЕДЛЕННЕЕ, НО ИДЕАЛЬНЫЙ ЧИСТЫЙ ЛОГИЧНЫЙ ЭФФЕКТИВНЫЙ КОД!**
|
❌ **ЛУЧШЕ МЕДЛЕННЕЕ, НО ИДЕАЛЬНЫЙ ЧИСТЫЙ ЛОГИЧНЫЙ ЭФФЕКТИВНЫЙ КОД!**
|
||||||
|
|
||||||
### ЗАПРЕТЫ НА ПРЕДПОЛОЖЕНИЯ:
|
### ЗАПРЕТЫ НА ПРЕДПОЛОЖЕНИЯ:
|
||||||
|
|
||||||
❌ НИКОГДА не предполагать/додумывать
|
❌ НИКОГДА не предполагать/додумывать
|
||||||
❌ НИКОГДА не улучшать без запроса
|
❌ НИКОГДА не улучшать без запроса
|
||||||
❌ НИКОГДА не рефакторить "заодно"
|
❌ НИКОГДА не рефакторить "заодно"
|
||||||
@ -111,6 +124,7 @@
|
|||||||
❌ НИКОГДА не реализовывать идеи без разрешения
|
❌ НИКОГДА не реализовывать идеи без разрешения
|
||||||
|
|
||||||
### ПРАВИЛА ИССЛЕДОВАНИЯ КОДА:
|
### ПРАВИЛА ИССЛЕДОВАНИЯ КОДА:
|
||||||
|
|
||||||
- ✅ **ОБЯЗАТЕЛЬНО использовать инструменты поиска** по кодовой базе
|
- ✅ **ОБЯЗАТЕЛЬНО использовать инструменты поиска** по кодовой базе
|
||||||
- ✅ **ОБЯЗАТЕЛЬНО читать исходный код** файлов
|
- ✅ **ОБЯЗАТЕЛЬНО читать исходный код** файлов
|
||||||
- ✅ **ОБЯЗАТЕЛЬНО читать архитектурные правила** ПЕРЕД любым созданием компонентов
|
- ✅ **ОБЯЗАТЕЛЬНО читать архитектурные правила** ПЕРЕД любым созданием компонентов
|
||||||
@ -119,6 +133,7 @@
|
|||||||
- ❌ **ЗАПРЕЩЕНО начинать код без понимания архитектуры**
|
- ❌ **ЗАПРЕЩЕНО начинать код без понимания архитектуры**
|
||||||
|
|
||||||
### РАБОТА С ПЛАНАМИ:
|
### РАБОТА С ПЛАНАМИ:
|
||||||
|
|
||||||
❌ НИКОГДА не изменять согласованные планы без явного решения
|
❌ НИКОГДА не изменять согласованные планы без явного решения
|
||||||
❌ НИКОГДА не менять последовательность задач молча
|
❌ НИКОГДА не менять последовательность задач молча
|
||||||
❌ НИКОГДА не добавлять новые пункты в план
|
❌ НИКОГДА не добавлять новые пункты в план
|
||||||
@ -128,6 +143,7 @@
|
|||||||
❌ НИКОГДА не делать вид что помню план, когда не помню
|
❌ НИКОГДА не делать вид что помню план, когда не помню
|
||||||
|
|
||||||
### ОБЯЗАТЕЛЬНЫЕ ДЕЙСТВИЯ:
|
### ОБЯЗАТЕЛЬНЫЕ ДЕЙСТВИЯ:
|
||||||
|
|
||||||
✅ ВСЕГДА спрашивать при сомнениях
|
✅ ВСЕГДА спрашивать при сомнениях
|
||||||
✅ ВСЕГДА читать код перед изменениями
|
✅ ВСЕГДА читать код перед изменениями
|
||||||
✅ ВСЕГДА проверять типы и линтер
|
✅ ВСЕГДА проверять типы и линтер
|
||||||
@ -139,7 +155,9 @@
|
|||||||
## 5. СИСТЕМЫ ПРОВЕРОК И КОНТРОЛЯ
|
## 5. СИСТЕМЫ ПРОВЕРОК И КОНТРОЛЯ
|
||||||
|
|
||||||
### СТОП-СИГНАЛЫ
|
### СТОП-СИГНАЛЫ
|
||||||
|
|
||||||
При этих словах → СТОП → уточнить:
|
При этих словах → СТОП → уточнить:
|
||||||
|
|
||||||
- "удали" → "Что именно удалить? Файл/функцию/строку?"
|
- "удали" → "Что именно удалить? Файл/функцию/строку?"
|
||||||
- "исправь" → "Какую конкретно ошибку?"
|
- "исправь" → "Какую конкретно ошибку?"
|
||||||
- "откати" → "На какой коммит/сколько действий?"
|
- "откати" → "На какой коммит/сколько действий?"
|
||||||
@ -147,12 +165,14 @@
|
|||||||
- "добавь" → "Куда именно добавить?"
|
- "добавь" → "Куда именно добавить?"
|
||||||
|
|
||||||
### ОБЯЗАТЕЛЬНЫЕ ФРАЗЫ при уточнении:
|
### ОБЯЗАТЕЛЬНЫЕ ФРАЗЫ при уточнении:
|
||||||
|
|
||||||
✅ "Не уверен. Уточните, пожалуйста:"
|
✅ "Не уверен. Уточните, пожалуйста:"
|
||||||
✅ "Какой именно файл/компонент?"
|
✅ "Какой именно файл/компонент?"
|
||||||
✅ "Вы имеете в виду X или Y?"
|
✅ "Вы имеете в виду X или Y?"
|
||||||
✅ "Правильно ли я понимаю, что..."
|
✅ "Правильно ли я понимаю, что..."
|
||||||
|
|
||||||
### ЗАПРЕЩЕННЫЕ ФРАЗЫ:
|
### ЗАПРЕЩЕННЫЕ ФРАЗЫ:
|
||||||
|
|
||||||
❌ "Возможно, вы имеете в виду..."
|
❌ "Возможно, вы имеете в виду..."
|
||||||
❌ "Скорее всего, нужно..."
|
❌ "Скорее всего, нужно..."
|
||||||
❌ "Попробую в этом файле..."
|
❌ "Попробую в этом файле..."
|
||||||
@ -161,24 +181,124 @@
|
|||||||
### АВТОМАТИЧЕСКИЕ ТРИГГЕРЫ:
|
### АВТОМАТИЧЕСКИЕ ТРИГГЕРЫ:
|
||||||
|
|
||||||
#### ТРИГГЕР #1: При упоминании компонентов
|
#### ТРИГГЕР #1: При упоминании компонентов
|
||||||
|
|
||||||
- Ключевые слова: "компонент", "файл", "содержание", "показывает"
|
- Ключевые слова: "компонент", "файл", "содержание", "показывает"
|
||||||
- Действие: ОБЯЗАТЕЛЬНО использовать инструменты анализа кода
|
- Действие: ОБЯЗАТЕЛЬНО использовать инструменты анализа кода
|
||||||
|
|
||||||
#### ТРИГГЕР #2: При неопределенности
|
#### ТРИГГЕР #2: При неопределенности
|
||||||
|
|
||||||
- Ключевые фразы: "возможно", "вероятно", "думаю", "предполагаю"
|
- Ключевые фразы: "возможно", "вероятно", "думаю", "предполагаю"
|
||||||
- Действие: СТОП + вопрос пользователю
|
- Действие: СТОП + вопрос пользователю
|
||||||
|
|
||||||
#### ТРИГГЕР #3: При работе с поставщиками
|
#### ТРИГГЕР #3: При работе с поставщиками
|
||||||
|
|
||||||
- Ключевые слова: "поставщик", "wholesale", "/warehouse", "/supplier-orders"
|
- Ключевые слова: "поставщик", "wholesale", "/warehouse", "/supplier-orders"
|
||||||
- Действие: ОБЯЗАТЕЛЬНО прочитать wholesale-cabinet-rules.md
|
- Действие: ОБЯЗАТЕЛЬНО прочитать wholesale-cabinet-rules.md
|
||||||
|
|
||||||
#### ТРИГГЕР #4: При создании компонентов
|
#### ТРИГГЕР #4: При создании компонентов
|
||||||
|
|
||||||
- Ключевые слова: "создай", "новый компонент", "добавь компонент", "создать компонент"
|
- Ключевые слова: "создай", "новый компонент", "добавь компонент", "создать компонент"
|
||||||
- Действие: ОБЯЗАТЕЛЬНО прочитать MODULAR_ARCHITECTURE_PATTERN.md + COMPONENT_ARCHITECTURE.md ПЕРЕД началом
|
- Действие: ОБЯЗАТЕЛЬНО прочитать MODULAR_ARCHITECTURE_PATTERN.md + COMPONENT_ARCHITECTURE.md ПЕРЕД началом
|
||||||
|
|
||||||
|
#### ТРИГГЕР #5: Модульная архитектура по умолчанию
|
||||||
|
|
||||||
|
- Ключевые слова: "страница", "page", "форма", "таблица", "dashboard", "management"
|
||||||
|
- Действие: АВТОМАТИЧЕСКИ применять модульную архитектуру
|
||||||
|
|
||||||
|
### МОДУЛЬНАЯ АРХИТЕКТУРА ПО УМОЛЧАНИЮ В SFERA
|
||||||
|
|
||||||
|
#### 🎯 КЛЮЧЕВОЕ ПРАВИЛО:
|
||||||
|
|
||||||
|
> "Если сомневаешься - делай модульным. Лучше иметь избыточную структуру папок, чем 2000-строчный спагетти-код через месяц."
|
||||||
|
|
||||||
|
#### ✅ ВСЕГДА МОДУЛЬНАЯ АРХИТЕКТУРА (без исключений):
|
||||||
|
|
||||||
|
**1. Страницы и основные разделы:**
|
||||||
|
|
||||||
|
- ВСЕ файлы `page.tsx` в `/app/**/`
|
||||||
|
- ВСЕ дашборды любых типов
|
||||||
|
- ВСЕ разделы управления (supplies, employees, products, settings)
|
||||||
|
- ВСЕ wizard/multi-step компоненты
|
||||||
|
|
||||||
|
**2. Формы (даже простые!):**
|
||||||
|
|
||||||
|
- ЛЮБАЯ форма создания/редактирования сущности
|
||||||
|
- Формы с >3 полей
|
||||||
|
- Многошаговые формы
|
||||||
|
- Формы с динамическими полями
|
||||||
|
- _Почему: формы ВСЕГДА разрастаются (валидация, автозаполнение, зависимые поля)_
|
||||||
|
|
||||||
|
**3. Таблицы и списки:**
|
||||||
|
|
||||||
|
- ЛЮБАЯ таблица с данными из БД
|
||||||
|
- Таблицы с фильтрацией/сортировкой
|
||||||
|
- Таблицы с inline-редактированием
|
||||||
|
- Списки с действиями (approve/reject/delete)
|
||||||
|
- _Почему: всегда добавятся фильтры, сортировка, экспорт, bulk-операции_
|
||||||
|
|
||||||
|
**4. Комплексные компоненты:**
|
||||||
|
|
||||||
|
- Чаты/мессенджеры
|
||||||
|
- Календари/планировщики
|
||||||
|
- Графики/аналитика
|
||||||
|
- Файловые менеджеры
|
||||||
|
- Корзины/калькуляторы
|
||||||
|
|
||||||
|
#### ❌ ИСКЛЮЧЕНИЯ (только эти остаются простыми):
|
||||||
|
|
||||||
|
1. **Чистые UI из Radix/shadcn** - уже оптимизированы
|
||||||
|
2. **Stateless компоненты** < 50 строк без логики
|
||||||
|
3. **Чистые Layout** компоненты (Header/Footer)
|
||||||
|
4. **Utility компоненты** (ErrorBoundary, LoadingState)
|
||||||
|
5. **Простые модалки** подтверждения (Да/Нет)
|
||||||
|
|
||||||
|
#### 📏 КРИТЕРИИ ПРИНЯТИЯ РЕШЕНИЯ:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Псевдокод для принятия решения
|
||||||
|
function shouldUseModularArchitecture(component) {
|
||||||
|
// Автоматически ДА
|
||||||
|
if (
|
||||||
|
component.type === 'page' ||
|
||||||
|
component.type === 'dashboard' ||
|
||||||
|
component.type === 'form' ||
|
||||||
|
component.type === 'table' ||
|
||||||
|
component.expectedSize > 300
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем сложность
|
||||||
|
const complexityScore =
|
||||||
|
component.stateVariables + // количество useState
|
||||||
|
component.apiCalls * 2 + // API вызовы весят больше
|
||||||
|
component.formFields + // поля форм
|
||||||
|
(component.hasBusinessLogic ? 3 : 0) // бизнес-логика
|
||||||
|
|
||||||
|
return complexityScore >= 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 🎯 ПРАКТИЧЕСКИЕ ПРИМЕРЫ:
|
||||||
|
|
||||||
|
**Модульные:**
|
||||||
|
|
||||||
|
- `/app/suppliers/create/page.tsx` → CreateSupplierPage/
|
||||||
|
- `EmployeeManagementTable` → EmployeeManagement/
|
||||||
|
- `SupplyOrderForm` → SupplyOrderForm/
|
||||||
|
- `ProductCatalog` → ProductCatalog/
|
||||||
|
|
||||||
|
**Простые:**
|
||||||
|
|
||||||
|
- `Logo.tsx`
|
||||||
|
- `LoadingDots.tsx`
|
||||||
|
- `ConfirmDialog.tsx`
|
||||||
|
- `PriceDisplay.tsx`
|
||||||
|
|
||||||
### ПРАВИЛО ПОСЛЕДОВАТЕЛЬНОСТИ ВЫПОЛНЕНИЯ:
|
### ПРАВИЛО ПОСЛЕДОВАТЕЛЬНОСТИ ВЫПОЛНЕНИЯ:
|
||||||
|
|
||||||
**ОБЯЗАТЕЛЬНО:**
|
**ОБЯЗАТЕЛЬНО:**
|
||||||
|
|
||||||
- Выполнять задачи в согласованной последовательности
|
- Выполнять задачи в согласованной последовательности
|
||||||
- Завершать текущую задачу перед переходом к следующей
|
- Завершать текущую задачу перед переходом к следующей
|
||||||
- Отмечать статус выполнения каждой задачи в TodoWrite
|
- Отмечать статус выполнения каждой задачи в TodoWrite
|
||||||
@ -186,6 +306,7 @@
|
|||||||
- Обновлять статус задач в реальном времени
|
- Обновлять статус задач в реальном времени
|
||||||
|
|
||||||
**ЗАПРЕЩЕНО:**
|
**ЗАПРЕЩЕНО:**
|
||||||
|
|
||||||
- Перепрыгивать между задачами без разрешения
|
- Перепрыгивать между задачами без разрешения
|
||||||
- Объединять задачи самовольно
|
- Объединять задачи самовольно
|
||||||
- Менять приоритеты без согласования
|
- Менять приоритеты без согласования
|
||||||
@ -194,6 +315,7 @@
|
|||||||
### СИСТЕМА САМОПРОВЕРКИ:
|
### СИСТЕМА САМОПРОВЕРКИ:
|
||||||
|
|
||||||
**ПРОВЕРКА #1: АНАЛИЗ КОДА**
|
**ПРОВЕРКА #1: АНАЛИЗ КОДА**
|
||||||
|
|
||||||
```
|
```
|
||||||
□ Использовал ли поиск по кодовой базе?
|
□ Использовал ли поиск по кодовой базе?
|
||||||
□ Прочитал ли исходный код?
|
□ Прочитал ли исходный код?
|
||||||
@ -201,6 +323,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
**ПРОВЕРКА #2: СОБЛЮДЕНИЕ ПРОТОКОЛОВ**
|
**ПРОВЕРКА #2: СОБЛЮДЕНИЕ ПРОТОКОЛОВ**
|
||||||
|
|
||||||
```
|
```
|
||||||
□ Определил ли сложность задачи?
|
□ Определил ли сложность задачи?
|
||||||
□ Применил ли соответствующий протокол?
|
□ Применил ли соответствующий протокол?
|
||||||
@ -211,6 +334,7 @@
|
|||||||
### ИЗМЕРИМЫЕ МЕТРИКИ УСПЕХА:
|
### ИЗМЕРИМЫЕ МЕТРИКИ УСПЕХА:
|
||||||
|
|
||||||
**КОНКРЕТНЫЕ МЕТРИКИ:**
|
**КОНКРЕТНЫЕ МЕТРИКИ:**
|
||||||
|
|
||||||
- ✅ Минимум 2 уточняющих вопроса при неопределенности
|
- ✅ Минимум 2 уточняющих вопроса при неопределенности
|
||||||
- ✅ 100% файлов из списка зависимостей компонента изучены
|
- ✅ 100% файлов из списка зависимостей компонента изучены
|
||||||
- ✅ Все пункты протокола сложности выполнены
|
- ✅ Все пункты протокола сложности выполнены
|
||||||
@ -218,6 +342,7 @@
|
|||||||
- ✅ План одобрен пользователем до начала выполнения
|
- ✅ План одобрен пользователем до начала выполнения
|
||||||
|
|
||||||
**5 ВОПРОСОВ ПОСЛЕ КАЖДОЙ ЗАДАЧИ:**
|
**5 ВОПРОСОВ ПОСЛЕ КАЖДОЙ ЗАДАЧИ:**
|
||||||
|
|
||||||
1. Прочитал ли все необходимые файлы правил?
|
1. Прочитал ли все необходимые файлы правил?
|
||||||
2. Применил ли соответствующий протокол сложности?
|
2. Применил ли соответствующий протокол сложности?
|
||||||
3. Получил ли одобрение плана перед выполнением?
|
3. Получил ли одобрение плана перед выполнением?
|
||||||
@ -227,6 +352,7 @@
|
|||||||
**ЦЕЛЬ: 5/5 ответов "ДА" для каждой задачи**
|
**ЦЕЛЬ: 5/5 ответов "ДА" для каждой задачи**
|
||||||
|
|
||||||
**ФИНАЛЬНАЯ МЕГА-ПРОВЕРКА**
|
**ФИНАЛЬНАЯ МЕГА-ПРОВЕРКА**
|
||||||
|
|
||||||
```
|
```
|
||||||
МЕГА-ВОПРОС К СЕБЕ:
|
МЕГА-ВОПРОС К СЕБЕ:
|
||||||
"Применил ли я правильный протокол, проверил ли все правила,
|
"Применил ли я правильный протокол, проверил ли все правила,
|
||||||
@ -242,11 +368,13 @@
|
|||||||
### ПРАВИЛО ЧЕСТНОГО ПРИЗНАНИЯ ОГРАНИЧЕНИЙ
|
### ПРАВИЛО ЧЕСТНОГО ПРИЗНАНИЯ ОГРАНИЧЕНИЙ
|
||||||
|
|
||||||
#### При потере информации:
|
#### При потере информации:
|
||||||
|
|
||||||
- ✅ **ЧЕСТНО** сказать: "Не помню/не нашел"
|
- ✅ **ЧЕСТНО** сказать: "Не помню/не нашел"
|
||||||
- ✅ **ПОПРОСИТЬ** помощи у пользователя
|
- ✅ **ПОПРОСИТЬ** помощи у пользователя
|
||||||
- ❌ **НЕ ПРИДУМЫВАТЬ** информацию
|
- ❌ **НЕ ПРИДУМЫВАТЬ** информацию
|
||||||
|
|
||||||
**Формат при потере контекста плана:**
|
**Формат при потере контекста плана:**
|
||||||
|
|
||||||
```
|
```
|
||||||
🔍 НЕ МОГУ НАЙТИ: мои изначальные предложения по задаче X
|
🔍 НЕ МОГУ НАЙТИ: мои изначальные предложения по задаче X
|
||||||
🆘 НУЖНА ПОМОЩЬ: напомните что я предлагал или дайте новые инструкции
|
🆘 НУЖНА ПОМОЩЬ: напомните что я предлагал или дайте новые инструкции
|
||||||
@ -254,11 +382,13 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### При неопределенности:
|
#### При неопределенности:
|
||||||
|
|
||||||
- ✅ **ОСТАНОВИТЬСЯ** и спросить
|
- ✅ **ОСТАНОВИТЬСЯ** и спросить
|
||||||
- ✅ **ОПИСАТЬ** варианты действий
|
- ✅ **ОПИСАТЬ** варианты действий
|
||||||
- ❌ **НЕ ДЕЙСТВОВАТЬ** наугад
|
- ❌ **НЕ ДЕЙСТВОВАТЬ** наугад
|
||||||
|
|
||||||
**Формат при неопределенности:**
|
**Формат при неопределенности:**
|
||||||
|
|
||||||
```
|
```
|
||||||
🤔 НЕОПРЕДЕЛЕННОСТЬ: [описание проблемы]
|
🤔 НЕОПРЕДЕЛЕННОСТЬ: [описание проблемы]
|
||||||
❓ НУЖНО УТОЧНИТЬ: [конкретный вопрос]
|
❓ НУЖНО УТОЧНИТЬ: [конкретный вопрос]
|
||||||
@ -269,6 +399,7 @@
|
|||||||
### ПРАВИЛО ПРОЗРАЧНОСТИ ДЕЙСТВИЙ
|
### ПРАВИЛО ПРОЗРАЧНОСТИ ДЕЙСТВИЙ
|
||||||
|
|
||||||
#### ОБЯЗАТЕЛЬНО СООБЩАТЬ:
|
#### ОБЯЗАТЕЛЬНО СООБЩАТЬ:
|
||||||
|
|
||||||
- Когда не уверен в правильности действий
|
- Когда не уверен в правильности действий
|
||||||
- Когда обнаружил противоречия в правилах
|
- Когда обнаружил противоречия в правилах
|
||||||
- Когда нужны уточнения для продолжения
|
- Когда нужны уточнения для продолжения
|
||||||
@ -276,6 +407,7 @@
|
|||||||
- О всех критических проблемах в плане
|
- О всех критических проблемах в плане
|
||||||
|
|
||||||
#### При необходимости изменить план:
|
#### При необходимости изменить план:
|
||||||
|
|
||||||
```
|
```
|
||||||
⚠️ ПРЕДЛОЖЕНИЕ ОБ ИЗМЕНЕНИИ ПЛАНА:
|
⚠️ ПРЕДЛОЖЕНИЕ ОБ ИЗМЕНЕНИИ ПЛАНА:
|
||||||
- ТЕКУЩИЙ ПЛАН: [что согласовано]
|
- ТЕКУЩИЙ ПЛАН: [что согласовано]
|
||||||
@ -285,6 +417,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### При обнаружении ошибок в плане:
|
#### При обнаружении ошибок в плане:
|
||||||
|
|
||||||
```
|
```
|
||||||
🚨 ОБНАРУЖЕНА ПРОБЛЕМА В ПЛАНЕ:
|
🚨 ОБНАРУЖЕНА ПРОБЛЕМА В ПЛАНЕ:
|
||||||
- ЗАДАЧА: [какая именно]
|
- ЗАДАЧА: [какая именно]
|
||||||
@ -295,15 +428,18 @@
|
|||||||
### ЭКСТРЕННАЯ ОСТАНОВКА И УТОЧНЕНИЯ
|
### ЭКСТРЕННАЯ ОСТАНОВКА И УТОЧНЕНИЯ
|
||||||
|
|
||||||
#### Команда остановки:
|
#### Команда остановки:
|
||||||
|
|
||||||
**"СТОП - ЧИТАЙ ПРАВИЛА"** - немедленно останавливает любую работу
|
**"СТОП - ЧИТАЙ ПРАВИЛА"** - немедленно останавливает любую работу
|
||||||
|
|
||||||
#### Обязательные остановки при:
|
#### Обязательные остановки при:
|
||||||
|
|
||||||
- Неопределенности или сомнениях
|
- Неопределенности или сомнениях
|
||||||
- Средних/сложных задачах без протокола
|
- Средних/сложных задачах без протокола
|
||||||
- Противоречиях в правилах
|
- Противоречиях в правилах
|
||||||
- Анализе компонентов без использования инструментов
|
- Анализе компонентов без использования инструментов
|
||||||
|
|
||||||
#### Формат уточняющих вопросов:
|
#### Формат уточняющих вопросов:
|
||||||
|
|
||||||
```
|
```
|
||||||
🎯 КОНТЕКСТ: Что именно я делаю
|
🎯 КОНТЕКСТ: Что именно я делаю
|
||||||
❓ ВОПРОС: Что конкретно неясно
|
❓ ВОПРОС: Что конкретно неясно
|
||||||
@ -317,12 +453,14 @@
|
|||||||
## 7. СПЕЦИФИКА ПРОЕКТА SFERA
|
## 7. СПЕЦИФИКА ПРОЕКТА SFERA
|
||||||
|
|
||||||
### ТЕХНОЛОГИИ:
|
### ТЕХНОЛОГИИ:
|
||||||
|
|
||||||
- Next.js 15 + TypeScript (строгая типизация)
|
- Next.js 15 + TypeScript (строгая типизация)
|
||||||
- GraphQL (не менять схемы без запроса)
|
- GraphQL (не менять схемы без запроса)
|
||||||
- Prisma (миграции только по команде)
|
- Prisma (миграции только по команде)
|
||||||
- Git (коммиты только когда попросят)
|
- Git (коммиты только когда попросят)
|
||||||
|
|
||||||
### СТРУКТУРА:
|
### СТРУКТУРА:
|
||||||
|
|
||||||
- /src/app - страницы Next.js
|
- /src/app - страницы Next.js
|
||||||
- /src/components - React компоненты
|
- /src/components - React компоненты
|
||||||
- /src/graphql - API слой
|
- /src/graphql - API слой
|
||||||
@ -335,6 +473,7 @@
|
|||||||
- /legacy-rules - архив правил (не трогать)
|
- /legacy-rules - архив правил (не трогать)
|
||||||
|
|
||||||
### КОМАНДЫ:
|
### КОМАНДЫ:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Разработка
|
npm run dev # Разработка
|
||||||
npm run build # Сборка production
|
npm run build # Сборка production
|
||||||
@ -348,6 +487,7 @@ npx prisma studio # GUI для базы данных
|
|||||||
```
|
```
|
||||||
|
|
||||||
### API ИНТЕГРАЦИИ:
|
### API ИНТЕГРАЦИИ:
|
||||||
|
|
||||||
- **Wildberries/Ozon** - маркетплейсы
|
- **Wildberries/Ozon** - маркетплейсы
|
||||||
- **DaData** - валидация ИНН и реквизитов
|
- **DaData** - валидация ИНН и реквизитов
|
||||||
- **SMS Aero** - отправка SMS для авторизации
|
- **SMS Aero** - отправка SMS для авторизации
|
||||||
@ -358,47 +498,57 @@ npx prisma studio # GUI для базы данных
|
|||||||
#### СТРУКТУРА ДОКУМЕНТАЦИИ СИСТЕМЫ:
|
#### СТРУКТУРА ДОКУМЕНТАЦИИ СИСТЕМЫ:
|
||||||
|
|
||||||
**🎯 CORE - Ядро системы**
|
**🎯 CORE - Ядро системы**
|
||||||
|
|
||||||
- **DOMAIN_MODEL.md** - 4 типа организаций, основные сущности
|
- **DOMAIN_MODEL.md** - 4 типа организаций, основные сущности
|
||||||
- **BUSINESS_RULES_CORE.md** - Ключевые бизнес-правила: доступ, партнерство, расходники
|
- **BUSINESS_RULES_CORE.md** - Ключевые бизнес-правила: доступ, партнерство, расходники
|
||||||
|
|
||||||
**🔌 API_LAYER - Уровень API**
|
**🔌 API_LAYER - Уровень API**
|
||||||
|
|
||||||
- **GRAPHQL_SCHEMA_RULES.md** - Правила GraphQL схемы: типы, enums, безопасность
|
- **GRAPHQL_SCHEMA_RULES.md** - Правила GraphQL схемы: типы, enums, безопасность
|
||||||
|
|
||||||
**💾 DATA_LAYER - Уровень данных**
|
**💾 DATA_LAYER - Уровень данных**
|
||||||
|
|
||||||
- **PRISMA_MODEL_RULES.md** - Правила Prisma моделей: структуры, связи, миграции
|
- **PRISMA_MODEL_RULES.md** - Правила Prisma моделей: структуры, связи, миграции
|
||||||
|
|
||||||
**🎨 PRESENTATION_LAYER - Уровень представления**
|
**🎨 PRESENTATION_LAYER - Уровень представления**
|
||||||
|
|
||||||
- **COMPONENT_ARCHITECTURE.md** - Архитектура React компонентов: модульность, hooks, patterns
|
- **COMPONENT_ARCHITECTURE.md** - Архитектура React компонентов: модульность, hooks, patterns
|
||||||
|
|
||||||
**🏢 ORGANIZATION_TYPES - Домены по типам организаций**
|
**🏢 ORGANIZATION_TYPES - Домены по типам организаций**
|
||||||
|
|
||||||
- **FULFILLMENT_DOMAIN.md** - Домен фулфилмента: двойная система расходников, workflow
|
- **FULFILLMENT_DOMAIN.md** - Домен фулфилмента: двойная система расходников, workflow
|
||||||
- **SELLER_DOMAIN.md** - Домен селлеров: маркетплейсы, рецептуры, изоляция данных
|
- **SELLER_DOMAIN.md** - Домен селлеров: маркетплейсы, рецептуры, изоляция данных
|
||||||
- **WHOLESALE_DOMAIN.md** - Домен поставщиков: каталог, входящие заказы, координация
|
- **WHOLESALE_DOMAIN.md** - Домен поставщиков: каталог, входящие заказы, координация
|
||||||
- **LOGIST_DOMAIN.md** - Домен логистики: маршруты, ценообразование по объему
|
- **LOGIST_DOMAIN.md** - Домен логистики: маршруты, ценообразование по объему
|
||||||
|
|
||||||
**🔄 BUSINESS_PROCESSES - Бизнес-процессы**
|
**🔄 BUSINESS_PROCESSES - Бизнес-процессы**
|
||||||
|
|
||||||
- **SUPPLY_CHAIN_WORKFLOW.md** - Цепочка поставок: 8 статусов, роли, переходы
|
- **SUPPLY_CHAIN_WORKFLOW.md** - Цепочка поставок: 8 статусов, роли, переходы
|
||||||
- **PARTNERSHIP_SYSTEM.md** - Система партнерства: заявки, автопартнерство, бонусы
|
- **PARTNERSHIP_SYSTEM.md** - Система партнерства: заявки, автопартнерство, бонусы
|
||||||
|
|
||||||
#### АЛГОРИТМ ВЫБОРА ДОКУМЕНТАЦИИ:
|
#### АЛГОРИТМ ВЫБОРА ДОКУМЕНТАЦИИ:
|
||||||
|
|
||||||
**ПРИ СОЗДАНИИ НОВЫХ КОМПОНЕНТОВ:**
|
**ПРИ СОЗДАНИИ НОВЫХ КОМПОНЕНТОВ:**
|
||||||
|
|
||||||
1. **MODULAR_ARCHITECTURE_PATTERN.md** - Архитектурные требования (СНАЧАЛА)
|
1. **MODULAR_ARCHITECTURE_PATTERN.md** - Архитектурные требования (СНАЧАЛА)
|
||||||
2. **COMPONENT_ARCHITECTURE.md** - Паттерны реализации React компонентов
|
2. **COMPONENT_ARCHITECTURE.md** - Паттерны реализации React компонентов
|
||||||
3. **DOMAIN_MODEL.md** - Понимание доменных сущностей
|
3. **DOMAIN_MODEL.md** - Понимание доменных сущностей
|
||||||
4. Соответствующий **organization-types/*.md** - Специфика типа организации
|
4. Соответствующий **organization-types/\*.md** - Специфика типа организации
|
||||||
|
|
||||||
**ПРИ РАБОТЕ С API:**
|
**ПРИ РАБОТЕ С API:**
|
||||||
|
|
||||||
1. **GRAPHQL_SCHEMA_RULES.md** - Правила схемы
|
1. **GRAPHQL_SCHEMA_RULES.md** - Правила схемы
|
||||||
2. **BUSINESS_RULES_CORE.md** - Бизнес-логика
|
2. **BUSINESS_RULES_CORE.md** - Бизнес-логика
|
||||||
3. **PRISMA_MODEL_RULES.md** - Модели данных
|
3. **PRISMA_MODEL_RULES.md** - Модели данных
|
||||||
|
|
||||||
**ПРИ WORKFLOW ПОСТАВОК:**
|
**ПРИ WORKFLOW ПОСТАВОК:**
|
||||||
|
|
||||||
1. **SUPPLY_CHAIN_WORKFLOW.md** - Полный процесс
|
1. **SUPPLY_CHAIN_WORKFLOW.md** - Полный процесс
|
||||||
2. Релевантные **organization-types/*.md** - Роли участников
|
2. Релевантные **organization-types/\*.md** - Роли участников
|
||||||
3. **BUSINESS_RULES_CORE.md** - Правила доступа
|
3. **BUSINESS_RULES_CORE.md** - Правила доступа
|
||||||
|
|
||||||
#### АВТОМАТИЧЕСКИЕ ТРИГГЕРЫ ЧТЕНИЯ:
|
#### АВТОМАТИЧЕСКИЕ ТРИГГЕРЫ ЧТЕНИЯ:
|
||||||
|
|
||||||
- **Упоминание "создай компонент"** → MODULAR_ARCHITECTURE_PATTERN.md + COMPONENT_ARCHITECTURE.md
|
- **Упоминание "создай компонент"** → MODULAR_ARCHITECTURE_PATTERN.md + COMPONENT_ARCHITECTURE.md
|
||||||
- **Упоминание "новый компонент"** → MODULAR_ARCHITECTURE_PATTERN.md + COMPONENT_ARCHITECTURE.md
|
- **Упоминание "новый компонент"** → MODULAR_ARCHITECTURE_PATTERN.md + COMPONENT_ARCHITECTURE.md
|
||||||
- **Упоминание "архитектура"** → MODULAR_ARCHITECTURE_PATTERN.md
|
- **Упоминание "архитектура"** → MODULAR_ARCHITECTURE_PATTERN.md
|
||||||
@ -409,6 +559,7 @@ npx prisma studio # GUI для базы данных
|
|||||||
- **Упоминание "GraphQL"** → GRAPHQL_SCHEMA_RULES.md
|
- **Упоминание "GraphQL"** → GRAPHQL_SCHEMA_RULES.md
|
||||||
- **Упоминание "компонент"** → COMPONENT_ARCHITECTURE.md
|
- **Упоминание "компонент"** → COMPONENT_ARCHITECTURE.md
|
||||||
- **Упоминание "поставки"** → SUPPLY_CHAIN_WORKFLOW.md
|
- **Упоминание "поставки"** → SUPPLY_CHAIN_WORKFLOW.md
|
||||||
|
- **Упоминание "создай страницу", "новая страница", "создай форму", "новая форма", "создай таблицу", "новая таблица"** → АВТОМАТИЧЕСКИ применять модульную архитектуру
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -417,6 +568,7 @@ npx prisma studio # GUI для базы данных
|
|||||||
### ОТЧЕТНОСТЬ
|
### ОТЧЕТНОСТЬ
|
||||||
|
|
||||||
После выполнения всегда показывать:
|
После выполнения всегда показывать:
|
||||||
|
|
||||||
```
|
```
|
||||||
✅ СДЕЛАНО:
|
✅ СДЕЛАНО:
|
||||||
- Создал файл X
|
- Создал файл X
|
||||||
@ -433,11 +585,13 @@ npx prisma studio # GUI для базы данных
|
|||||||
### КОМАНДЫ ОТКАТА ЧЕРЕЗ КОММЕНТАРИИ
|
### КОМАНДЫ ОТКАТА ЧЕРЕЗ КОММЕНТАРИИ
|
||||||
|
|
||||||
#### Основная команда:
|
#### Основная команда:
|
||||||
|
|
||||||
```
|
```
|
||||||
"откати [описание] через комментарии"
|
"откати [описание] через комментарии"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Примеры использования:**
|
**Примеры использования:**
|
||||||
|
|
||||||
- `"откати центрирование поиска через комментарии"`
|
- `"откати центрирование поиска через комментарии"`
|
||||||
- `"откати изменения кнопки через комментарии"`
|
- `"откати изменения кнопки через комментарии"`
|
||||||
- `"откати новую логику через комментарии"`
|
- `"откати новую логику через комментарии"`
|
||||||
@ -445,16 +599,15 @@ npx prisma studio # GUI для базы данных
|
|||||||
#### Алгоритм выполнения:
|
#### Алгоритм выполнения:
|
||||||
|
|
||||||
**ЭТАП 1: ВОССТАНОВЛЕНИЕ ИСХОДНОГО КОДА**
|
**ЭТАП 1: ВОССТАНОВЛЕНИЕ ИСХОДНОГО КОДА**
|
||||||
|
|
||||||
1. Найти измененный код в текущих файлах
|
1. Найти измененный код в текущих файлах
|
||||||
2. Извлечь исходный код из git истории
|
2. Извлечь исходный код из git истории
|
||||||
3. Восстановить исходную функциональность
|
3. Восстановить исходную функциональность
|
||||||
|
|
||||||
**ЭТАП 2: СОЗДАНИЕ СИСТЕМЫ ПЕРЕКЛЮЧЕНИЯ**
|
**ЭТАП 2: СОЗДАНИЕ СИСТЕМЫ ПЕРЕКЛЮЧЕНИЯ** 4. Оставить **Вариант 1** (исходный) - активным 5. Добавить **Вариант 2** (измененный) в комментариях 6. Добавить четкие описания для каждого варианта
|
||||||
4. Оставить **Вариант 1** (исходный) - активным
|
|
||||||
5. Добавить **Вариант 2** (измененный) в комментариях
|
|
||||||
6. Добавить четкие описания для каждого варианта
|
|
||||||
|
|
||||||
**Пример структуры кода:**
|
**Пример структуры кода:**
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
// Вариант 1: Исходный (активный)
|
// Вариант 1: Исходный (активный)
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -470,11 +623,13 @@ npx prisma studio # GUI для базы данных
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Дополнительные команды:
|
#### Дополнительные команды:
|
||||||
|
|
||||||
- `"очисти комментарии"` - удалить закомментированные варианты
|
- `"очисти комментарии"` - удалить закомментированные варианты
|
||||||
- `"переключи на вариант 2"` - активировать закомментированный код
|
- `"переключи на вариант 2"` - активировать закомментированный код
|
||||||
- `"покажи варианты"` - показать доступные варианты
|
- `"покажи варианты"` - показать доступные варианты
|
||||||
|
|
||||||
#### ПРАВИЛА ПРИМЕНЕНИЯ:
|
#### ПРАВИЛА ПРИМЕНЕНИЯ:
|
||||||
|
|
||||||
- ✅ **Использовать для UI экспериментов** и небольших логических изменений
|
- ✅ **Использовать для UI экспериментов** и небольших логических изменений
|
||||||
- ✅ **Всегда добавлять четкие комментарии** с описанием вариантов
|
- ✅ **Всегда добавлять четкие комментарии** с описанием вариантов
|
||||||
- ✅ **Очищать комментарии перед финальным коммитом**
|
- ✅ **Очищать комментарии перед финальным коммитом**
|
||||||
@ -483,12 +638,14 @@ npx prisma studio # GUI для базы данных
|
|||||||
### ДОКУМЕНТИРОВАНИЕ ИЗМЕНЕНИЙ
|
### ДОКУМЕНТИРОВАНИЕ ИЗМЕНЕНИЙ
|
||||||
|
|
||||||
#### При любых изменениях документировать:
|
#### При любых изменениях документировать:
|
||||||
|
|
||||||
- **ЧТО** изменено (конкретные файлы и функции)
|
- **ЧТО** изменено (конкретные файлы и функции)
|
||||||
- **ПОЧЕМУ** изменено (обоснование решения)
|
- **ПОЧЕМУ** изменено (обоснование решения)
|
||||||
- **КТО** принял решение об изменении (пользователь/автоматически)
|
- **КТО** принял решение об изменении (пользователь/автоматически)
|
||||||
- **КОГДА** изменено (временная метка)
|
- **КОГДА** изменено (временная метка)
|
||||||
|
|
||||||
**Формат документирования:**
|
**Формат документирования:**
|
||||||
|
|
||||||
```
|
```
|
||||||
📝 ИЗМЕНЕНИЕ ЗАФИКСИРОВАНО:
|
📝 ИЗМЕНЕНИЕ ЗАФИКСИРОВАНО:
|
||||||
- ДАТА: [когда]
|
- ДАТА: [когда]
|
||||||
@ -500,11 +657,13 @@ npx prisma studio # GUI для базы данных
|
|||||||
### АНАЛИЗ ПРИМЕРОВ КОДА
|
### АНАЛИЗ ПРИМЕРОВ КОДА
|
||||||
|
|
||||||
#### Трехуровневый анализ примеров:
|
#### Трехуровневый анализ примеров:
|
||||||
|
|
||||||
1. **📋 СОДЕРЖАТЕЛЬНЫЙ** - что делает код (функциональность, логика, данные)
|
1. **📋 СОДЕРЖАТЕЛЬНЫЙ** - что делает код (функциональность, логика, данные)
|
||||||
2. **🏗️ АРХИТЕКТУРНЫЙ** - как организован (структура, взаимосвязи, позиционирование)
|
2. **🏗️ АРХИТЕКТУРНЫЙ** - как организован (структура, взаимосвязи, позиционирование)
|
||||||
3. **🎨 СТИЛЕВОЙ** - как выглядит (CSS классы, анимации, цвета)
|
3. **🎨 СТИЛЕВОЙ** - как выглядит (CSS классы, анимации, цвета)
|
||||||
|
|
||||||
#### Алгоритм анализа примера:
|
#### Алгоритм анализа примера:
|
||||||
|
|
||||||
1. **Прочитать** весь код компонента-примера
|
1. **Прочитать** весь код компонента-примера
|
||||||
2. **Понять архитектуру** - где элемент размещен относительно других
|
2. **Понять архитектуру** - где элемент размещен относительно других
|
||||||
3. **Понять логику** - почему именно так структурировано
|
3. **Понять логику** - почему именно так структурировано
|
||||||
@ -512,6 +671,7 @@ npx prisma studio # GUI для базы данных
|
|||||||
5. **Проверить** соответствие правилам проекта
|
5. **Проверить** соответствие правилам проекта
|
||||||
|
|
||||||
#### Стоп-вопросы перед реализацией:
|
#### Стоп-вопросы перед реализацией:
|
||||||
|
|
||||||
- "Понимаю ли я **архитектуру** этого решения?"
|
- "Понимаю ли я **архитектуру** этого решения?"
|
||||||
- "Где именно должен располагаться элемент в **общей структуре**?"
|
- "Где именно должен располагаться элемент в **общей структуре**?"
|
||||||
- "Какова **семантическая роль** этого элемента?"
|
- "Какова **семантическая роль** этого элемента?"
|
||||||
@ -524,12 +684,14 @@ npx prisma studio # GUI для базы данных
|
|||||||
**CASE STUDY: Ошибка с плавающей кнопкой из UI Kit**
|
**CASE STUDY: Ошибка с плавающей кнопкой из UI Kit**
|
||||||
|
|
||||||
**❌ ОШИБКА**: При добавлении кнопки "🌟 Вариант 1: Плавающая кнопка слева":
|
**❌ ОШИБКА**: При добавлении кнопки "🌟 Вариант 1: Плавающая кнопка слева":
|
||||||
|
|
||||||
1. **Поверхностный анализ**: Скопировал только стили кнопки
|
1. **Поверхностный анализ**: Скопировал только стили кнопки
|
||||||
2. **Игнорирование архитектуры**: Не заметил, что кнопка в **отдельном контейнере**
|
2. **Игнорирование архитектуры**: Не заметил, что кнопка в **отдельном контейнере**
|
||||||
3. **Неправильное размещение**: Добавил как часть блока контента
|
3. **Неправильное размещение**: Добавил как часть блока контента
|
||||||
4. **Непонимание термина**: "Плавающая" = независимая от контента, между элементами
|
4. **Непонимание термина**: "Плавающая" = независимая от контента, между элементами
|
||||||
|
|
||||||
#### ОБЯЗАТЕЛЬНЫЙ ЧЕК-ЛИСТ ДЛЯ UI KIT КОМПОНЕНТОВ:
|
#### ОБЯЗАТЕЛЬНЫЙ ЧЕК-ЛИСТ ДЛЯ UI KIT КОМПОНЕНТОВ:
|
||||||
|
|
||||||
```
|
```
|
||||||
🔍 ПЕРЕД РЕАЛИЗАЦИЕЙ:
|
🔍 ПЕРЕД РЕАЛИЗАЦИЕЙ:
|
||||||
□ Прочитал ВЕСЬ код компонента-примера
|
□ Прочитал ВЕСЬ код компонента-примера
|
||||||
@ -541,12 +703,14 @@ npx prisma studio # GUI для базы данных
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### АНТИ-ПАТТЕРНЫ:
|
#### АНТИ-ПАТТЕРНЫ:
|
||||||
|
|
||||||
- **"Быстрое копирование"** - копировать стили без понимания архитектуры
|
- **"Быстрое копирование"** - копировать стили без понимания архитектуры
|
||||||
- **"Частичный анализ"** - читать только нужную часть кода
|
- **"Частичный анализ"** - читать только нужную часть кода
|
||||||
- **"Буквальное применение"** - использовать без адаптации к контексту
|
- **"Буквальное применение"** - использовать без адаптации к контексту
|
||||||
- **"Игнорирование контейнеров"** - не обращать внимание на DOM-структуру
|
- **"Игнорирование контейнеров"** - не обращать внимание на DOM-структуру
|
||||||
|
|
||||||
#### ПРАВИЛЬНЫЕ ПАТТЕРНЫ:
|
#### ПРАВИЛЬНЫЕ ПАТТЕРНЫ:
|
||||||
|
|
||||||
- **"Архитектурный анализ первым"** - понять структуру, потом стили
|
- **"Архитектурный анализ первым"** - понять структуру, потом стили
|
||||||
- **"Контекстная адаптация"** - применять принципы, а не код буквально
|
- **"Контекстная адаптация"** - применять принципы, а не код буквально
|
||||||
- **"Семантическое понимание"** - осознавать роль каждого элемента
|
- **"Семантическое понимание"** - осознавать роль каждого элемента
|
||||||
@ -572,11 +736,13 @@ npx prisma studio # GUI для базы данных
|
|||||||
### РАБОТА С КОНТЕКСТОМ
|
### РАБОТА С КОНТЕКСТОМ
|
||||||
|
|
||||||
#### Файлы контекста:
|
#### Файлы контекста:
|
||||||
|
|
||||||
- **current-session.md** - текущие задачи и решения
|
- **current-session.md** - текущие задачи и решения
|
||||||
- **CLAUDE.md** - эти правила (загружаются автоматически)
|
- **CLAUDE.md** - эти правила (загружаются автоматически)
|
||||||
- **TodoWrite** - инструмент для отслеживания задач
|
- **TodoWrite** - инструмент для отслеживания задач
|
||||||
|
|
||||||
#### При потере контекста:
|
#### При потере контекста:
|
||||||
|
|
||||||
1. Прочитать current-session.md
|
1. Прочитать current-session.md
|
||||||
2. Проверить TodoWrite
|
2. Проверить TodoWrite
|
||||||
3. Спросить у пользователя о текущей задаче
|
3. Спросить у пользователя о текущей задаче
|
||||||
@ -584,7 +750,8 @@ npx prisma studio # GUI для базы данных
|
|||||||
---
|
---
|
||||||
|
|
||||||
# Важные напоминания для Claude Code
|
# Важные напоминания для Claude Code
|
||||||
|
|
||||||
Делай только то, что просят; ни больше, ни меньше.
|
Делай только то, что просят; ни больше, ни меньше.
|
||||||
НИКОГДА не создавай файлы, если они не абсолютно необходимы для достижения цели.
|
НИКОГДА не создавай файлы, если они не абсолютно необходимы для достижения цели.
|
||||||
ВСЕГДА отдавай предпочтение редактированию существующего файла, а не созданию нового.
|
ВСЕГДА отдавай предпочтение редактированию существующего файла, а не созданию нового.
|
||||||
НИКОГДА не создавай проактивно файлы документации (*.md) или README файлы. Создавай файлы документации только по явной просьбе пользователя.
|
НИКОГДА не создавай проактивно файлы документации (\*.md) или README файлы. Создавай файлы документации только по явной просьбе пользователя.
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# 🏗️ Паттерн модульной архитектуры для React компонентов
|
# 🏗️ Паттерн модульной архитектуры для React компонентов
|
||||||
|
|
||||||
> ⚠️ **ОФИЦИАЛЬНЫЙ СТАНДАРТ АРХИТЕКТУРЫ SFERA**
|
> ⚠️ **ОФИЦИАЛЬНЫЙ СТАНДАРТ АРХИТЕКТУРЫ SFERA**
|
||||||
> Этот документ описывает **ОБЯЗАТЕЛЬНУЮ** архитектуру для всех новых компонентов >500 строк и рефакторинга существующих >800 строк.
|
> Этот документ описывает **ОБЯЗАТЕЛЬНУЮ** модульную архитектуру по умолчанию для большинства компонентов в системе SFERA.
|
||||||
|
|
||||||
## 🎯 СТАТУС АРХИТЕКТУРНОГО СТАНДАРТА
|
## 🎯 СТАТУС АРХИТЕКТУРНОГО СТАНДАРТА
|
||||||
|
|
||||||
@ -12,13 +12,79 @@
|
|||||||
|
|
||||||
### 📋 ПРАВИЛА ПРИМЕНЕНИЯ:
|
### 📋 ПРАВИЛА ПРИМЕНЕНИЯ:
|
||||||
|
|
||||||
1. **ВСЕ НОВЫЕ КОМПОНЕНТЫ >500 строк** → создавать по модульной архитектуре
|
#### ✅ ВСЕГДА МОДУЛЬНАЯ АРХИТЕКТУРА (без исключений):
|
||||||
2. **Существующие компоненты >800 строк** → рефакторить по возможности
|
|
||||||
3. **Обязательно использовать** этот паттерн для компонентов dashboard, creation, management
|
|
||||||
|
|
||||||
## 🎯 Применимость паттерна
|
**1. Страницы и основные разделы:**
|
||||||
|
|
||||||
### Кандидаты для рефакторинга:
|
- ВСЕ файлы `page.tsx` в `/app/**/`
|
||||||
|
- ВСЕ дашборды любых типов
|
||||||
|
- ВСЕ разделы управления (supplies, employees, products, settings)
|
||||||
|
- ВСЕ wizard/multi-step компоненты
|
||||||
|
|
||||||
|
**2. Формы (даже простые!):**
|
||||||
|
|
||||||
|
- ЛЮБАЯ форма создания/редактирования сущности
|
||||||
|
- Формы с >3 полей
|
||||||
|
- Многошаговые формы
|
||||||
|
- Формы с динамическими полями
|
||||||
|
- _Почему: формы ВСЕГДА разрастаются (валидация, автозаполнение, зависимые поля)_
|
||||||
|
|
||||||
|
**3. Таблицы и списки:**
|
||||||
|
|
||||||
|
- ЛЮБАЯ таблица с данными из БД
|
||||||
|
- Таблицы с фильтрацией/сортировкой
|
||||||
|
- Таблицы с inline-редактированием
|
||||||
|
- Списки с действиями (approve/reject/delete)
|
||||||
|
- _Почему: всегда добавятся фильтры, сортировка, экспорт, bulk-операции_
|
||||||
|
|
||||||
|
**4. Комплексные компоненты:**
|
||||||
|
|
||||||
|
- Чаты/мессенджеры
|
||||||
|
- Календари/планировщики
|
||||||
|
- Графики/аналитика
|
||||||
|
- Файловые менеджеры
|
||||||
|
- Корзины/калькуляторы
|
||||||
|
|
||||||
|
#### ❌ ИСКЛЮЧЕНИЯ (только эти остаются простыми):
|
||||||
|
|
||||||
|
1. **Чистые UI из Radix/shadcn** - уже оптимизированы
|
||||||
|
2. **Stateless компоненты** < 50 строк без логики
|
||||||
|
3. **Чистые Layout** компоненты (Header/Footer)
|
||||||
|
4. **Utility компоненты** (ErrorBoundary, LoadingState)
|
||||||
|
5. **Простые модалки** подтверждения (Да/Нет)
|
||||||
|
|
||||||
|
## 🎯 КЛЮЧЕВОЕ ПРАВИЛО SFERA
|
||||||
|
|
||||||
|
> **"Если сомневаешься - делай модульным. Лучше иметь избыточную структуру папок, чем 2000-строчный спагетти-код через месяц."**
|
||||||
|
|
||||||
|
### 📏 Критерии принятия решения:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Алгоритм принятия решения о модульности
|
||||||
|
function shouldUseModularArchitecture(component) {
|
||||||
|
// Автоматически ДА
|
||||||
|
if (
|
||||||
|
component.type === 'page' ||
|
||||||
|
component.type === 'dashboard' ||
|
||||||
|
component.type === 'form' ||
|
||||||
|
component.type === 'table' ||
|
||||||
|
component.expectedSize > 300
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем сложность
|
||||||
|
const complexityScore =
|
||||||
|
component.stateVariables + // количество useState
|
||||||
|
component.apiCalls * 2 + // API вызовы весят больше
|
||||||
|
component.formFields + // поля форм
|
||||||
|
(component.hasBusinessLogic ? 3 : 0) // бизнес-логика
|
||||||
|
|
||||||
|
return complexityScore >= 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Кандидаты для рефакторинга (legacy компонентов):
|
||||||
|
|
||||||
- **Размер**: >800 строк кода
|
- **Размер**: >800 строк кода
|
||||||
- **Сложность**: Множественные состояния и бизнес-логика
|
- **Сложность**: Множественные состояния и бизнес-логика
|
||||||
@ -287,10 +353,11 @@ function MainComponent() {
|
|||||||
|
|
||||||
### Перед началом
|
### Перед началом
|
||||||
|
|
||||||
- [ ] Компонент больше 800 строк
|
- [ ] Компонент относится к категории: page, form, table, dashboard
|
||||||
- [ ] Есть несколько логических секций UI
|
- [ ] ИЛИ ожидаемый размер >300 строк
|
||||||
- [ ] Множественные useState и useEffect
|
- [ ] ИЛИ сложность по алгоритму ≥5 баллов
|
||||||
- [ ] Активно развивающийся функционал
|
- [ ] ИЛИ есть несколько логических секций UI
|
||||||
|
- [ ] ИЛИ множественные useState и useEffect
|
||||||
|
|
||||||
### Планирование
|
### Планирование
|
||||||
|
|
||||||
@ -324,10 +391,28 @@ function MainComponent() {
|
|||||||
|
|
||||||
## 🎯 Заключение
|
## 🎯 Заключение
|
||||||
|
|
||||||
Модульная архитектура значительно улучшает качество кода, скорость разработки и поддержки. Применяйте этот паттерн к большим компонентам постепенно, следуя принципам безопасного рефакторинга.
|
Модульная архитектура по умолчанию значительно улучшает качество кода, скорость разработки и поддержки в долгосрочной перспективе. В SFERA мы применяем принцип "лучше переструктурировать сразу, чем рефакторить потом".
|
||||||
|
|
||||||
|
### 🎯 Практические примеры применения:
|
||||||
|
|
||||||
|
**Модульные (новые стандарты):**
|
||||||
|
|
||||||
|
- `/app/suppliers/create/page.tsx` → CreateSupplierPage/
|
||||||
|
- `EmployeeManagementTable` → EmployeeManagement/
|
||||||
|
- `SupplyOrderForm` → SupplyOrderForm/
|
||||||
|
- `ProductCatalog` → ProductCatalog/
|
||||||
|
- `UserSettingsForm` → UserSettingsForm/
|
||||||
|
|
||||||
|
**Простые (исключения):**
|
||||||
|
|
||||||
|
- `Logo.tsx`
|
||||||
|
- `LoadingDots.tsx`
|
||||||
|
- `ConfirmDialog.tsx`
|
||||||
|
- `PriceDisplay.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Основано на**: Успешном рефакторинге create-suppliers-supply-page.tsx (1,467→240 строк)
|
**Основано на**: Успешном рефакторинге create-suppliers-supply-page.tsx (1,467→240 строк)
|
||||||
|
**Обновлено**: Синхронизировано с правилами CLAUDE.md
|
||||||
**Автор паттерна**: Claude Code
|
**Автор паттерна**: Claude Code
|
||||||
**Дата**: Август 2025
|
**Дата**: Август 2025
|
||||||
|
@ -26,6 +26,7 @@ model User {
|
|||||||
|
|
||||||
// === НОВЫЕ СВЯЗИ С ПРИЕМКОЙ ПОСТАВОК V2 ===
|
// === НОВЫЕ СВЯЗИ С ПРИЕМКОЙ ПОСТАВОК V2 ===
|
||||||
fulfillmentSupplyOrdersReceived FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersReceiver")
|
fulfillmentSupplyOrdersReceived FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersReceiver")
|
||||||
|
sellerSupplyOrdersReceived SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersReceiver")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -130,6 +131,15 @@ model Organization {
|
|||||||
fulfillmentSupplyOrdersAsSupplier FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersSupplier")
|
fulfillmentSupplyOrdersAsSupplier FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersSupplier")
|
||||||
fulfillmentSupplyOrdersAsLogistics FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersLogistics")
|
fulfillmentSupplyOrdersAsLogistics FulfillmentConsumableSupplyOrder[] @relation("FFSupplyOrdersLogistics")
|
||||||
|
|
||||||
|
// Поставки расходников селлера
|
||||||
|
sellerSupplyOrdersAsSeller SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersSeller")
|
||||||
|
sellerSupplyOrdersAsFulfillment SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersFulfillment")
|
||||||
|
sellerSupplyOrdersAsSupplier SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersSupplier")
|
||||||
|
|
||||||
|
// === НОВЫЕ СВЯЗИ СО СКЛАДСКИМИ ОСТАТКАМИ V2 ===
|
||||||
|
fulfillmentInventory FulfillmentConsumableInventory[] @relation("FFInventory")
|
||||||
|
sellerSupplyOrdersAsLogistics SellerConsumableSupplyOrder[] @relation("SellerSupplyOrdersLogistics")
|
||||||
|
|
||||||
@@index([referralCode])
|
@@index([referralCode])
|
||||||
@@index([referredById])
|
@@index([referredById])
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
@ -295,6 +305,10 @@ model Product {
|
|||||||
|
|
||||||
// === НОВЫЕ СВЯЗИ С ПОСТАВКАМИ V2 ===
|
// === НОВЫЕ СВЯЗИ С ПОСТАВКАМИ V2 ===
|
||||||
fulfillmentSupplyItems FulfillmentConsumableSupplyItem[] @relation("FFSupplyItems")
|
fulfillmentSupplyItems FulfillmentConsumableSupplyItem[] @relation("FFSupplyItems")
|
||||||
|
sellerSupplyItems SellerConsumableSupplyItem[] @relation("SellerSupplyItems")
|
||||||
|
|
||||||
|
// === НОВЫЕ СВЯЗИ СО СКЛАДСКИМИ ОСТАТКАМИ V2 ===
|
||||||
|
inventoryRecords FulfillmentConsumableInventory[] @relation("InventoryProducts")
|
||||||
|
|
||||||
@@unique([organizationId, article])
|
@@unique([organizationId, article])
|
||||||
@@map("products")
|
@@map("products")
|
||||||
@ -760,6 +774,16 @@ enum SupplyOrderStatusV2 {
|
|||||||
CANCELLED // Отменено
|
CANCELLED // Отменено
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5-статусная система для поставок расходников селлера
|
||||||
|
enum SellerSupplyOrderStatus {
|
||||||
|
PENDING // Ожидает одобрения поставщика
|
||||||
|
APPROVED // Одобрено поставщиком
|
||||||
|
SHIPPED // Отгружено
|
||||||
|
DELIVERED // Доставлено
|
||||||
|
COMPLETED // Завершено
|
||||||
|
CANCELLED // Отменено
|
||||||
|
}
|
||||||
|
|
||||||
// Модель для поставок расходников фулфилмента
|
// Модель для поставок расходников фулфилмента
|
||||||
model FulfillmentConsumableSupplyOrder {
|
model FulfillmentConsumableSupplyOrder {
|
||||||
// === БАЗОВЫЕ ПОЛЯ ===
|
// === БАЗОВЫЕ ПОЛЯ ===
|
||||||
@ -837,3 +861,135 @@ model FulfillmentConsumableSupplyItem {
|
|||||||
@@unique([supplyOrderId, productId])
|
@@unique([supplyOrderId, productId])
|
||||||
@@map("fulfillment_consumable_supply_items")
|
@@map("fulfillment_consumable_supply_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 📦 СИСТЕМА ПОСТАВОК РАСХОДНИКОВ СЕЛЛЕРА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Модель для поставок расходников селлера
|
||||||
|
model SellerConsumableSupplyOrder {
|
||||||
|
// === БАЗОВЫЕ ПОЛЯ ===
|
||||||
|
id String @id @default(cuid())
|
||||||
|
status SellerSupplyOrderStatus @default(PENDING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// === ДАННЫЕ СЕЛЛЕРА (создатель) ===
|
||||||
|
sellerId String // кто заказывает (FK: Organization SELLER)
|
||||||
|
fulfillmentCenterId String // куда доставлять (FK: Organization FULFILLMENT)
|
||||||
|
requestedDeliveryDate DateTime // когда нужно
|
||||||
|
notes String? // заметки селлера
|
||||||
|
|
||||||
|
// === ДАННЫЕ ПОСТАВЩИКА ===
|
||||||
|
supplierId String? // кто поставляет (FK: Organization WHOLESALE)
|
||||||
|
supplierApprovedAt DateTime? // когда одобрил
|
||||||
|
packagesCount Int? // количество грузомест
|
||||||
|
estimatedVolume Decimal? @db.Decimal(8, 3) // объем груза в м³
|
||||||
|
supplierContractId String? // номер договора
|
||||||
|
supplierNotes String? // заметки поставщика
|
||||||
|
|
||||||
|
// === ДАННЫЕ ЛОГИСТИКИ ===
|
||||||
|
logisticsPartnerId String? // кто везет (FK: Organization LOGIST)
|
||||||
|
estimatedDeliveryDate DateTime? // план доставки
|
||||||
|
routeId String? // маршрут (FK: LogisticsRoute)
|
||||||
|
logisticsCost Decimal? @db.Decimal(10, 2) // стоимость доставки
|
||||||
|
logisticsNotes String? // заметки логистики
|
||||||
|
|
||||||
|
// === ДАННЫЕ ОТГРУЗКИ ===
|
||||||
|
shippedAt DateTime? // факт отгрузки
|
||||||
|
trackingNumber String? // номер отслеживания
|
||||||
|
|
||||||
|
// === ДАННЫЕ ПРИЕМКИ ===
|
||||||
|
deliveredAt DateTime? // факт доставки в ФФ
|
||||||
|
receivedById String? // кто принял в ФФ (FK: User)
|
||||||
|
actualQuantity Int? // принято количество
|
||||||
|
defectQuantity Int? // брак
|
||||||
|
receiptNotes String? // заметки приемки
|
||||||
|
|
||||||
|
// === ЭКОНОМИКА (для будущего раздела экономики) ===
|
||||||
|
totalCostWithDelivery Decimal? @db.Decimal(12, 2) // общая стоимость с доставкой
|
||||||
|
estimatedStorageCost Decimal? @db.Decimal(10, 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================================
|
||||||
|
// INVENTORY SYSTEM V2.0 - СКЛАДСКИЕ ОСТАТКИ
|
||||||
|
// ===============================================
|
||||||
|
|
||||||
|
// Складские остатки расходников фулфилмента
|
||||||
|
model FulfillmentConsumableInventory {
|
||||||
|
// === ИДЕНТИФИКАЦИЯ ===
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// === СВЯЗИ ===
|
||||||
|
fulfillmentCenterId String // где хранится (FK: Organization)
|
||||||
|
productId String // что хранится (FK: Product)
|
||||||
|
|
||||||
|
// === СКЛАДСКИЕ ДАННЫЕ ===
|
||||||
|
currentStock Int @default(0) // текущий остаток на складе
|
||||||
|
minStock Int @default(0) // минимальный порог для заказа
|
||||||
|
maxStock Int? // максимальный порог (опционально)
|
||||||
|
reservedStock Int @default(0) // зарезервировано для отгрузок
|
||||||
|
totalReceived Int @default(0) // всего получено с момента создания
|
||||||
|
totalShipped Int @default(0) // всего отгружено селлерам
|
||||||
|
|
||||||
|
// === ЦЕНЫ ===
|
||||||
|
averageCost Decimal @default(0) @db.Decimal(10, 2) // средняя себестоимость
|
||||||
|
resalePrice Decimal? @db.Decimal(10, 2) // цена продажи селлерам
|
||||||
|
|
||||||
|
// === МЕТАДАННЫЕ ===
|
||||||
|
lastSupplyDate DateTime? // последняя поставка
|
||||||
|
lastUsageDate DateTime? // последнее использование/отгрузка
|
||||||
|
notes String? // заметки по складскому учету
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// === СВЯЗИ ===
|
||||||
|
fulfillmentCenter Organization @relation("FFInventory", fields: [fulfillmentCenterId], references: [id])
|
||||||
|
product Product @relation("InventoryProducts", fields: [productId], references: [id])
|
||||||
|
|
||||||
|
// === ИНДЕКСЫ ===
|
||||||
|
@@unique([fulfillmentCenterId, productId]) // один товар = одна запись на фулфилмент
|
||||||
|
@@index([fulfillmentCenterId, currentStock])
|
||||||
|
@@index([currentStock, minStock]) // для поиска "заканчивающихся"
|
||||||
|
@@index([fulfillmentCenterId, lastSupplyDate])
|
||||||
|
@@map("fulfillment_consumable_inventory")
|
||||||
|
}
|
||||||
|
151
seller_supply_migration.sql
Normal file
151
seller_supply_migration.sql
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 📦 МИГРАЦИЯ ДЛЯ СИСТЕМЫ ПОСТАВОК РАСХОДНИКОВ СЕЛЛЕРА
|
||||||
|
-- =============================================================================
|
||||||
|
-- Создание: Новые таблицы для селлерских поставок расходников
|
||||||
|
-- Автор: Claude Code AI Assistant
|
||||||
|
-- Дата: $(date)
|
||||||
|
|
||||||
|
-- Создание нового enum для статусов селлера (5-статусная система)
|
||||||
|
CREATE TYPE "SellerSupplyOrderStatus" AS ENUM (
|
||||||
|
'PENDING', -- Ожидает одобрения поставщика
|
||||||
|
'APPROVED', -- Одобрено поставщиком
|
||||||
|
'SHIPPED', -- Отгружено
|
||||||
|
'DELIVERED', -- Доставлено
|
||||||
|
'COMPLETED', -- Завершено
|
||||||
|
'CANCELLED' -- Отменено
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Основная таблица поставок расходников селлера
|
||||||
|
CREATE TABLE "seller_consumable_supply_orders" (
|
||||||
|
-- === БАЗОВЫЕ ПОЛЯ ===
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"status" "SellerSupplyOrderStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
-- === ДАННЫЕ СЕЛЛЕРА (создатель) ===
|
||||||
|
"sellerId" TEXT NOT NULL, -- кто заказывает (FK: Organization SELLER)
|
||||||
|
"fulfillmentCenterId" TEXT NOT NULL, -- куда доставлять (FK: Organization FULFILLMENT)
|
||||||
|
"requestedDeliveryDate" TIMESTAMP(3) NOT NULL, -- когда нужно
|
||||||
|
"notes" TEXT, -- заметки селлера
|
||||||
|
|
||||||
|
-- === ДАННЫЕ ПОСТАВЩИКА ===
|
||||||
|
"supplierId" TEXT, -- кто поставляет (FK: Organization WHOLESALE)
|
||||||
|
"supplierApprovedAt" TIMESTAMP(3), -- когда одобрил
|
||||||
|
"packagesCount" INTEGER, -- количество грузомест
|
||||||
|
"estimatedVolume" DECIMAL(8,3), -- объем груза в м³
|
||||||
|
"supplierContractId" TEXT, -- номер договора
|
||||||
|
"supplierNotes" TEXT, -- заметки поставщика
|
||||||
|
|
||||||
|
-- === ДАННЫЕ ЛОГИСТИКИ ===
|
||||||
|
"logisticsPartnerId" TEXT, -- кто везет (FK: Organization LOGIST)
|
||||||
|
"estimatedDeliveryDate" TIMESTAMP(3), -- план доставки
|
||||||
|
"routeId" TEXT, -- маршрут (FK: LogisticsRoute)
|
||||||
|
"logisticsCost" DECIMAL(10,2), -- стоимость доставки
|
||||||
|
"logisticsNotes" TEXT, -- заметки логистики
|
||||||
|
|
||||||
|
-- === ДАННЫЕ ОТГРУЗКИ ===
|
||||||
|
"shippedAt" TIMESTAMP(3), -- факт отгрузки
|
||||||
|
"trackingNumber" TEXT, -- номер отслеживания
|
||||||
|
|
||||||
|
-- === ДАННЫЕ ПРИЕМКИ ===
|
||||||
|
"deliveredAt" TIMESTAMP(3), -- факт доставки в ФФ
|
||||||
|
"receivedById" TEXT, -- кто принял в ФФ (FK: User)
|
||||||
|
"actualQuantity" INTEGER, -- принято количество
|
||||||
|
"defectQuantity" INTEGER, -- брак
|
||||||
|
"receiptNotes" TEXT, -- заметки приемки
|
||||||
|
|
||||||
|
-- === ЭКОНОМИКА (для будущего раздела экономики) ===
|
||||||
|
"totalCostWithDelivery" DECIMAL(12,2), -- общая стоимость с доставкой
|
||||||
|
"estimatedStorageCost" DECIMAL(10,2), -- оценочная стоимость хранения
|
||||||
|
|
||||||
|
CONSTRAINT "seller_consumable_supply_orders_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Позиции в поставке расходников селлера
|
||||||
|
CREATE TABLE "seller_consumable_supply_items" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"supplyOrderId" TEXT NOT NULL, -- связь с поставкой
|
||||||
|
"productId" TEXT NOT NULL, -- какой расходник (FK: Product)
|
||||||
|
|
||||||
|
-- === КОЛИЧЕСТВА ===
|
||||||
|
"requestedQuantity" INTEGER NOT NULL, -- запросили
|
||||||
|
"approvedQuantity" INTEGER, -- поставщик одобрил
|
||||||
|
"shippedQuantity" INTEGER, -- отгрузили
|
||||||
|
"receivedQuantity" INTEGER, -- приняли в ФФ
|
||||||
|
"defectQuantity" INTEGER DEFAULT 0, -- брак
|
||||||
|
|
||||||
|
-- === ЦЕНЫ ===
|
||||||
|
"unitPrice" DECIMAL(10,2) NOT NULL, -- цена за единицу от поставщика
|
||||||
|
"totalPrice" DECIMAL(12,2) NOT NULL, -- общая стоимость
|
||||||
|
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "seller_consumable_supply_items_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- === СОЗДАНИЕ ИНДЕКСОВ ===
|
||||||
|
CREATE UNIQUE INDEX "seller_consumable_supply_items_supplyOrderId_productId_key"
|
||||||
|
ON "seller_consumable_supply_items"("supplyOrderId", "productId");
|
||||||
|
|
||||||
|
-- === СОЗДАНИЕ ВНЕШНИХ КЛЮЧЕЙ ===
|
||||||
|
|
||||||
|
-- Seller Supply Orders связи
|
||||||
|
ALTER TABLE "seller_consumable_supply_orders"
|
||||||
|
ADD CONSTRAINT "seller_consumable_supply_orders_sellerId_fkey"
|
||||||
|
FOREIGN KEY ("sellerId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "seller_consumable_supply_orders"
|
||||||
|
ADD CONSTRAINT "seller_consumable_supply_orders_fulfillmentCenterId_fkey"
|
||||||
|
FOREIGN KEY ("fulfillmentCenterId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "seller_consumable_supply_orders"
|
||||||
|
ADD CONSTRAINT "seller_consumable_supply_orders_supplierId_fkey"
|
||||||
|
FOREIGN KEY ("supplierId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "seller_consumable_supply_orders"
|
||||||
|
ADD CONSTRAINT "seller_consumable_supply_orders_logisticsPartnerId_fkey"
|
||||||
|
FOREIGN KEY ("logisticsPartnerId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "seller_consumable_supply_orders"
|
||||||
|
ADD CONSTRAINT "seller_consumable_supply_orders_receivedById_fkey"
|
||||||
|
FOREIGN KEY ("receivedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Seller Supply Items связи
|
||||||
|
ALTER TABLE "seller_consumable_supply_items"
|
||||||
|
ADD CONSTRAINT "seller_consumable_supply_items_supplyOrderId_fkey"
|
||||||
|
FOREIGN KEY ("supplyOrderId") REFERENCES "seller_consumable_supply_orders"("id") ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "seller_consumable_supply_items"
|
||||||
|
ADD CONSTRAINT "seller_consumable_supply_items_productId_fkey"
|
||||||
|
FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- === ДОБАВЛЕНИЕ СВЯЗЕЙ В СУЩЕСТВУЮЩИЕ ТАБЛИЦЫ ===
|
||||||
|
|
||||||
|
-- Добавление связей в organizations (если они еще не существуют)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Проверяем существование колонок перед добавлением
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'organizations'
|
||||||
|
AND column_name = 'sellerSupplyOrdersAsSeller'
|
||||||
|
) THEN
|
||||||
|
-- Добавляем связи будут созданы автоматически через Prisma при следующем generate
|
||||||
|
RAISE NOTICE 'Связи для селлерских поставок будут созданы автоматически при prisma generate';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Комментарии к таблицам
|
||||||
|
COMMENT ON TABLE "seller_consumable_supply_orders" IS 'Поставки расходников селлера - заказы от селлеров для доставки в фулфилмент-центры';
|
||||||
|
COMMENT ON TABLE "seller_consumable_supply_items" IS 'Позиции в поставках расходников селлера';
|
||||||
|
|
||||||
|
-- Комментарии к ключевым полям
|
||||||
|
COMMENT ON COLUMN "seller_consumable_supply_orders"."sellerId" IS 'Селлер-заказчик (тип SELLER)';
|
||||||
|
COMMENT ON COLUMN "seller_consumable_supply_orders"."fulfillmentCenterId" IS 'Фулфилмент-получатель (тип FULFILLMENT)';
|
||||||
|
COMMENT ON COLUMN "seller_consumable_supply_orders"."supplierId" IS 'Поставщик товаров (тип WHOLESALE)';
|
||||||
|
COMMENT ON COLUMN "seller_consumable_supply_orders"."totalCostWithDelivery" IS 'Для будущего раздела экономики селлера';
|
||||||
|
|
||||||
|
RAISE NOTICE 'Система поставок расходников селлера успешно создана!';
|
@ -0,0 +1,307 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 📦 БЛОК РАСХОДНИКОВ
|
||||||
|
// =============================================================================
|
||||||
|
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в монолитной версии!
|
||||||
|
// Все gradients, glassmorphism, анимации, индикаторы остатков сохранены
|
||||||
|
|
||||||
|
import { Search, Wrench, Package, Plus, Minus } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
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 type { FulfillmentConsumableProduct } from '../types'
|
||||||
|
|
||||||
|
import type { ConsumablesBlockProps } from './types'
|
||||||
|
|
||||||
|
export function ConsumablesBlock({
|
||||||
|
selectedSupplier,
|
||||||
|
products,
|
||||||
|
productsLoading,
|
||||||
|
productSearchQuery,
|
||||||
|
getSelectedQuantity,
|
||||||
|
onProductSearchChange,
|
||||||
|
onUpdateQuantity,
|
||||||
|
formatCurrency,
|
||||||
|
}: ConsumablesBlockProps) {
|
||||||
|
return (
|
||||||
|
<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) => onProductSearchChange(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>
|
||||||
|
) : products.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">
|
||||||
|
{products.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={() =>
|
||||||
|
onUpdateQuantity(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)
|
||||||
|
|
||||||
|
onUpdateQuantity(product.id, clampedValue)
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
// При потере фокуса, если поле пустое, устанавливаем 0
|
||||||
|
if (e.target.value === '') {
|
||||||
|
onUpdateQuantity(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={() =>
|
||||||
|
onUpdateQuantity(
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 📄 БЛОК ЗАГОЛОВКА СТРАНИЦЫ
|
||||||
|
// =============================================================================
|
||||||
|
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в монолитной версии!
|
||||||
|
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
import type { PageHeaderProps } from './types'
|
||||||
|
|
||||||
|
export function PageHeader({ onBack }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<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={onBack}
|
||||||
|
className="text-white/60 hover:text-white hover:bg-white/10 text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,143 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🛒 БЛОК КОРЗИНЫ
|
||||||
|
// =============================================================================
|
||||||
|
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в монолитной версии!
|
||||||
|
// Все gradients, glassmorphism, анимации сохранены
|
||||||
|
|
||||||
|
import { ShoppingCart } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
import type { ShoppingCartBlockProps } from './types'
|
||||||
|
|
||||||
|
export function ShoppingCartBlock({
|
||||||
|
selectedConsumables,
|
||||||
|
deliveryDate,
|
||||||
|
notes,
|
||||||
|
selectedLogistics,
|
||||||
|
logisticsPartners,
|
||||||
|
isCreatingSupply,
|
||||||
|
getTotalAmount,
|
||||||
|
getTotalItems,
|
||||||
|
formatCurrency,
|
||||||
|
onUpdateQuantity,
|
||||||
|
onSetDeliveryDate,
|
||||||
|
onSetNotes,
|
||||||
|
onSetLogistics,
|
||||||
|
onCreateSupply,
|
||||||
|
}: ShoppingCartBlockProps) {
|
||||||
|
return (
|
||||||
|
<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={() => onUpdateQuantity(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) => onSetDeliveryDate(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)
|
||||||
|
onSetLogistics(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) => onSetNotes(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={onCreateSupply}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,143 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🏢 БЛОК ПОСТАВЩИКОВ
|
||||||
|
// =============================================================================
|
||||||
|
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в монолитной версии!
|
||||||
|
// Все gradients, glassmorphism, анимации сохранены
|
||||||
|
|
||||||
|
import { Building2, Search } from 'lucide-react'
|
||||||
|
|
||||||
|
import { OrganizationAvatar } from '@/components/market/organization-avatar'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
import type { FulfillmentConsumableSupplier } from '../types'
|
||||||
|
|
||||||
|
import type { SuppliersBlockProps } from './types'
|
||||||
|
|
||||||
|
export function SuppliersBlock({
|
||||||
|
suppliers,
|
||||||
|
filteredSuppliers,
|
||||||
|
selectedSupplier,
|
||||||
|
searchQuery,
|
||||||
|
loading,
|
||||||
|
onSelectSupplier,
|
||||||
|
onSearchChange,
|
||||||
|
}: SuppliersBlockProps) {
|
||||||
|
return (
|
||||||
|
<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) => onSearchChange(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={() => onSelectSupplier(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">
|
||||||
|
{loading ? (
|
||||||
|
<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={() => onSelectSupplier(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>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🧩 UI BLOCKS ДЛЯ СИСТЕМЫ СОЗДАНИЯ ПОСТАВОК РАСХОДНИКОВ ФУЛФИЛМЕНТА V2
|
||||||
|
// =============================================================================
|
||||||
|
// Эти блоки содержат весь UI, экстрагированный из монолитного компонента
|
||||||
|
// с сохранением ТОЧНО ТАКОГО ЖЕ визуала в соответствии с модульной архитектурой
|
||||||
|
|
||||||
|
export { PageHeader } from './PageHeader'
|
||||||
|
export { SuppliersBlock } from './SuppliersBlock'
|
||||||
|
export { ConsumablesBlock } from './ConsumablesBlock'
|
||||||
|
export { ShoppingCartBlock } from './ShoppingCartBlock'
|
||||||
|
|
||||||
|
// 🎯 Экспорт типов для блоков
|
||||||
|
export type {
|
||||||
|
PageHeaderProps,
|
||||||
|
SuppliersBlockProps,
|
||||||
|
ConsumablesBlockProps,
|
||||||
|
ShoppingCartBlockProps,
|
||||||
|
} from './types'
|
@ -0,0 +1,168 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🛒 БЛОК КОРЗИНЫ ДЛЯ СЕЛЛЕРА
|
||||||
|
// =============================================================================
|
||||||
|
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в оригинале!
|
||||||
|
// Отличие - показываем выбранный фулфилмент-центр и другие лейблы
|
||||||
|
|
||||||
|
import { ShoppingCart, Building2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
import type { SellerShoppingCartBlockProps } from '../types/seller-types'
|
||||||
|
|
||||||
|
export function SellerShoppingCartBlock({
|
||||||
|
selectedConsumables,
|
||||||
|
selectedFulfillment,
|
||||||
|
deliveryDate,
|
||||||
|
notes,
|
||||||
|
selectedLogistics,
|
||||||
|
logisticsPartners,
|
||||||
|
isCreatingSupply,
|
||||||
|
getTotalAmount,
|
||||||
|
getTotalItems,
|
||||||
|
formatCurrency,
|
||||||
|
onUpdateQuantity,
|
||||||
|
onSetDeliveryDate,
|
||||||
|
onSetNotes,
|
||||||
|
onSetLogistics,
|
||||||
|
onCreateSupply,
|
||||||
|
}: SellerShoppingCartBlockProps) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 🏢 ИНФОРМАЦИЯ О ВЫБРАННОМ ФУЛФИЛМЕНТ-ЦЕНТРЕ */}
|
||||||
|
{selectedFulfillment && (
|
||||||
|
<div className="mb-3 p-2 bg-white/5 rounded-lg border border-green-400/20">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Building2 className="h-4 w-4 text-green-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-green-400 text-xs font-medium">Доставка в:</p>
|
||||||
|
<p className="text-white text-xs">
|
||||||
|
{selectedFulfillment.name || selectedFulfillment.fullName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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-1">Выберите поставщика и</p>
|
||||||
|
<p className="text-white/40 text-xs">добавьте расходники для заказа</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={() => onUpdateQuantity(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) => onSetDeliveryDate(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)
|
||||||
|
onSetLogistics(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) => onSetNotes(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={onCreateSupply}
|
||||||
|
disabled={isCreatingSupply || !deliveryDate || selectedConsumables.length === 0 || !selectedFulfillment}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Подсказка для селлера */}
|
||||||
|
{selectedConsumables.length > 0 && !selectedFulfillment && (
|
||||||
|
<p className="text-amber-400 text-xs mt-2 text-center">
|
||||||
|
Выберите фулфилмент-центр для доставки
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,181 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🏪 БЛОК ВЫБОРА ПОСТАВЩИКОВ И ФУЛФИЛМЕНТ-ЦЕНТРОВ ДЛЯ СЕЛЛЕРА
|
||||||
|
// =============================================================================
|
||||||
|
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в оригинале!
|
||||||
|
// Отличие только в логике - селлер выбирает и поставщика, и фулфилмент-центр
|
||||||
|
|
||||||
|
import { Building2, Search } from 'lucide-react'
|
||||||
|
|
||||||
|
import { OrganizationAvatar } from '@/components/market/organization-avatar'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
import type { SellerSuppliersBlockProps } from '../types/seller-types'
|
||||||
|
|
||||||
|
export function SellerSuppliersBlock({
|
||||||
|
suppliers,
|
||||||
|
fulfillmentCenters,
|
||||||
|
filteredSuppliers,
|
||||||
|
filteredFulfillmentCenters,
|
||||||
|
selectedSupplier,
|
||||||
|
selectedFulfillment,
|
||||||
|
searchQuery,
|
||||||
|
loading,
|
||||||
|
onSelectSupplier,
|
||||||
|
onSelectFulfillment,
|
||||||
|
onSearchChange,
|
||||||
|
}: SellerSuppliersBlockProps) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 flex-shrink-0">
|
||||||
|
<h3 className="text-white font-semibold mb-3 flex items-center text-sm">
|
||||||
|
<Building2 className="h-4 w-4 mr-2" />
|
||||||
|
Партнеры ({suppliers.length + fulfillmentCenters.length})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 🔍 Поиск партнеров */}
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/40 h-4 w-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск поставщиков и фулфилмент-центров..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-10 bg-white/10 border-white/20 text-white placeholder-white/40 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<div className="animate-pulse text-white/60 text-sm">Загрузка партнеров...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
|
||||||
|
{/* 🏪 СЕКЦИЯ ПОСТАВЩИКОВ */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white/80 font-medium text-xs mb-2 uppercase tracking-wide">
|
||||||
|
Поставщики расходников ({filteredSuppliers.length})
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid gap-2 max-h-48 overflow-y-auto">
|
||||||
|
{filteredSuppliers.length === 0 ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<Building2 className="h-8 w-8 text-white/20 mx-auto mb-2" />
|
||||||
|
<p className="text-white/40 text-xs">
|
||||||
|
{searchQuery ? 'Поставщики не найдены' : 'Нет поставщиков'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredSuppliers.map((supplier, index) => (
|
||||||
|
<div
|
||||||
|
key={supplier.id}
|
||||||
|
onClick={() => onSelectSupplier(supplier)}
|
||||||
|
className={`
|
||||||
|
p-3 rounded-lg cursor-pointer transition-all duration-200 border
|
||||||
|
${
|
||||||
|
selectedSupplier?.id === supplier.id
|
||||||
|
? 'bg-gradient-to-br from-blue-500/30 to-purple-500/30 border-blue-400/50 shadow-lg'
|
||||||
|
: 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${index * 50}ms`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<OrganizationAvatar
|
||||||
|
organization={supplier}
|
||||||
|
size="sm"
|
||||||
|
className={
|
||||||
|
selectedSupplier?.id === supplier.id
|
||||||
|
? 'ring-2 ring-blue-400/50'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white font-medium text-sm truncate">
|
||||||
|
{supplier.name || supplier.fullName || supplier.inn}
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 text-xs">ИНН: {supplier.inn}</p>
|
||||||
|
{supplier.address && (
|
||||||
|
<p className="text-white/40 text-xs truncate">{supplier.address}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedSupplier?.id === supplier.id && (
|
||||||
|
<div className="bg-blue-500/20 rounded-full p-1">
|
||||||
|
<div className="h-2 w-2 bg-blue-400 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 🏢 СЕКЦИЯ ФУЛФИЛМЕНТ-ЦЕНТРОВ */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white/80 font-medium text-xs mb-2 uppercase tracking-wide">
|
||||||
|
Фулфилмент-центры ({filteredFulfillmentCenters.length})
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid gap-2 max-h-48 overflow-y-auto">
|
||||||
|
{filteredFulfillmentCenters.length === 0 ? (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<Building2 className="h-8 w-8 text-white/20 mx-auto mb-2" />
|
||||||
|
<p className="text-white/40 text-xs">
|
||||||
|
{searchQuery ? 'Фулфилмент-центры не найдены' : 'Нет фулфилмент-центров'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredFulfillmentCenters.map((fulfillment, index) => (
|
||||||
|
<div
|
||||||
|
key={fulfillment.id}
|
||||||
|
onClick={() => onSelectFulfillment(fulfillment)}
|
||||||
|
className={`
|
||||||
|
p-3 rounded-lg cursor-pointer transition-all duration-200 border
|
||||||
|
${
|
||||||
|
selectedFulfillment?.id === fulfillment.id
|
||||||
|
? 'bg-gradient-to-br from-green-500/30 to-emerald-500/30 border-green-400/50 shadow-lg'
|
||||||
|
: 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${(index + filteredSuppliers.length) * 50}ms`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<OrganizationAvatar
|
||||||
|
organization={fulfillment}
|
||||||
|
size="sm"
|
||||||
|
className={
|
||||||
|
selectedFulfillment?.id === fulfillment.id
|
||||||
|
? 'ring-2 ring-green-400/50'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white font-medium text-sm truncate">
|
||||||
|
{fulfillment.name || fulfillment.fullName || fulfillment.inn}
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 text-xs">ИНН: {fulfillment.inn}</p>
|
||||||
|
{fulfillment.address && (
|
||||||
|
<p className="text-white/40 text-xs truncate">{fulfillment.address}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedFulfillment?.id === fulfillment.id && (
|
||||||
|
<div className="bg-green-500/20 rounded-full p-1">
|
||||||
|
<div className="h-2 w-2 bg-green-400 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 📦 ЭКСПОРТ СЕЛЛЕРСКИХ UI БЛОКОВ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export { SellerSuppliersBlock } from './SellerSuppliersBlock'
|
||||||
|
export { SellerShoppingCartBlock } from './SellerShoppingCartBlock'
|
@ -0,0 +1,55 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🧩 ТИПЫ ДЛЯ UI BLOCKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FulfillmentConsumableSupplier,
|
||||||
|
FulfillmentConsumableProduct,
|
||||||
|
SelectedFulfillmentConsumable,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
// 📄 ТИПЫ ДЛЯ ЗАГОЛОВКА СТРАНИЦЫ
|
||||||
|
export interface PageHeaderProps {
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🏢 ТИПЫ ДЛЯ БЛОКА ПОСТАВЩИКОВ
|
||||||
|
export interface SuppliersBlockProps {
|
||||||
|
suppliers: FulfillmentConsumableSupplier[]
|
||||||
|
filteredSuppliers: FulfillmentConsumableSupplier[]
|
||||||
|
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||||
|
searchQuery: string
|
||||||
|
loading: boolean
|
||||||
|
onSelectSupplier: (supplier: FulfillmentConsumableSupplier | null) => void
|
||||||
|
onSearchChange: (query: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📦 ТИПЫ ДЛЯ БЛОКА РАСХОДНИКОВ
|
||||||
|
export interface ConsumablesBlockProps {
|
||||||
|
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||||
|
products: FulfillmentConsumableProduct[]
|
||||||
|
productsLoading: boolean
|
||||||
|
productSearchQuery: string
|
||||||
|
getSelectedQuantity: (productId: string) => number
|
||||||
|
onProductSearchChange: (query: string) => void
|
||||||
|
onUpdateQuantity: (productId: string, quantity: number) => void
|
||||||
|
formatCurrency: (amount: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛒 ТИПЫ ДЛЯ БЛОКА КОРЗИНЫ
|
||||||
|
export interface ShoppingCartBlockProps {
|
||||||
|
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||||
|
deliveryDate: string
|
||||||
|
notes: string
|
||||||
|
selectedLogistics: FulfillmentConsumableSupplier | null
|
||||||
|
logisticsPartners: FulfillmentConsumableSupplier[]
|
||||||
|
isCreatingSupply: boolean
|
||||||
|
getTotalAmount: () => number
|
||||||
|
getTotalItems: () => number
|
||||||
|
formatCurrency: (amount: number) => string
|
||||||
|
onUpdateQuantity: (productId: string, quantity: number) => void
|
||||||
|
onSetDeliveryDate: (date: string) => void
|
||||||
|
onSetNotes: (notes: string) => void
|
||||||
|
onSetLogistics: (logistics: FulfillmentConsumableSupplier | null) => void
|
||||||
|
onCreateSupply: () => void
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🔧 BUSINESS HOOKS ДЛЯ СИСТЕМЫ СОЗДАНИЯ ПОСТАВОК РАСХОДНИКОВ ФУЛФИЛМЕНТА V2
|
||||||
|
// =============================================================================
|
||||||
|
// Эти хуки содержат всю бизнес-логику, экстрагированную из монолитного компонента
|
||||||
|
// в соответствии с правилами модульной архитектуры MODULAR_ARCHITECTURE_PATTERN.md
|
||||||
|
|
||||||
|
export { useSupplierData } from './useSupplierData'
|
||||||
|
export { useProductData } from './useProductData'
|
||||||
|
export { useSupplyForm } from './useSupplyForm'
|
||||||
|
export { useQuantityManagement } from './useQuantityManagement'
|
||||||
|
export { useSupplyCreation } from './useSupplyCreation'
|
||||||
|
export { useCurrencyFormatting } from './useCurrencyFormatting'
|
||||||
|
export { useStockValidation } from './useStockValidation'
|
||||||
|
|
||||||
|
// 🎯 Экспорт типов для хуков
|
||||||
|
export type {
|
||||||
|
UseSupplierDataReturn,
|
||||||
|
UseProductDataReturn,
|
||||||
|
UseSupplyFormReturn,
|
||||||
|
UseQuantityManagementReturn,
|
||||||
|
UseSupplyCreationReturn,
|
||||||
|
} from './types'
|
@ -0,0 +1,7 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 📦 ЭКСПОРТ СЕЛЛЕРСКИХ БИЗНЕС ХУКОВ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export { useSellerSupplyCreation } from './useSellerSupplyCreation'
|
||||||
|
export { useSellerSupplyForm } from './useSellerSupplyForm'
|
||||||
|
export { useSellerSupplierData } from './useSellerSupplierData'
|
@ -0,0 +1,90 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🏢 ХУК ДЛЯ ПОЛУЧЕНИЯ ДАННЫХ ПОСТАВЩИКОВ И ФУЛФИЛМЕНТ-ЦЕНТРОВ (ДЛЯ СЕЛЛЕРА)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CounterpartiesData,
|
||||||
|
SellerConsumableSupplier,
|
||||||
|
UseSupplierDataReturn,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
interface UseSellerSupplierDataProps {
|
||||||
|
searchQuery: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSellerSupplierDataReturn {
|
||||||
|
// Поставщики (WHOLESALE)
|
||||||
|
suppliers: SellerConsumableSupplier[]
|
||||||
|
filteredSuppliers: SellerConsumableSupplier[]
|
||||||
|
|
||||||
|
// Фулфилмент-центры (FULFILLMENT)
|
||||||
|
fulfillmentCenters: SellerConsumableSupplier[]
|
||||||
|
filteredFulfillmentCenters: SellerConsumableSupplier[]
|
||||||
|
|
||||||
|
// Логистические партнеры (LOGIST)
|
||||||
|
logisticsPartners: SellerConsumableSupplier[]
|
||||||
|
|
||||||
|
// Состояние загрузки и ошибки
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSellerSupplierData({
|
||||||
|
searchQuery,
|
||||||
|
}: UseSellerSupplierDataProps): UseSellerSupplierDataReturn {
|
||||||
|
|
||||||
|
// 🔄 ЗАГРУЗКА КОНТРАГЕНТОВ
|
||||||
|
const { data, loading, error } = useQuery<CounterpartiesData>(GET_MY_COUNTERPARTIES)
|
||||||
|
|
||||||
|
// 📊 МЕМОИЗИРОВАННАЯ ОБРАБОТКА ДАННЫХ
|
||||||
|
const processedData = useMemo(() => {
|
||||||
|
const allPartners = data?.myCounterparties || []
|
||||||
|
|
||||||
|
// 🏪 ПОСТАВЩИКИ (где селлер заказывает товары)
|
||||||
|
const suppliers = allPartners.filter(partner => partner.type === 'WHOLESALE')
|
||||||
|
|
||||||
|
// 🏢 ФУЛФИЛМЕНТ-ЦЕНТРЫ (куда селлер доставляет товары)
|
||||||
|
const fulfillmentCenters = allPartners.filter(partner => partner.type === 'FULFILLMENT')
|
||||||
|
|
||||||
|
// 🚚 ЛОГИСТИЧЕСКИЕ ПАРТНЕРЫ (кто доставляет)
|
||||||
|
const logisticsPartners = allPartners.filter(partner => partner.type === 'LOGIST')
|
||||||
|
|
||||||
|
// 🔍 ФИЛЬТРАЦИЯ ПО ПОИСКУ
|
||||||
|
const searchLower = searchQuery.toLowerCase().trim()
|
||||||
|
|
||||||
|
const filteredSuppliers = searchLower
|
||||||
|
? suppliers.filter(supplier =>
|
||||||
|
supplier.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
supplier.fullName?.toLowerCase().includes(searchLower) ||
|
||||||
|
supplier.inn.includes(searchLower),
|
||||||
|
)
|
||||||
|
: suppliers
|
||||||
|
|
||||||
|
const filteredFulfillmentCenters = searchLower
|
||||||
|
? fulfillmentCenters.filter(ff =>
|
||||||
|
ff.name?.toLowerCase().includes(searchLower) ||
|
||||||
|
ff.fullName?.toLowerCase().includes(searchLower) ||
|
||||||
|
ff.inn.includes(searchLower),
|
||||||
|
)
|
||||||
|
: fulfillmentCenters
|
||||||
|
|
||||||
|
return {
|
||||||
|
suppliers,
|
||||||
|
fulfillmentCenters,
|
||||||
|
logisticsPartners,
|
||||||
|
filteredSuppliers,
|
||||||
|
filteredFulfillmentCenters,
|
||||||
|
}
|
||||||
|
}, [data?.myCounterparties, searchQuery])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...processedData,
|
||||||
|
loading,
|
||||||
|
error: error || null,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,124 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🚀 ХУК ДЛЯ СОЗДАНИЯ ПОСТАВКИ СЕЛЛЕРА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useMutation } from '@apollo/client'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import {
|
||||||
|
CREATE_SELLER_CONSUMABLE_SUPPLY,
|
||||||
|
GET_MY_SELLER_CONSUMABLE_SUPPLIES,
|
||||||
|
} from '@/graphql/queries/seller-consumables-v2'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SellerConsumableSupplier,
|
||||||
|
SelectedSellerConsumable,
|
||||||
|
SellerSupplyCreationInput,
|
||||||
|
CreateSellerSupplyMutationResponse,
|
||||||
|
UseSellerSupplyCreationReturn,
|
||||||
|
UseSellerSupplyCreationProps,
|
||||||
|
} from '../types/seller-types'
|
||||||
|
|
||||||
|
export function useSellerSupplyCreation({
|
||||||
|
selectedSupplier,
|
||||||
|
selectedFulfillment,
|
||||||
|
selectedConsumables,
|
||||||
|
deliveryDate,
|
||||||
|
notes,
|
||||||
|
resetForm,
|
||||||
|
}: UseSellerSupplyCreationProps): UseSellerSupplyCreationReturn {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Мутация для создания заказа поставки расходников селлера
|
||||||
|
const [createSupplyMutation] = useMutation<CreateSellerSupplyMutationResponse>(CREATE_SELLER_CONSUMABLE_SUPPLY)
|
||||||
|
|
||||||
|
// Функция создания поставки
|
||||||
|
const createSupply = useCallback(async () => {
|
||||||
|
// 🔍 ВАЛИДАЦИЯ ОБЯЗАТЕЛЬНЫХ ПОЛЕЙ ДЛЯ СЕЛЛЕРА
|
||||||
|
if (!selectedSupplier || !selectedFulfillment || selectedConsumables.length === 0 || !deliveryDate) {
|
||||||
|
const errorMessage = 'Заполните все обязательные поля: поставщик, фулфилмент-центр, расходники и дата доставки'
|
||||||
|
setError(errorMessage)
|
||||||
|
toast.error(errorMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 📊 ПОДГОТОВКА ДАННЫХ ДЛЯ СОЗДАНИЯ ПОСТАВКИ
|
||||||
|
const input: SellerSupplyCreationInput = {
|
||||||
|
fulfillmentCenterId: selectedFulfillment.id, // 🏢 Куда доставлять
|
||||||
|
supplierId: selectedSupplier.id, // 🏪 От кого заказывать
|
||||||
|
requestedDeliveryDate: deliveryDate, // 📅 Когда нужно
|
||||||
|
items: selectedConsumables.map((consumable) => ({
|
||||||
|
productId: consumable.id,
|
||||||
|
requestedQuantity: consumable.selectedQuantity,
|
||||||
|
})),
|
||||||
|
notes: notes || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🚀 Создание поставки селлера с данными:', input)
|
||||||
|
|
||||||
|
// 🔄 ВЫПОЛНЕНИЕ МУТАЦИИ
|
||||||
|
const result = await createSupplyMutation({
|
||||||
|
variables: { input },
|
||||||
|
refetchQueries: [
|
||||||
|
{
|
||||||
|
query: GET_MY_SELLER_CONSUMABLE_SUPPLIES,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
awaitRefetchQueries: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = result.data?.createSellerConsumableSupply
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
// ✅ УСПЕШНОЕ СОЗДАНИЕ
|
||||||
|
toast.success(response.message || 'Поставка успешно создана')
|
||||||
|
|
||||||
|
// Очищаем форму
|
||||||
|
resetForm()
|
||||||
|
|
||||||
|
// Переходим к списку поставок селлера
|
||||||
|
// TODO: Создать маршрут для списка поставок селлера
|
||||||
|
// router.push('/seller-supplies')
|
||||||
|
|
||||||
|
console.log('✅ Поставка селлера создана:', response.supplyOrder)
|
||||||
|
} else {
|
||||||
|
// ❌ ОШИБКА ИЗ СЕРВЕРА
|
||||||
|
const errorMessage = response?.message || 'Ошибка создания поставки'
|
||||||
|
setError(errorMessage)
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// ❌ ОШИБКА ВЫПОЛНЕНИЯ
|
||||||
|
console.error('❌ Ошибка создания поставки селлера:', err)
|
||||||
|
|
||||||
|
const errorMessage = err?.message || err?.graphQLErrors?.[0]?.message || 'Неизвестная ошибка при создании поставки'
|
||||||
|
setError(errorMessage)
|
||||||
|
toast.error(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
selectedSupplier,
|
||||||
|
selectedFulfillment,
|
||||||
|
selectedConsumables,
|
||||||
|
deliveryDate,
|
||||||
|
notes,
|
||||||
|
createSupplyMutation,
|
||||||
|
resetForm,
|
||||||
|
router,
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
createSupply,
|
||||||
|
isCreating,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 📋 ХУК ДЛЯ УПРАВЛЕНИЯ ФОРМОЙ ПОСТАВКИ СЕЛЛЕРА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SellerConsumableSupplier,
|
||||||
|
SelectedSellerConsumable,
|
||||||
|
UseSellerSupplyFormReturn,
|
||||||
|
} from '../types/seller-types'
|
||||||
|
|
||||||
|
export function useSellerSupplyForm(): UseSellerSupplyFormReturn {
|
||||||
|
// 🏪 СОСТОЯНИЕ ПОСТАВЩИКА
|
||||||
|
const [selectedSupplier, setSelectedSupplier] = useState<SellerConsumableSupplier | null>(null)
|
||||||
|
|
||||||
|
// 🏢 СОСТОЯНИЕ ФУЛФИЛМЕНТ-ЦЕНТРА (специфично для селлера)
|
||||||
|
const [selectedFulfillment, setSelectedFulfillment] = useState<SellerConsumableSupplier | null>(null)
|
||||||
|
|
||||||
|
// 🚚 СОСТОЯНИЕ ЛОГИСТИКИ
|
||||||
|
const [selectedLogistics, setSelectedLogistics] = useState<SellerConsumableSupplier | null>(null)
|
||||||
|
|
||||||
|
// 📦 СОСТОЯНИЕ ВЫБРАННЫХ РАСХОДНИКОВ
|
||||||
|
const [selectedConsumables, setSelectedConsumables] = useState<SelectedSellerConsumable[]>([])
|
||||||
|
|
||||||
|
// 🔍 СОСТОЯНИЕ ПОИСКА
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [productSearchQuery, setProductSearchQuery] = useState('')
|
||||||
|
|
||||||
|
// 📅 СОСТОЯНИЕ ДАТЫ ДОСТАВКИ
|
||||||
|
const [deliveryDate, setDeliveryDate] = useState('')
|
||||||
|
|
||||||
|
// 📝 СОСТОЯНИЕ ЗАМЕТОК
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
|
||||||
|
// 🔄 ФУНКЦИЯ СБРОСА ФОРМЫ
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setSelectedSupplier(null)
|
||||||
|
setSelectedFulfillment(null)
|
||||||
|
setSelectedLogistics(null)
|
||||||
|
setSelectedConsumables([])
|
||||||
|
setSearchQuery('')
|
||||||
|
setProductSearchQuery('')
|
||||||
|
setDeliveryDate('')
|
||||||
|
setNotes('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 🔧 СПЕЦИАЛЬНЫЕ СЕТТЕРЫ С ОЧИСТКОЙ ЗАВИСИМЫХ ПОЛЕЙ
|
||||||
|
|
||||||
|
const handleSetSelectedSupplier = useCallback((supplier: SellerConsumableSupplier | null) => {
|
||||||
|
setSelectedSupplier(supplier)
|
||||||
|
|
||||||
|
// При смене поставщика очищаем выбранные товары
|
||||||
|
setSelectedConsumables([])
|
||||||
|
setProductSearchQuery('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSetSelectedFulfillment = useCallback((fulfillment: SellerConsumableSupplier | null) => {
|
||||||
|
setSelectedFulfillment(fulfillment)
|
||||||
|
|
||||||
|
// При смене фулфилмента можем очистить логистику если нужно
|
||||||
|
// setSelectedLogistics(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSetSelectedLogistics = useCallback((logistics: SellerConsumableSupplier | null) => {
|
||||||
|
setSelectedLogistics(logistics)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Состояние
|
||||||
|
selectedSupplier,
|
||||||
|
selectedFulfillment,
|
||||||
|
selectedLogistics,
|
||||||
|
selectedConsumables,
|
||||||
|
searchQuery,
|
||||||
|
productSearchQuery,
|
||||||
|
deliveryDate,
|
||||||
|
notes,
|
||||||
|
|
||||||
|
// Сеттеры
|
||||||
|
setSelectedSupplier: handleSetSelectedSupplier,
|
||||||
|
setSelectedFulfillment: handleSetSelectedFulfillment,
|
||||||
|
setSelectedLogistics: handleSetSelectedLogistics,
|
||||||
|
setSelectedConsumables,
|
||||||
|
setSearchQuery,
|
||||||
|
setProductSearchQuery,
|
||||||
|
setDeliveryDate,
|
||||||
|
setNotes,
|
||||||
|
|
||||||
|
// Утилиты
|
||||||
|
resetForm,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🔧 ТИПЫ ДЛЯ BUSINESS HOOKS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FulfillmentConsumableSupplier,
|
||||||
|
FulfillmentConsumableProduct,
|
||||||
|
SelectedFulfillmentConsumable,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
// 🏢 ТИПЫ ДЛЯ ХУКА ПОСТАВЩИКОВ
|
||||||
|
export interface UseSupplierDataReturn {
|
||||||
|
suppliers: FulfillmentConsumableSupplier[]
|
||||||
|
logisticsPartners: FulfillmentConsumableSupplier[]
|
||||||
|
filteredSuppliers: FulfillmentConsumableSupplier[]
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📦 ТИПЫ ДЛЯ ХУКА ТОВАРОВ
|
||||||
|
export interface UseProductDataReturn {
|
||||||
|
products: FulfillmentConsumableProduct[]
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
refetch: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📋 ТИПЫ ДЛЯ ХУКА ФОРМЫ
|
||||||
|
export interface UseSupplyFormReturn {
|
||||||
|
// Состояние формы
|
||||||
|
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||||
|
selectedLogistics: FulfillmentConsumableSupplier | null
|
||||||
|
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||||
|
searchQuery: string
|
||||||
|
productSearchQuery: string
|
||||||
|
deliveryDate: string
|
||||||
|
notes: string
|
||||||
|
|
||||||
|
// Действия формы
|
||||||
|
setSelectedSupplier: (supplier: FulfillmentConsumableSupplier | null) => void
|
||||||
|
setSelectedLogistics: (logistics: FulfillmentConsumableSupplier | null) => void
|
||||||
|
setSelectedConsumables: (consumables: SelectedFulfillmentConsumable[]) => void
|
||||||
|
setSearchQuery: (query: string) => void
|
||||||
|
setProductSearchQuery: (query: string) => void
|
||||||
|
setDeliveryDate: (date: string) => void
|
||||||
|
setNotes: (notes: string) => void
|
||||||
|
|
||||||
|
// Сброс формы
|
||||||
|
resetForm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📊 ТИПЫ ДЛЯ ХУКА УПРАВЛЕНИЯ КОЛИЧЕСТВОМ
|
||||||
|
export interface UseQuantityManagementReturn {
|
||||||
|
updateConsumableQuantity: (productId: string, quantity: number) => void
|
||||||
|
getSelectedQuantity: (productId: string) => number
|
||||||
|
getTotalAmount: () => number
|
||||||
|
getTotalItems: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 ТИПЫ ДЛЯ ХУКА СОЗДАНИЯ ПОСТАВКИ
|
||||||
|
export interface UseSupplyCreationReturn {
|
||||||
|
createSupply: () => Promise<void>
|
||||||
|
isCreating: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 💰 ХУК ДЛЯ ФОРМАТИРОВАНИЯ ВАЛЮТЫ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
export function useCurrencyFormatting() {
|
||||||
|
// Форматирование валюты в рублях
|
||||||
|
const formatCurrency = useCallback((amount: number): string => {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(amount)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatCurrency,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 📦 ХУК ДЛЯ УПРАВЛЕНИЯ ДАННЫМИ ТОВАРОВ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { GET_ORGANIZATION_PRODUCTS } from '@/graphql/queries'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FulfillmentConsumableSupplier,
|
||||||
|
ProductsData,
|
||||||
|
UseProductDataReturn,
|
||||||
|
GraphQLQueryVariables,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
interface UseProductDataProps {
|
||||||
|
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||||
|
productSearchQuery: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProductData({
|
||||||
|
selectedSupplier,
|
||||||
|
productSearchQuery,
|
||||||
|
}: UseProductDataProps): UseProductDataReturn {
|
||||||
|
// Стабилизируем переменные для useQuery
|
||||||
|
const queryVariables = useMemo((): GraphQLQueryVariables => {
|
||||||
|
return {
|
||||||
|
organizationId: selectedSupplier?.id || '',
|
||||||
|
search: productSearchQuery || null,
|
||||||
|
category: null,
|
||||||
|
type: 'CONSUMABLE' as const, // Фильтруем только расходники согласно GraphQL правилам
|
||||||
|
}
|
||||||
|
}, [selectedSupplier?.id, productSearchQuery])
|
||||||
|
|
||||||
|
// Загружаем товары для выбранного поставщика с фильтрацией по типу CONSUMABLE
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<ProductsData>(GET_ORGANIZATION_PRODUCTS, {
|
||||||
|
skip: !selectedSupplier?.id, // Не запрашиваем без выбранного поставщика
|
||||||
|
variables: queryVariables,
|
||||||
|
onCompleted: (data) => {
|
||||||
|
// Логируем только количество загруженных товаров
|
||||||
|
console.warn(`📦 Загружено товаров: ${data?.organizationProducts?.length || 0}`)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('❌ GET_ORGANIZATION_PRODUCTS ERROR:', error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получаем товары поставщика (уже отфильтрованы в GraphQL запросе по типу CONSUMABLE)
|
||||||
|
const products = useMemo(() => {
|
||||||
|
return data?.organizationProducts || []
|
||||||
|
}, [data?.organizationProducts])
|
||||||
|
|
||||||
|
return {
|
||||||
|
products,
|
||||||
|
loading,
|
||||||
|
error: error || null,
|
||||||
|
refetch,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,96 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 📊 ХУК ДЛЯ УПРАВЛЕНИЯ КОЛИЧЕСТВОМ ТОВАРОВ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FulfillmentConsumableSupplier,
|
||||||
|
FulfillmentConsumableProduct,
|
||||||
|
SelectedFulfillmentConsumable,
|
||||||
|
UseQuantityManagementReturn,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
interface UseQuantityManagementProps {
|
||||||
|
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||||
|
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||||
|
products: FulfillmentConsumableProduct[]
|
||||||
|
setSelectedConsumables: (updater: SelectedFulfillmentConsumable[] | ((prev: SelectedFulfillmentConsumable[]) => SelectedFulfillmentConsumable[])) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQuantityManagement({
|
||||||
|
selectedSupplier,
|
||||||
|
selectedConsumables,
|
||||||
|
products,
|
||||||
|
setSelectedConsumables,
|
||||||
|
}: UseQuantityManagementProps): UseQuantityManagementReturn {
|
||||||
|
|
||||||
|
// Обновление количества выбранного расходника
|
||||||
|
const updateConsumableQuantity = useCallback((productId: string, quantity: number) => {
|
||||||
|
const product = products.find((p: FulfillmentConsumableProduct) => p.id === productId)
|
||||||
|
if (!product || !selectedSupplier) return
|
||||||
|
|
||||||
|
// 🔒 ВАЛИДАЦИЯ ОСТАТКОВ согласно бизнес-правилам (раздел 6.2)
|
||||||
|
if (quantity > 0) {
|
||||||
|
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0)
|
||||||
|
|
||||||
|
if (quantity > availableStock) {
|
||||||
|
toast.error(`❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedConsumables((prev: SelectedFulfillmentConsumable[]) => {
|
||||||
|
const existing = prev.find((p: SelectedFulfillmentConsumable) => p.id === productId)
|
||||||
|
|
||||||
|
if (quantity === 0) {
|
||||||
|
// Удаляем расходник если количество 0
|
||||||
|
return prev.filter((p: SelectedFulfillmentConsumable) => p.id !== productId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Обновляем количество существующего расходника
|
||||||
|
return prev.map((p: SelectedFulfillmentConsumable) => (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 || 'Поставщик',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [products, selectedSupplier, setSelectedConsumables])
|
||||||
|
|
||||||
|
// Получение выбранного количества для товара
|
||||||
|
const getSelectedQuantity = useCallback((productId: string): number => {
|
||||||
|
const selected = selectedConsumables.find((p) => p.id === productId)
|
||||||
|
return selected ? selected.selectedQuantity : 0
|
||||||
|
}, [selectedConsumables])
|
||||||
|
|
||||||
|
// Расчет общей стоимости
|
||||||
|
const getTotalAmount = useCallback(() => {
|
||||||
|
return selectedConsumables.reduce((sum, consumable) => sum + consumable.price * consumable.selectedQuantity, 0)
|
||||||
|
}, [selectedConsumables])
|
||||||
|
|
||||||
|
// Расчет общего количества товаров
|
||||||
|
const getTotalItems = useCallback(() => {
|
||||||
|
return selectedConsumables.reduce((sum, consumable) => sum + consumable.selectedQuantity, 0)
|
||||||
|
}, [selectedConsumables])
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateConsumableQuantity,
|
||||||
|
getSelectedQuantity,
|
||||||
|
getTotalAmount,
|
||||||
|
getTotalItems,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,76 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 📊 ХУК ДЛЯ ВАЛИДАЦИИ ОСТАТКОВ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FulfillmentConsumableProduct,
|
||||||
|
StockCalculation,
|
||||||
|
StockValidationResult,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export function useStockValidation() {
|
||||||
|
// Расчет остатков товара
|
||||||
|
const calculateStock = useCallback((product: FulfillmentConsumableProduct): StockCalculation => {
|
||||||
|
const totalStock = product.stock || product.quantity || 0
|
||||||
|
const orderedStock = product.ordered || 0
|
||||||
|
const availableStock = totalStock - orderedStock
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalStock,
|
||||||
|
orderedStock,
|
||||||
|
availableStock,
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Валидация запрашиваемого количества
|
||||||
|
const validateQuantity = useCallback((
|
||||||
|
product: FulfillmentConsumableProduct,
|
||||||
|
requestedQuantity: number,
|
||||||
|
): StockValidationResult => {
|
||||||
|
const { availableStock } = calculateStock(product)
|
||||||
|
|
||||||
|
if (requestedQuantity <= 0) {
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
availableStock,
|
||||||
|
requestedQuantity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedQuantity > availableStock) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
availableStock,
|
||||||
|
requestedQuantity,
|
||||||
|
error: `❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${requestedQuantity} шт.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
availableStock,
|
||||||
|
requestedQuantity,
|
||||||
|
}
|
||||||
|
}, [calculateStock])
|
||||||
|
|
||||||
|
// Определение статуса остатков для UI
|
||||||
|
const getStockStatus = useCallback((product: FulfillmentConsumableProduct): 'out_of_stock' | 'low_stock' | 'in_stock' => {
|
||||||
|
const { availableStock } = calculateStock(product)
|
||||||
|
|
||||||
|
if (availableStock <= 0) {
|
||||||
|
return 'out_of_stock'
|
||||||
|
} else if (availableStock <= 10) {
|
||||||
|
return 'low_stock'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'in_stock'
|
||||||
|
}, [calculateStock])
|
||||||
|
|
||||||
|
return {
|
||||||
|
calculateStock,
|
||||||
|
validateQuantity,
|
||||||
|
getStockStatus,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🏢 ХУК ДЛЯ УПРАВЛЕНИЯ ДАННЫМИ ПОСТАВЩИКОВ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FulfillmentConsumableSupplier,
|
||||||
|
CounterpartiesData,
|
||||||
|
UseSupplierDataReturn,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
interface UseSupplierDataProps {
|
||||||
|
searchQuery: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSupplierData({ searchQuery }: UseSupplierDataProps): UseSupplierDataReturn {
|
||||||
|
// Загружаем контрагентов-поставщиков расходников
|
||||||
|
const { data, loading, error } = useQuery<CounterpartiesData>(GET_MY_COUNTERPARTIES)
|
||||||
|
|
||||||
|
// Фильтруем только поставщиков расходников (WHOLESALE)
|
||||||
|
const suppliers = useMemo(() => {
|
||||||
|
return (data?.myCounterparties || []).filter(
|
||||||
|
(org: FulfillmentConsumableSupplier) => org.type === 'WHOLESALE',
|
||||||
|
)
|
||||||
|
}, [data?.myCounterparties])
|
||||||
|
|
||||||
|
// Фильтруем только логистические компании (LOGIST)
|
||||||
|
const logisticsPartners = useMemo(() => {
|
||||||
|
return (data?.myCounterparties || []).filter(
|
||||||
|
(org: FulfillmentConsumableSupplier) => org.type === 'LOGIST',
|
||||||
|
)
|
||||||
|
}, [data?.myCounterparties])
|
||||||
|
|
||||||
|
// Фильтруем поставщиков по поисковому запросу
|
||||||
|
const filteredSuppliers = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
return suppliers
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase()
|
||||||
|
return suppliers.filter((supplier: FulfillmentConsumableSupplier) =>
|
||||||
|
supplier.name?.toLowerCase().includes(query) ||
|
||||||
|
supplier.fullName?.toLowerCase().includes(query) ||
|
||||||
|
supplier.inn?.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
}, [suppliers, searchQuery])
|
||||||
|
|
||||||
|
return {
|
||||||
|
suppliers,
|
||||||
|
logisticsPartners,
|
||||||
|
filteredSuppliers,
|
||||||
|
loading,
|
||||||
|
error: error || null,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,112 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🚀 ХУК ДЛЯ СОЗДАНИЯ ПОСТАВКИ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useMutation } from '@apollo/client'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import {
|
||||||
|
CREATE_FULFILLMENT_CONSUMABLE_SUPPLY,
|
||||||
|
GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES,
|
||||||
|
} from '@/graphql/queries/fulfillment-consumables-v2'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FulfillmentConsumableSupplier,
|
||||||
|
SelectedFulfillmentConsumable,
|
||||||
|
SupplyCreationInput,
|
||||||
|
CreateSupplyMutationResponse,
|
||||||
|
UseSupplyCreationReturn,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
interface UseSupplyCreationProps {
|
||||||
|
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||||
|
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||||
|
selectedLogistics: any | null // Добавляем логистику
|
||||||
|
deliveryDate: string
|
||||||
|
notes: string
|
||||||
|
resetForm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSupplyCreation({
|
||||||
|
selectedSupplier,
|
||||||
|
selectedConsumables,
|
||||||
|
selectedLogistics,
|
||||||
|
deliveryDate,
|
||||||
|
notes,
|
||||||
|
resetForm,
|
||||||
|
}: UseSupplyCreationProps): UseSupplyCreationReturn {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Мутация для создания заказа поставки расходников v2
|
||||||
|
const [createSupplyMutation] = useMutation<CreateSupplyMutationResponse>(CREATE_FULFILLMENT_CONSUMABLE_SUPPLY)
|
||||||
|
|
||||||
|
// Функция создания поставки
|
||||||
|
const createSupply = useCallback(async () => {
|
||||||
|
// Валидация обязательных полей
|
||||||
|
if (!selectedSupplier || selectedConsumables.length === 0 || !deliveryDate) {
|
||||||
|
const errorMessage = 'Заполните все обязательные поля: поставщик, расходники и дата доставки'
|
||||||
|
setError(errorMessage)
|
||||||
|
toast.error(errorMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Формируем input для системы v2
|
||||||
|
const input: SupplyCreationInput = {
|
||||||
|
supplierId: selectedSupplier.id,
|
||||||
|
logisticsPartnerId: selectedLogistics?.id || undefined, // Добавляем логистику
|
||||||
|
requestedDeliveryDate: deliveryDate,
|
||||||
|
items: selectedConsumables.map((consumable) => ({
|
||||||
|
productId: consumable.id,
|
||||||
|
requestedQuantity: consumable.selectedQuantity,
|
||||||
|
})),
|
||||||
|
notes: notes || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('🚀 СОЗДАНИЕ ПОСТАВКИ v2 - INPUT:', input)
|
||||||
|
|
||||||
|
const result = await createSupplyMutation({
|
||||||
|
variables: { input },
|
||||||
|
refetchQueries: [
|
||||||
|
{ query: GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES }, // Обновляем новый v2 запрос
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
console.warn('🎯 РЕЗУЛЬТАТ СОЗДАНИЯ ПОСТАВКИ v2:', result)
|
||||||
|
|
||||||
|
if (result.data?.createFulfillmentConsumableSupply?.success) {
|
||||||
|
toast.success('Поставка расходников создана успешно!')
|
||||||
|
|
||||||
|
// Очищаем форму
|
||||||
|
resetForm()
|
||||||
|
|
||||||
|
// Перенаправляем на страницу детальных поставок
|
||||||
|
router.push('/fulfillment-supplies/detailed-supplies')
|
||||||
|
} else {
|
||||||
|
const errorMessage = result.data?.createFulfillmentConsumableSupply?.message || 'Ошибка при создании поставки'
|
||||||
|
setError(errorMessage)
|
||||||
|
toast.error(errorMessage)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = 'Ошибка при создании поставки расходников'
|
||||||
|
console.error('Error creating fulfillment consumables supply v2:', error)
|
||||||
|
setError(errorMessage)
|
||||||
|
toast.error(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}, [selectedSupplier, selectedConsumables, selectedLogistics, deliveryDate, notes, resetForm, router, createSupplyMutation])
|
||||||
|
|
||||||
|
return {
|
||||||
|
createSupply,
|
||||||
|
isCreating,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 📋 ХУК ДЛЯ УПРАВЛЕНИЯ ФОРМОЙ СОЗДАНИЯ ПОСТАВКИ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FulfillmentConsumableSupplier,
|
||||||
|
SelectedFulfillmentConsumable,
|
||||||
|
UseSupplyFormReturn,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export function useSupplyForm(): UseSupplyFormReturn {
|
||||||
|
// Состояние формы
|
||||||
|
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 resetForm = useCallback(() => {
|
||||||
|
setSelectedSupplier(null)
|
||||||
|
setSelectedLogistics(null)
|
||||||
|
setSelectedConsumables([])
|
||||||
|
setSearchQuery('')
|
||||||
|
setProductSearchQuery('')
|
||||||
|
setDeliveryDate('')
|
||||||
|
setNotes('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Состояние формы
|
||||||
|
selectedSupplier,
|
||||||
|
selectedLogistics,
|
||||||
|
selectedConsumables,
|
||||||
|
searchQuery,
|
||||||
|
productSearchQuery,
|
||||||
|
deliveryDate,
|
||||||
|
notes,
|
||||||
|
|
||||||
|
// Действия формы
|
||||||
|
setSelectedSupplier,
|
||||||
|
setSelectedLogistics,
|
||||||
|
setSelectedConsumables,
|
||||||
|
setSearchQuery,
|
||||||
|
setProductSearchQuery,
|
||||||
|
setDeliveryDate,
|
||||||
|
setNotes,
|
||||||
|
|
||||||
|
// Сброс формы
|
||||||
|
resetForm,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 🔄 СИСТЕМА ПЕРЕКЛЮЧЕНИЯ ПО ТИПУ ОРГАНИЗАЦИИ - УНИВЕРСАЛЬНАЯ ПОСТАВКА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
|
||||||
|
import { ModularVersion } from './modular-version' // Фулфилмент версия
|
||||||
|
import { MonolithicVersion } from './monolithic-version' // Фолбэк версия
|
||||||
|
import { SellerModularVersion } from './seller-modular-version' // 🆕 Селлер версия
|
||||||
|
|
||||||
|
// ⚙️ КОНФИГУРАЦИЯ ВЕРСИЙ
|
||||||
|
const USE_MODULAR_ARCHITECTURE = true // 👈 ПЕРЕКЛЮЧАТЕЛЬ: true = модульная, false = монолитная
|
||||||
|
|
||||||
|
export default function CreateConsumablesSupplyV2Page() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
// 🔄 Выбор версии по типу организации
|
||||||
|
if (USE_MODULAR_ARCHITECTURE && user?.organization) {
|
||||||
|
const organizationType = user.organization.type
|
||||||
|
|
||||||
|
// 🏪 СЕЛЛЕР - заказывает расходники у поставщика для доставки в ФФ
|
||||||
|
if (organizationType === 'SELLER') {
|
||||||
|
return <SellerModularVersion />
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🏢 ФУЛФИЛМЕНТ - заказывает расходники для собственного использования
|
||||||
|
if (organizationType === 'FULFILLMENT') {
|
||||||
|
return <ModularVersion />
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚚 ДРУГИЕ ТИПЫ - пока используют фулфилмент версию
|
||||||
|
// TODO: Добавить специальные версии для WHOLESALE и LOGIST если нужно
|
||||||
|
return <ModularVersion />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ ФОЛБЭК НА МОНОЛИТНУЮ ВЕРСИЮ
|
||||||
|
return <MonolithicVersion />
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 📋 ИНСТРУКЦИИ ПО ПЕРЕКЛЮЧЕНИЮ:
|
||||||
|
//
|
||||||
|
// 🔄 Переключение на модульную архитектуру:
|
||||||
|
// 1. Установить USE_MODULAR_ARCHITECTURE = true
|
||||||
|
// 2. Импорт ModularVersion уже активен
|
||||||
|
// 3. return <ModularVersion /> уже активен
|
||||||
|
//
|
||||||
|
// ⬅️ ОТКАТ к монолитной версии:
|
||||||
|
// 1. Установить USE_MODULAR_ARCHITECTURE = false
|
||||||
|
// 2. Импорт ModularVersion можно оставить
|
||||||
|
// 3. return <ModularVersion /> автоматически отключится
|
||||||
|
//
|
||||||
|
// ⚡ ГОРЯЧИЕ КЛАВИШИ ДЛЯ ПЕРЕКЛЮЧЕНИЯ:
|
||||||
|
// - Ctrl+F → "USE_MODULAR_ARCHITECTURE"
|
||||||
|
// - Изменить true/false
|
||||||
|
// - Сохранить файл
|
||||||
|
// =============================================================================
|
@ -0,0 +1,169 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🧩 МОДУЛЬНАЯ ВЕРСИЯ - СОЗДАНИЕ ПОСТАВОК РАСХОДНИКОВ ФУЛФИЛМЕНТА V2
|
||||||
|
// =============================================================================
|
||||||
|
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в монолитной версии!
|
||||||
|
// Рефакторинг касается только архитектуры - разделение на модули
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
|
||||||
|
// 📦 Импорт модульных компонентов
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
SuppliersBlock,
|
||||||
|
ConsumablesBlock,
|
||||||
|
ShoppingCartBlock,
|
||||||
|
} from './blocks'
|
||||||
|
|
||||||
|
// 🔧 Импорт бизнес-хуков
|
||||||
|
import {
|
||||||
|
useSupplierData,
|
||||||
|
useProductData,
|
||||||
|
useSupplyForm,
|
||||||
|
useQuantityManagement,
|
||||||
|
useSupplyCreation,
|
||||||
|
useCurrencyFormatting,
|
||||||
|
} from './hooks'
|
||||||
|
|
||||||
|
export function ModularVersion() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { getSidebarMargin } = useSidebar()
|
||||||
|
const { user: _user } = useAuth()
|
||||||
|
|
||||||
|
// 📋 Управление состоянием формы
|
||||||
|
const {
|
||||||
|
selectedSupplier,
|
||||||
|
selectedLogistics,
|
||||||
|
selectedConsumables,
|
||||||
|
searchQuery,
|
||||||
|
productSearchQuery,
|
||||||
|
deliveryDate,
|
||||||
|
notes,
|
||||||
|
setSelectedSupplier,
|
||||||
|
setSelectedLogistics,
|
||||||
|
setSelectedConsumables,
|
||||||
|
setSearchQuery,
|
||||||
|
setProductSearchQuery,
|
||||||
|
setDeliveryDate,
|
||||||
|
setNotes,
|
||||||
|
resetForm,
|
||||||
|
} = useSupplyForm()
|
||||||
|
|
||||||
|
// 🏢 Данные поставщиков
|
||||||
|
const {
|
||||||
|
suppliers,
|
||||||
|
logisticsPartners,
|
||||||
|
filteredSuppliers,
|
||||||
|
loading: suppliersLoading,
|
||||||
|
} = useSupplierData({ searchQuery })
|
||||||
|
|
||||||
|
// 📦 Данные товаров
|
||||||
|
const {
|
||||||
|
products,
|
||||||
|
loading: productsLoading,
|
||||||
|
} = useProductData({
|
||||||
|
selectedSupplier,
|
||||||
|
productSearchQuery,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📊 Управление количеством
|
||||||
|
const {
|
||||||
|
updateConsumableQuantity,
|
||||||
|
getSelectedQuantity,
|
||||||
|
getTotalAmount,
|
||||||
|
getTotalItems,
|
||||||
|
} = useQuantityManagement({
|
||||||
|
selectedSupplier,
|
||||||
|
selectedConsumables,
|
||||||
|
products,
|
||||||
|
setSelectedConsumables,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🚀 Создание поставки
|
||||||
|
const {
|
||||||
|
createSupply,
|
||||||
|
isCreating: isCreatingSupply,
|
||||||
|
} = useSupplyCreation({
|
||||||
|
selectedSupplier,
|
||||||
|
selectedConsumables,
|
||||||
|
selectedLogistics,
|
||||||
|
deliveryDate,
|
||||||
|
notes,
|
||||||
|
resetForm,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 💰 Форматирование валюты
|
||||||
|
const { formatCurrency } = useCurrencyFormatting()
|
||||||
|
|
||||||
|
// 🔄 Обработчики событий
|
||||||
|
const handleBack = () => {
|
||||||
|
router.push('/fulfillment-supplies/detailed-supplies')
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* 📄 Заголовок страницы */}
|
||||||
|
<PageHeader onBack={handleBack} />
|
||||||
|
|
||||||
|
{/* 🧩 Основной контент с двумя блоками */}
|
||||||
|
<div className="flex-1 flex gap-3 min-h-0">
|
||||||
|
{/* 📦 Левая колонка - Поставщики и Расходники */}
|
||||||
|
<div className="flex-1 flex flex-col gap-3 min-h-0">
|
||||||
|
{/* 🏢 Блок поставщиков */}
|
||||||
|
<SuppliersBlock
|
||||||
|
suppliers={suppliers}
|
||||||
|
filteredSuppliers={filteredSuppliers}
|
||||||
|
selectedSupplier={selectedSupplier}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
loading={suppliersLoading}
|
||||||
|
onSelectSupplier={setSelectedSupplier}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 📦 Блок расходников */}
|
||||||
|
<ConsumablesBlock
|
||||||
|
selectedSupplier={selectedSupplier}
|
||||||
|
products={products}
|
||||||
|
productsLoading={productsLoading}
|
||||||
|
productSearchQuery={productSearchQuery}
|
||||||
|
getSelectedQuantity={getSelectedQuantity}
|
||||||
|
onProductSearchChange={setProductSearchQuery}
|
||||||
|
onUpdateQuantity={updateConsumableQuantity}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 🛒 Правая колонка - Корзина */}
|
||||||
|
<div className="w-72 flex-shrink-0">
|
||||||
|
<ShoppingCartBlock
|
||||||
|
selectedConsumables={selectedConsumables}
|
||||||
|
deliveryDate={deliveryDate}
|
||||||
|
notes={notes}
|
||||||
|
selectedLogistics={selectedLogistics}
|
||||||
|
logisticsPartners={logisticsPartners}
|
||||||
|
isCreatingSupply={isCreatingSupply}
|
||||||
|
getTotalAmount={getTotalAmount}
|
||||||
|
getTotalItems={getTotalItems}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
onUpdateQuantity={updateConsumableQuantity}
|
||||||
|
onSetDeliveryDate={setDeliveryDate}
|
||||||
|
onSetNotes={setNotes}
|
||||||
|
onSetLogistics={setSelectedLogistics}
|
||||||
|
onCreateSupply={createSupply}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,820 @@
|
|||||||
|
'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 { GET_MY_COUNTERPARTIES, GET_ORGANIZATION_PRODUCTS } from '@/graphql/queries'
|
||||||
|
import {
|
||||||
|
CREATE_FULFILLMENT_CONSUMABLE_SUPPLY,
|
||||||
|
GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES,
|
||||||
|
} from '@/graphql/queries/fulfillment-consumables-v2'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
|
||||||
|
interface FulfillmentConsumableSupplier {
|
||||||
|
id: string
|
||||||
|
inn: string
|
||||||
|
name?: string
|
||||||
|
fullName?: string
|
||||||
|
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||||
|
address?: string
|
||||||
|
phones?: Array<{ value: string }>
|
||||||
|
emails?: Array<{ value: string }>
|
||||||
|
users?: Array<{ id: string; avatar?: string; managerName?: string }>
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FulfillmentConsumableProduct {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
price: number
|
||||||
|
type?: 'PRODUCT' | 'CONSUMABLE'
|
||||||
|
category?: { name: string }
|
||||||
|
images: string[]
|
||||||
|
mainImage?: string
|
||||||
|
organization: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
stock?: number
|
||||||
|
unit?: string
|
||||||
|
quantity?: number
|
||||||
|
ordered?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedFulfillmentConsumable {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
price: number
|
||||||
|
selectedQuantity: number
|
||||||
|
unit?: string
|
||||||
|
category?: string
|
||||||
|
supplierId: string
|
||||||
|
supplierName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonolithicVersion() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { getSidebarMargin } = useSidebar()
|
||||||
|
const { user: _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(`❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedConsumables((prev) => {
|
||||||
|
const existing = prev.find((p) => p.id === productId)
|
||||||
|
|
||||||
|
if (quantity === 0) {
|
||||||
|
// Удаляем расходник если количество 0
|
||||||
|
return prev.filter((p) => p.id !== productId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Обновляем количество существующего расходника
|
||||||
|
return prev.map((p) => (p.id === productId ? { ...p, selectedQuantity: quantity } : p))
|
||||||
|
} else {
|
||||||
|
// Добавляем новый расходник
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
price: product.price,
|
||||||
|
selectedQuantity: quantity,
|
||||||
|
unit: product.unit || 'шт',
|
||||||
|
category: product.category?.name || 'Расходники',
|
||||||
|
supplierId: selectedSupplier.id,
|
||||||
|
supplierName: selectedSupplier.name || selectedSupplier.fullName || 'Поставщик',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSelectedQuantity = (productId: string): number => {
|
||||||
|
const selected = selectedConsumables.find((p) => p.id === productId)
|
||||||
|
return selected ? selected.selectedQuantity : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTotalAmount = () => {
|
||||||
|
return selectedConsumables.reduce((sum, consumable) => sum + consumable.price * consumable.selectedQuantity, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTotalItems = () => {
|
||||||
|
return selectedConsumables.reduce((sum, consumable) => sum + consumable.selectedQuantity, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateSupply = async () => {
|
||||||
|
if (!selectedSupplier || selectedConsumables.length === 0 || !deliveryDate) {
|
||||||
|
toast.error('Заполните все обязательные поля: поставщик, расходники и дата доставки')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreatingSupply(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Новый формат для системы v2
|
||||||
|
const input = {
|
||||||
|
supplierId: selectedSupplier.id,
|
||||||
|
logisticsPartnerId: selectedLogistics?.id || undefined, // Добавляем логистического партнера
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,183 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🧩 МОДУЛЬНАЯ ВЕРСИЯ ДЛЯ СЕЛЛЕРА - СОЗДАНИЕ ПОСТАВОК РАСХОДНИКОВ V2
|
||||||
|
// =============================================================================
|
||||||
|
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в фулфилмент версии!
|
||||||
|
// Отличия только в бизнес-логике и данных
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
|
|
||||||
|
// 📦 Импорт селлерских компонентов
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
ConsumablesBlock, // Используем тот же компонент расходников
|
||||||
|
} from './blocks'
|
||||||
|
import {
|
||||||
|
SellerSuppliersBlock,
|
||||||
|
SellerShoppingCartBlock,
|
||||||
|
} from './blocks/seller-blocks'
|
||||||
|
|
||||||
|
// 🔧 Импорт селлерских хуков
|
||||||
|
import {
|
||||||
|
useProductData, // Используем тот же hook для товаров
|
||||||
|
useQuantityManagement, // Используем тот же hook для количества
|
||||||
|
useCurrencyFormatting, // Используем тот же hook для валюты
|
||||||
|
} from './hooks'
|
||||||
|
import {
|
||||||
|
useSellerSupplyForm,
|
||||||
|
useSellerSupplyCreation,
|
||||||
|
useSellerSupplierData,
|
||||||
|
} from './hooks/seller-hooks'
|
||||||
|
|
||||||
|
export function SellerModularVersion() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { getSidebarMargin } = useSidebar()
|
||||||
|
const { user: _user } = useAuth()
|
||||||
|
|
||||||
|
// 📋 Управление состоянием формы СЕЛЛЕРА
|
||||||
|
const {
|
||||||
|
selectedSupplier,
|
||||||
|
selectedFulfillment, // 🆕 Дополнительное поле для селлера
|
||||||
|
selectedLogistics,
|
||||||
|
selectedConsumables,
|
||||||
|
searchQuery,
|
||||||
|
productSearchQuery,
|
||||||
|
deliveryDate,
|
||||||
|
notes,
|
||||||
|
setSelectedSupplier,
|
||||||
|
setSelectedFulfillment, // 🆕 Дополнительный сеттер
|
||||||
|
setSelectedLogistics,
|
||||||
|
setSelectedConsumables,
|
||||||
|
setSearchQuery,
|
||||||
|
setProductSearchQuery,
|
||||||
|
setDeliveryDate,
|
||||||
|
setNotes,
|
||||||
|
resetForm,
|
||||||
|
} = useSellerSupplyForm()
|
||||||
|
|
||||||
|
// 🏢 Данные партнеров для селлера (поставщики + фулфилмент-центры)
|
||||||
|
const {
|
||||||
|
suppliers,
|
||||||
|
fulfillmentCenters, // 🆕 Фулфилмент-центры для селлера
|
||||||
|
filteredSuppliers,
|
||||||
|
filteredFulfillmentCenters, // 🆕 Фильтрованные ФФ
|
||||||
|
logisticsPartners,
|
||||||
|
loading: suppliersLoading,
|
||||||
|
} = useSellerSupplierData({ searchQuery })
|
||||||
|
|
||||||
|
// 📦 Данные товаров (используем тот же hook что и для фулфилмента)
|
||||||
|
const {
|
||||||
|
products,
|
||||||
|
loading: productsLoading,
|
||||||
|
} = useProductData({
|
||||||
|
selectedSupplier, // 🔄 Товары от выбранного поставщика
|
||||||
|
productSearchQuery,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📊 Управление количеством (используем тот же hook)
|
||||||
|
const {
|
||||||
|
updateConsumableQuantity,
|
||||||
|
getSelectedQuantity,
|
||||||
|
getTotalAmount,
|
||||||
|
getTotalItems,
|
||||||
|
} = useQuantityManagement({
|
||||||
|
selectedSupplier,
|
||||||
|
selectedConsumables,
|
||||||
|
products,
|
||||||
|
setSelectedConsumables,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🚀 Создание поставки СЕЛЛЕРА
|
||||||
|
const {
|
||||||
|
createSupply,
|
||||||
|
isCreating: isCreatingSupply,
|
||||||
|
} = useSellerSupplyCreation({
|
||||||
|
selectedSupplier,
|
||||||
|
selectedFulfillment, // 🆕 Передаем фулфилмент-центр
|
||||||
|
selectedConsumables,
|
||||||
|
deliveryDate,
|
||||||
|
notes,
|
||||||
|
resetForm,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 💰 Форматирование валюты (используем тот же hook)
|
||||||
|
const { formatCurrency } = useCurrencyFormatting()
|
||||||
|
|
||||||
|
// 🔄 Обработчики событий
|
||||||
|
const handleBack = () => {
|
||||||
|
// TODO: Создать маршрут для списка поставок селлера
|
||||||
|
router.push('/seller-supplies') // или другой подходящий маршрут
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* 📄 Заголовок страницы */}
|
||||||
|
<PageHeader onBack={handleBack} />
|
||||||
|
|
||||||
|
{/* 🧩 Основной контент с двумя блоками */}
|
||||||
|
<div className="flex-1 flex gap-3 min-h-0">
|
||||||
|
{/* 📦 Левая колонка - Партнеры и Расходники */}
|
||||||
|
<div className="flex-1 flex flex-col gap-3 min-h-0">
|
||||||
|
{/* 🏪 Блок партнеров (поставщики + фулфилмент-центры) */}
|
||||||
|
<SellerSuppliersBlock
|
||||||
|
suppliers={suppliers}
|
||||||
|
fulfillmentCenters={fulfillmentCenters}
|
||||||
|
filteredSuppliers={filteredSuppliers}
|
||||||
|
filteredFulfillmentCenters={filteredFulfillmentCenters}
|
||||||
|
selectedSupplier={selectedSupplier}
|
||||||
|
selectedFulfillment={selectedFulfillment}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
loading={suppliersLoading}
|
||||||
|
onSelectSupplier={setSelectedSupplier}
|
||||||
|
onSelectFulfillment={setSelectedFulfillment}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 📦 Блок расходников (тот же что у фулфилмента) */}
|
||||||
|
<ConsumablesBlock
|
||||||
|
selectedSupplier={selectedSupplier}
|
||||||
|
products={products}
|
||||||
|
productsLoading={productsLoading}
|
||||||
|
productSearchQuery={productSearchQuery}
|
||||||
|
getSelectedQuantity={getSelectedQuantity}
|
||||||
|
onProductSearchChange={setProductSearchQuery}
|
||||||
|
onUpdateQuantity={updateConsumableQuantity}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 🛒 Правая колонка - Корзина СЕЛЛЕРА */}
|
||||||
|
<div className="w-72 flex-shrink-0">
|
||||||
|
<SellerShoppingCartBlock
|
||||||
|
selectedConsumables={selectedConsumables}
|
||||||
|
selectedFulfillment={selectedFulfillment} // 🆕 Показываем выбранный ФФ
|
||||||
|
deliveryDate={deliveryDate}
|
||||||
|
notes={notes}
|
||||||
|
selectedLogistics={selectedLogistics}
|
||||||
|
logisticsPartners={logisticsPartners}
|
||||||
|
isCreatingSupply={isCreatingSupply}
|
||||||
|
getTotalAmount={getTotalAmount}
|
||||||
|
getTotalItems={getTotalItems}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
onUpdateQuantity={updateConsumableQuantity}
|
||||||
|
onSetDeliveryDate={setDeliveryDate}
|
||||||
|
onSetNotes={setNotes}
|
||||||
|
onSetLogistics={setSelectedLogistics}
|
||||||
|
onCreateSupply={createSupply}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,252 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🔧 ТИПЫ ДЛЯ СИСТЕМЫ СОЗДАНИЯ ПОСТАВОК РАСХОДНИКОВ ФУЛФИЛМЕНТА V2
|
||||||
|
// =============================================================================
|
||||||
|
// Эти типы экстрагированы из монолитного компонента в соответствии с правилами
|
||||||
|
// модульной архитектуры MODULAR_ARCHITECTURE_PATTERN.md
|
||||||
|
|
||||||
|
// 🏢 ТИПЫ ПОСТАВЩИКОВ И ОРГАНИЗАЦИЙ
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📦 ТИПЫ ТОВАРОВ И РАСХОДНИКОВ
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛒 ТИПЫ КОРЗИНЫ И ВЫБРАННЫХ ТОВАРОВ
|
||||||
|
export interface SelectedFulfillmentConsumable {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
price: number
|
||||||
|
selectedQuantity: number
|
||||||
|
unit?: string
|
||||||
|
category?: string
|
||||||
|
supplierId: string
|
||||||
|
supplierName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📋 ТИПЫ ФОРМЫ СОЗДАНИЯ ПОСТАВКИ
|
||||||
|
export interface CreateSupplyFormData {
|
||||||
|
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||||
|
selectedLogistics: FulfillmentConsumableSupplier | null
|
||||||
|
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||||
|
searchQuery: string
|
||||||
|
productSearchQuery: string
|
||||||
|
deliveryDate: string
|
||||||
|
notes: string
|
||||||
|
isCreatingSupply: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 ТИПЫ ПОИСКОВЫХ ПАРАМЕТРОВ
|
||||||
|
export interface SupplierSearchParams {
|
||||||
|
searchQuery: string
|
||||||
|
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSearchParams {
|
||||||
|
productSearchQuery: string
|
||||||
|
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||||
|
type: 'CONSUMABLE'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📊 ТИПЫ ДАННЫХ ЗАПРОСОВ
|
||||||
|
export interface CounterpartiesData {
|
||||||
|
myCounterparties: FulfillmentConsumableSupplier[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductsData {
|
||||||
|
organizationProducts: FulfillmentConsumableProduct[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎯 ТИПЫ ДЕЙСТВИЙ И ОПЕРАЦИЙ
|
||||||
|
export interface QuantityUpdateParams {
|
||||||
|
productId: string
|
||||||
|
quantity: number
|
||||||
|
product: FulfillmentConsumableProduct
|
||||||
|
selectedSupplier: FulfillmentConsumableSupplier
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplyCreationInput {
|
||||||
|
supplierId: string
|
||||||
|
logisticsPartnerId?: string // Добавляем логистического партнера
|
||||||
|
requestedDeliveryDate: string
|
||||||
|
items: Array<{
|
||||||
|
productId: string
|
||||||
|
requestedQuantity: number
|
||||||
|
}>
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 ТИПЫ УТИЛИТ И ХЕЛПЕРОВ
|
||||||
|
export interface StockCalculation {
|
||||||
|
totalStock: number
|
||||||
|
orderedStock: number
|
||||||
|
availableStock: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrencyFormatOptions {
|
||||||
|
style: 'currency'
|
||||||
|
currency: 'RUB'
|
||||||
|
minimumFractionDigits: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚦 ТИПЫ СОСТОЯНИЙ ЗАГРУЗКИ
|
||||||
|
export interface LoadingStates {
|
||||||
|
counterpartiesLoading: boolean
|
||||||
|
productsLoading: boolean
|
||||||
|
isCreatingSupply: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ ТИПЫ ОШИБОК И ВАЛИДАЦИИ
|
||||||
|
export interface ValidationError {
|
||||||
|
field: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockValidationResult {
|
||||||
|
isValid: boolean
|
||||||
|
availableStock: number
|
||||||
|
requestedQuantity: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎨 ТИПЫ UI КОМПОНЕНТОВ
|
||||||
|
export interface SupplierCardProps {
|
||||||
|
supplier: FulfillmentConsumableSupplier
|
||||||
|
isSelected: boolean
|
||||||
|
onSelect: (supplier: FulfillmentConsumableSupplier) => void
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductCardProps {
|
||||||
|
product: FulfillmentConsumableProduct
|
||||||
|
selectedQuantity: number
|
||||||
|
onUpdateQuantity: (productId: string, quantity: number) => void
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingCartProps {
|
||||||
|
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||||
|
deliveryDate: string
|
||||||
|
notes: string
|
||||||
|
selectedLogistics: FulfillmentConsumableSupplier | null
|
||||||
|
logisticsPartners: FulfillmentConsumableSupplier[]
|
||||||
|
isCreatingSupply: boolean
|
||||||
|
onUpdateQuantity: (productId: string, quantity: number) => void
|
||||||
|
onSetDeliveryDate: (date: string) => void
|
||||||
|
onSetNotes: (notes: string) => void
|
||||||
|
onSetLogistics: (logistics: FulfillmentConsumableSupplier | null) => void
|
||||||
|
onCreateSupply: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📈 ТИПЫ ВЫЧИСЛЕНИЙ И АНАЛИТИКИ
|
||||||
|
export interface SupplyTotals {
|
||||||
|
totalAmount: number
|
||||||
|
totalItems: number
|
||||||
|
itemsCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎯 ТИПЫ СОБЫТИЙ И КОЛБЭКОВ
|
||||||
|
export type SupplierSelectHandler = (supplier: FulfillmentConsumableSupplier) => void
|
||||||
|
export type QuantityUpdateHandler = (productId: string, quantity: number) => void
|
||||||
|
export type SupplyCreateHandler = () => Promise<void>
|
||||||
|
export type SearchUpdateHandler = (query: string) => void
|
||||||
|
|
||||||
|
// 💾 ТИПЫ СТОРЕЙДЖА И КЕША
|
||||||
|
export interface CachedSupplierData {
|
||||||
|
suppliers: FulfillmentConsumableSupplier[]
|
||||||
|
lastFetch: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CachedProductData {
|
||||||
|
products: FulfillmentConsumableProduct[]
|
||||||
|
supplierId: string
|
||||||
|
lastFetch: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🌐 ТИПЫ ГРАФК ЗАПРОСОВ
|
||||||
|
export interface GraphQLQueryVariables {
|
||||||
|
organizationId?: string
|
||||||
|
search?: string | null
|
||||||
|
category?: string | null
|
||||||
|
type?: 'CONSUMABLE'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSupplyMutationResponse {
|
||||||
|
createFulfillmentConsumableSupply: {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 ЭКСПОРТ ТИПОВ ДЛЯ ХУКОВ
|
||||||
|
export interface UseSupplierDataReturn {
|
||||||
|
suppliers: FulfillmentConsumableSupplier[]
|
||||||
|
logisticsPartners: FulfillmentConsumableSupplier[]
|
||||||
|
filteredSuppliers: FulfillmentConsumableSupplier[]
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseProductDataReturn {
|
||||||
|
products: FulfillmentConsumableProduct[]
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
refetch: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSupplyFormReturn {
|
||||||
|
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||||
|
selectedLogistics: FulfillmentConsumableSupplier | null
|
||||||
|
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||||
|
searchQuery: string
|
||||||
|
productSearchQuery: string
|
||||||
|
deliveryDate: string
|
||||||
|
notes: string
|
||||||
|
setSelectedSupplier: (supplier: FulfillmentConsumableSupplier | null) => void
|
||||||
|
setSelectedLogistics: (logistics: FulfillmentConsumableSupplier | null) => void
|
||||||
|
setSelectedConsumables: (consumables: SelectedFulfillmentConsumable[]) => void
|
||||||
|
setSearchQuery: (query: string) => void
|
||||||
|
setProductSearchQuery: (query: string) => void
|
||||||
|
setDeliveryDate: (date: string) => void
|
||||||
|
setNotes: (notes: string) => void
|
||||||
|
resetForm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseQuantityManagementReturn {
|
||||||
|
updateConsumableQuantity: (productId: string, quantity: number) => void
|
||||||
|
getSelectedQuantity: (productId: string) => number
|
||||||
|
getTotalAmount: () => number
|
||||||
|
getTotalItems: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSupplyCreationReturn {
|
||||||
|
createSupply: () => Promise<void>
|
||||||
|
isCreating: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
@ -0,0 +1,193 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 🔧 ТИПЫ ДЛЯ СИСТЕМЫ СОЗДАНИЯ ПОСТАВОК РАСХОДНИКОВ СЕЛЛЕРА V2
|
||||||
|
// =============================================================================
|
||||||
|
// Адаптированные типы из основных типов для селлерской системы
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FulfillmentConsumableSupplier,
|
||||||
|
FulfillmentConsumableProduct,
|
||||||
|
SelectedFulfillmentConsumable,
|
||||||
|
SupplyCreationInput as BaseSupplyCreationInput,
|
||||||
|
CreateSupplyMutationResponse as BaseCreateSupplyMutationResponse,
|
||||||
|
} from './index'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 📦 СЕЛЛЕРСКИЕ ТИПЫ (АДАПТАЦИЯ)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Селлер использует те же типы поставщиков что и фулфилмент
|
||||||
|
export type SellerConsumableSupplier = FulfillmentConsumableSupplier
|
||||||
|
export type SellerConsumableProduct = FulfillmentConsumableProduct
|
||||||
|
export type SelectedSellerConsumable = SelectedFulfillmentConsumable
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 🆕 СПЕЦИФИЧНЫЕ ТИПЫ ДЛЯ СЕЛЛЕРА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Статусы поставок селлера (5-статусная система)
|
||||||
|
export type SellerSupplyOrderStatus =
|
||||||
|
| 'PENDING' // Ожидает одобрения поставщика
|
||||||
|
| 'APPROVED' // Одобрено поставщиком
|
||||||
|
| 'SHIPPED' // Отгружено
|
||||||
|
| 'DELIVERED' // Доставлено
|
||||||
|
| 'COMPLETED' // Завершено
|
||||||
|
| 'CANCELLED' // Отменено
|
||||||
|
|
||||||
|
// Данные для создания поставки селлера
|
||||||
|
export interface SellerSupplyCreationInput {
|
||||||
|
fulfillmentCenterId: string // куда доставлять (FULFILLMENT партнер)
|
||||||
|
supplierId: string // от кого заказывать (WHOLESALE партнер)
|
||||||
|
logisticsPartnerId?: string // кто везет (LOGIST партнер, опционально)
|
||||||
|
requestedDeliveryDate: string // когда нужно
|
||||||
|
items: Array<{
|
||||||
|
productId: string
|
||||||
|
requestedQuantity: number
|
||||||
|
}>
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response для создания поставки селлера
|
||||||
|
export interface CreateSellerSupplyMutationResponse {
|
||||||
|
createSellerConsumableSupply: {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
supplyOrder?: {
|
||||||
|
id: string
|
||||||
|
status: SellerSupplyOrderStatus
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 🎯 ТИПЫ ДЛЯ ХУКОВ (АДАПТАЦИЯ)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface UseSellerSupplyCreationProps {
|
||||||
|
selectedSupplier: SellerConsumableSupplier | null
|
||||||
|
selectedFulfillment: SellerConsumableSupplier | null // 🆕 Выбор фулфилмента
|
||||||
|
selectedConsumables: SelectedSellerConsumable[]
|
||||||
|
deliveryDate: string
|
||||||
|
notes: string
|
||||||
|
resetForm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSellerSupplyCreationReturn {
|
||||||
|
createSupply: () => Promise<void>
|
||||||
|
isCreating: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSellerSupplyFormReturn {
|
||||||
|
selectedSupplier: SellerConsumableSupplier | null
|
||||||
|
selectedFulfillment: SellerConsumableSupplier | null // 🆕 Дополнительное поле
|
||||||
|
selectedLogistics: SellerConsumableSupplier | null
|
||||||
|
selectedConsumables: SelectedSellerConsumable[]
|
||||||
|
searchQuery: string
|
||||||
|
productSearchQuery: string
|
||||||
|
deliveryDate: string
|
||||||
|
notes: string
|
||||||
|
setSelectedSupplier: (supplier: SellerConsumableSupplier | null) => void
|
||||||
|
setSelectedFulfillment: (fulfillment: SellerConsumableSupplier | null) => void // 🆕 Новый сеттер
|
||||||
|
setSelectedLogistics: (logistics: SellerConsumableSupplier | null) => void
|
||||||
|
setSelectedConsumables: (consumables: SelectedSellerConsumable[]) => void
|
||||||
|
setSearchQuery: (query: string) => void
|
||||||
|
setProductSearchQuery: (query: string) => void
|
||||||
|
setDeliveryDate: (date: string) => void
|
||||||
|
setNotes: (notes: string) => void
|
||||||
|
resetForm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 🎨 ТИПЫ UI КОМПОНЕНТОВ ДЛЯ СЕЛЛЕРА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface SellerSuppliersBlockProps {
|
||||||
|
suppliers: SellerConsumableSupplier[]
|
||||||
|
fulfillmentCenters: SellerConsumableSupplier[] // 🆕 Список фулфилмент-центров
|
||||||
|
filteredSuppliers: SellerConsumableSupplier[]
|
||||||
|
filteredFulfillmentCenters: SellerConsumableSupplier[] // 🆕 Фильтрованные ФФ
|
||||||
|
selectedSupplier: SellerConsumableSupplier | null
|
||||||
|
selectedFulfillment: SellerConsumableSupplier | null // 🆕 Выбранный фулфилмент
|
||||||
|
searchQuery: string
|
||||||
|
loading: boolean
|
||||||
|
onSelectSupplier: (supplier: SellerConsumableSupplier | null) => void
|
||||||
|
onSelectFulfillment: (fulfillment: SellerConsumableSupplier | null) => void // 🆕
|
||||||
|
onSearchChange: (query: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SellerShoppingCartBlockProps {
|
||||||
|
selectedConsumables: SelectedSellerConsumable[]
|
||||||
|
selectedFulfillment: SellerConsumableSupplier | null // 🆕 Показ выбранного ФФ
|
||||||
|
deliveryDate: string
|
||||||
|
notes: string
|
||||||
|
selectedLogistics: SellerConsumableSupplier | null
|
||||||
|
logisticsPartners: SellerConsumableSupplier[]
|
||||||
|
isCreatingSupply: boolean
|
||||||
|
getTotalAmount: () => number
|
||||||
|
getTotalItems: () => number
|
||||||
|
formatCurrency: (amount: number) => string
|
||||||
|
onUpdateQuantity: (productId: string, quantity: number) => void
|
||||||
|
onSetDeliveryDate: (date: string) => void
|
||||||
|
onSetNotes: (notes: string) => void
|
||||||
|
onSetLogistics: (logistics: SellerConsumableSupplier | null) => void
|
||||||
|
onCreateSupply: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 🔄 ТИПЫ ДЛЯ ПЕРЕКЛЮЧЕНИЯ СИСТЕМ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface SupplySystemConfig {
|
||||||
|
type: 'FULFILLMENT' | 'SELLER'
|
||||||
|
queries: {
|
||||||
|
GET_MY_SUPPLIES: any
|
||||||
|
CREATE_SUPPLY: any
|
||||||
|
GET_COUNTERPARTIES: any
|
||||||
|
GET_ORGANIZATION_PRODUCTS: any
|
||||||
|
}
|
||||||
|
components: {
|
||||||
|
SuppliersBlock: React.ComponentType<any>
|
||||||
|
ShoppingCartBlock: React.ComponentType<any>
|
||||||
|
}
|
||||||
|
hooks: {
|
||||||
|
useSupplyCreation: any
|
||||||
|
useSupplyForm: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 🌐 ТИПЫ ДАННЫХ ЗАПРОСОВ ДЛЯ СЕЛЛЕРА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface SellerSuppliesData {
|
||||||
|
mySellerConsumableSupplies: Array<{
|
||||||
|
id: string
|
||||||
|
status: SellerSupplyOrderStatus
|
||||||
|
seller: { id: string; name: string; inn: string }
|
||||||
|
fulfillmentCenter: { id: string; name: string; inn: string }
|
||||||
|
supplier?: { id: string; name: string; inn: string }
|
||||||
|
requestedDeliveryDate: string
|
||||||
|
totalCostWithDelivery?: number
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
product: { id: string; name: string; article: string }
|
||||||
|
requestedQuantity: number
|
||||||
|
unitPrice: number
|
||||||
|
totalPrice: number
|
||||||
|
}>
|
||||||
|
createdAt: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncomingSellerSuppliesData {
|
||||||
|
incomingSellerSupplies: Array<{
|
||||||
|
id: string
|
||||||
|
status: SellerSupplyOrderStatus
|
||||||
|
seller: { id: string; name: string; inn: string }
|
||||||
|
fulfillmentCenter: { id: string; name: string; inn: string }
|
||||||
|
requestedDeliveryDate: string
|
||||||
|
totalCostWithDelivery?: number
|
||||||
|
createdAt: string
|
||||||
|
}>
|
||||||
|
}
|
@ -35,6 +35,7 @@ import {
|
|||||||
GET_MY_EMPLOYEES,
|
GET_MY_EMPLOYEES,
|
||||||
GET_LOGISTICS_PARTNERS,
|
GET_LOGISTICS_PARTNERS,
|
||||||
} from '@/graphql/queries'
|
} from '@/graphql/queries'
|
||||||
|
import { GET_INCOMING_SELLER_SUPPLIES } from '@/graphql/queries/seller-consumables-v2'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
|
||||||
interface SupplyOrder {
|
interface SupplyOrder {
|
||||||
@ -146,21 +147,31 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
console.error('LOGISTICS ERROR:', logisticsError)
|
console.error('LOGISTICS ERROR:', logisticsError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем заказы поставок
|
// Загружаем заказы поставок из старой системы
|
||||||
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS)
|
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS)
|
||||||
|
|
||||||
|
// Загружаем селлерские поставки из новой системы
|
||||||
|
const {
|
||||||
|
data: sellerData,
|
||||||
|
loading: sellerLoading,
|
||||||
|
error: sellerError,
|
||||||
|
refetch: refetchSellerSupplies,
|
||||||
|
} = useQuery(GET_INCOMING_SELLER_SUPPLIES)
|
||||||
|
|
||||||
// Мутация для приемки поставки фулфилментом
|
// Мутация для приемки поставки фулфилментом
|
||||||
const [fulfillmentReceiveOrder, { loading: receiving }] = useMutation(FULFILLMENT_RECEIVE_ORDER, {
|
const [fulfillmentReceiveOrder, { loading: receiving }] = useMutation(FULFILLMENT_RECEIVE_ORDER, {
|
||||||
onCompleted: (data) => {
|
onCompleted: (data) => {
|
||||||
if (data.fulfillmentReceiveOrder.success) {
|
if (data.fulfillmentReceiveOrder.success) {
|
||||||
toast.success(data.fulfillmentReceiveOrder.message)
|
toast.success(data.fulfillmentReceiveOrder.message)
|
||||||
refetch() // Обновляем список заказов
|
refetch() // Обновляем старые заказы поставок
|
||||||
|
refetchSellerSupplies() // Обновляем селлерские поставки
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.fulfillmentReceiveOrder.message)
|
toast.error(data.fulfillmentReceiveOrder.message)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refetchQueries: [
|
refetchQueries: [
|
||||||
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||||
|
{ query: GET_INCOMING_SELLER_SUPPLIES }, // Обновляем селлерские поставки
|
||||||
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фулфилмента)
|
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фулфилмента)
|
||||||
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
||||||
{ query: GET_PENDING_SUPPLIES_COUNT }, // Обновляем счетчики уведомлений
|
{ query: GET_PENDING_SUPPLIES_COUNT }, // Обновляем счетчики уведомлений
|
||||||
@ -176,7 +187,8 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
onCompleted: (data) => {
|
onCompleted: (data) => {
|
||||||
if (data.assignLogisticsToSupply.success) {
|
if (data.assignLogisticsToSupply.success) {
|
||||||
toast.success('Логистика и ответственный назначены успешно')
|
toast.success('Логистика и ответственный назначены успешно')
|
||||||
refetch() // Обновляем список заказов
|
refetch() // Обновляем старые заказы поставок
|
||||||
|
refetchSellerSupplies() // Обновляем селлерские поставки
|
||||||
// Сбрасываем состояние назначения
|
// Сбрасываем состояние назначения
|
||||||
setAssigningOrders((prev) => {
|
setAssigningOrders((prev) => {
|
||||||
const newSet = new Set(prev)
|
const newSet = new Set(prev)
|
||||||
@ -187,7 +199,10 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
toast.error(data.assignLogisticsToSupply.message || 'Ошибка при назначении логистики')
|
toast.error(data.assignLogisticsToSupply.message || 'Ошибка при назначении логистики')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
refetchQueries: [
|
||||||
|
{ query: GET_SUPPLY_ORDERS },
|
||||||
|
{ query: GET_INCOMING_SELLER_SUPPLIES },
|
||||||
|
],
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Error assigning logistics:', error)
|
console.error('Error assigning logistics:', error)
|
||||||
toast.error('Ошибка при назначении логистики')
|
toast.error('Ошибка при назначении логистики')
|
||||||
@ -204,37 +219,93 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
setExpandedOrders(newExpanded)
|
setExpandedOrders(newExpanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем данные заказов поставок
|
// Получаем данные заказов поставок из старой системы
|
||||||
const supplyOrders: SupplyOrder[] = data?.supplyOrders || []
|
const supplyOrders: SupplyOrder[] = data?.supplyOrders || []
|
||||||
|
|
||||||
// Фильтруем заказы для фулфилмента (ТОЛЬКО расходники фулфилмента)
|
// Получаем селлерские поставки и конвертируем их в формат SupplyOrder
|
||||||
|
const sellerSupplies = sellerData?.incomingSellerSupplies || []
|
||||||
|
const convertedSellerSupplies: SupplyOrder[] = sellerSupplies.map((supply: any) => ({
|
||||||
|
id: supply.id,
|
||||||
|
partnerId: supply.supplierId || supply.supplier?.id,
|
||||||
|
deliveryDate: supply.requestedDeliveryDate,
|
||||||
|
status: supply.status === 'DELIVERED' ? 'DELIVERED' :
|
||||||
|
supply.status === 'SHIPPED' ? 'SHIPPED' :
|
||||||
|
supply.status === 'APPROVED' ? 'SUPPLIER_APPROVED' :
|
||||||
|
'PENDING',
|
||||||
|
totalAmount: supply.totalCostWithDelivery || supply.items?.reduce((sum: number, item: any) =>
|
||||||
|
sum + (item.unitPrice * item.requestedQuantity), 0) || 0,
|
||||||
|
totalItems: supply.items?.reduce((sum: number, item: any) => sum + item.requestedQuantity, 0) || 0,
|
||||||
|
createdAt: supply.createdAt,
|
||||||
|
consumableType: 'SELLER_CONSUMABLES',
|
||||||
|
fulfillmentCenter: supply.fulfillmentCenter,
|
||||||
|
organization: supply.seller, // Селлер-создатель
|
||||||
|
partner: supply.supplier, // Поставщик
|
||||||
|
logisticsPartner: supply.logisticsPartner,
|
||||||
|
items: supply.items?.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
quantity: item.requestedQuantity,
|
||||||
|
price: item.unitPrice,
|
||||||
|
totalPrice: item.totalPrice || (item.unitPrice * item.requestedQuantity),
|
||||||
|
product: {
|
||||||
|
id: item.product?.id,
|
||||||
|
name: item.product?.name || 'Товар',
|
||||||
|
article: item.product?.article || '',
|
||||||
|
description: item.product?.description,
|
||||||
|
price: item.product?.price,
|
||||||
|
quantity: item.product?.quantity,
|
||||||
|
images: item.product?.images,
|
||||||
|
mainImage: item.product?.mainImage,
|
||||||
|
category: item.product?.category,
|
||||||
|
},
|
||||||
|
})) || [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Объединяем старые поставки и селлерские поставки
|
||||||
|
const allSupplyOrders = [...supplyOrders, ...convertedSellerSupplies]
|
||||||
|
|
||||||
|
// Фильтруем заказы для фулфилмента (расходники фулфилмента + селлеров)
|
||||||
const fulfillmentOrders = supplyOrders.filter((order) => {
|
const fulfillmentOrders = supplyOrders.filter((order) => {
|
||||||
// Показываем только заказы созданные САМИМ фулфилментом для своих расходников
|
// Получатель должен быть наш фулфилмент-центр
|
||||||
const isCreatedBySelf = order.organization?.id === user?.organization?.id
|
|
||||||
// И получатель тоже мы (фулфилмент заказывает расходники для себя)
|
|
||||||
const isRecipient = order.fulfillmentCenter?.id === user?.organization?.id
|
const isRecipient = order.fulfillmentCenter?.id === user?.organization?.id
|
||||||
// И статус не PENDING и не CANCELLED (одобренные поставщиком заявки)
|
// И статус не PENDING и не CANCELLED (одобренные поставщиком заявки)
|
||||||
const isApproved = order.status !== 'CANCELLED' && order.status !== 'PENDING'
|
const isApproved = order.status !== 'CANCELLED' && order.status !== 'PENDING'
|
||||||
// ✅ КРИТИЧНОЕ ИСПРАВЛЕНИЕ: Показывать только расходники ФУЛФИЛМЕНТА (НЕ селлеров и НЕ товары)
|
|
||||||
|
// ✅ ИСПРАВЛЕНИЕ: Показывать ОБА типа расходников - фулфилмент и селлер
|
||||||
const isFulfillmentConsumables = order.consumableType === 'FULFILLMENT_CONSUMABLES'
|
const isFulfillmentConsumables = order.consumableType === 'FULFILLMENT_CONSUMABLES'
|
||||||
|
const isSellerConsumables = order.consumableType === 'SELLER_CONSUMABLES'
|
||||||
|
const isAnyConsumables = isFulfillmentConsumables || isSellerConsumables
|
||||||
|
|
||||||
// Проверяем, что это НЕ товары (товары содержат услуги в рецептуре)
|
// Проверяем, что это НЕ товары (товары содержат услуги в рецептуре)
|
||||||
const hasServices = order.items?.some(item => item.recipe?.services && item.recipe.services.length > 0)
|
const hasServices = order.items?.some(item => item.recipe?.services && item.recipe.services.length > 0)
|
||||||
const isConsumablesOnly = isFulfillmentConsumables && !hasServices
|
const isConsumablesOnly = isAnyConsumables && !hasServices
|
||||||
|
|
||||||
console.warn('🔍 Фильтрация расходников фулфилмента:', {
|
// Дополнительная проверка для селлерских поставок
|
||||||
|
const isCreatedBySelf = order.organization?.id === user?.organization?.id
|
||||||
|
const isFromSeller = order.organization?.type === 'SELLER'
|
||||||
|
|
||||||
|
// Логика фильтрации:
|
||||||
|
// 1. Свои поставки фулфилмента (созданные нами)
|
||||||
|
// 2. Поставки от селлеров (созданные селлерами для нас)
|
||||||
|
const shouldShow = isRecipient && isApproved && isConsumablesOnly && (isCreatedBySelf || isFromSeller)
|
||||||
|
|
||||||
|
console.warn('🔍 Фильтрация расходников фулфилмента + селлеров:', {
|
||||||
orderId: order.id.slice(-8),
|
orderId: order.id.slice(-8),
|
||||||
isRecipient,
|
isRecipient,
|
||||||
isCreatedBySelf,
|
|
||||||
isApproved,
|
isApproved,
|
||||||
isFulfillmentConsumables,
|
isFulfillmentConsumables,
|
||||||
|
isSellerConsumables,
|
||||||
|
isAnyConsumables,
|
||||||
hasServices,
|
hasServices,
|
||||||
isConsumablesOnly,
|
isConsumablesOnly,
|
||||||
|
isCreatedBySelf,
|
||||||
|
isFromSeller,
|
||||||
consumableType: order.consumableType,
|
consumableType: order.consumableType,
|
||||||
|
organizationType: order.organization?.type,
|
||||||
itemsWithServices: order.items?.filter(item => item.recipe?.services && item.recipe.services.length > 0).length || 0,
|
itemsWithServices: order.items?.filter(item => item.recipe?.services && item.recipe.services.length > 0).length || 0,
|
||||||
finalResult: isRecipient && isCreatedBySelf && isApproved && isConsumablesOnly,
|
finalResult: shouldShow,
|
||||||
})
|
})
|
||||||
|
|
||||||
return isRecipient && isCreatedBySelf && isApproved && isConsumablesOnly
|
return shouldShow
|
||||||
})
|
})
|
||||||
|
|
||||||
// Генерируем порядковые номера для заказов
|
// Генерируем порядковые номера для заказов
|
||||||
@ -422,7 +493,7 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-white/60 text-xs">Одобрено</p>
|
<p className="text-white/60 text-xs">Одобрено</p>
|
||||||
<p className="text-sm font-bold text-white">
|
<p className="text-sm font-bold text-white">
|
||||||
{fulfillmentOrders.filter((order) => order.status === 'SUPPLIER_APPROVED').length}
|
{(fulfillmentOrders || []).filter((order) => order.status === 'SUPPLIER_APPROVED').length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -436,7 +507,7 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-white/60 text-xs">Подтверждено</p>
|
<p className="text-white/60 text-xs">Подтверждено</p>
|
||||||
<p className="text-sm font-bold text-white">
|
<p className="text-sm font-bold text-white">
|
||||||
{fulfillmentOrders.filter((order) => order.status === 'CONFIRMED').length}
|
{(fulfillmentOrders || []).filter((order) => order.status === 'CONFIRMED').length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -450,7 +521,7 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-white/60 text-xs">В пути</p>
|
<p className="text-white/60 text-xs">В пути</p>
|
||||||
<p className="text-sm font-bold text-white">
|
<p className="text-sm font-bold text-white">
|
||||||
{fulfillmentOrders.filter((order) => order.status === 'IN_TRANSIT').length}
|
{(fulfillmentOrders || []).filter((order) => order.status === 'IN_TRANSIT').length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -464,7 +535,7 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-white/60 text-xs">Доставлено</p>
|
<p className="text-white/60 text-xs">Доставлено</p>
|
||||||
<p className="text-sm font-bold text-white">
|
<p className="text-sm font-bold text-white">
|
||||||
{fulfillmentOrders.filter((order) => order.status === 'DELIVERED').length}
|
{(fulfillmentOrders || []).filter((order) => order.status === 'DELIVERED').length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -505,28 +576,30 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
<span className="text-white font-semibold text-sm">{order.number}</span>
|
<span className="text-white font-semibold text-sm">{order.number}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Селлер */}
|
{/* Заказчик (селлер или фулфилмент) */}
|
||||||
<div className="flex items-center space-x-2 min-w-0">
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<Store className="h-3 w-3 text-blue-400 mb-0.5" />
|
<Store className="h-3 w-3 text-blue-400 mb-0.5" />
|
||||||
<span className="text-blue-400 text-xs font-medium leading-none">Селлер</span>
|
<span className="text-blue-400 text-xs font-medium leading-none">
|
||||||
|
{order.consumableType === 'SELLER_CONSUMABLES' ? 'Селлер' : 'Заказчик'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1.5">
|
<div className="flex items-center space-x-1.5">
|
||||||
<Avatar className="w-7 h-7 flex-shrink-0">
|
<Avatar className="w-7 h-7 flex-shrink-0">
|
||||||
<AvatarFallback className="bg-blue-500 text-white text-xs">
|
<AvatarFallback className="bg-blue-500 text-white text-xs">
|
||||||
{getInitials(order.partner.name || order.partner.fullName)}
|
{getInitials(order.organization?.name || order.organization?.fullName || 'Н/Д')}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-white font-medium text-sm truncate max-w-[120px]">
|
<h3 className="text-white font-medium text-sm truncate max-w-[120px]">
|
||||||
{order.partner.name || order.partner.fullName}
|
{order.organization?.name || order.organization?.fullName || 'Не указано'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-white/60 text-xs">{order.partner.inn}</p>
|
<p className="text-white/60 text-xs">{order.organization?.type || 'Н/Д'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Поставщик (фулфилмент-центр) */}
|
{/* Поставщик */}
|
||||||
<div className="hidden xl:flex items-center space-x-2 min-w-0">
|
<div className="hidden xl:flex items-center space-x-2 min-w-0">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<Building className="h-3 w-3 text-green-400 mb-0.5" />
|
<Building className="h-3 w-3 text-green-400 mb-0.5" />
|
||||||
@ -535,14 +608,14 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
<div className="flex items-center space-x-1.5">
|
<div className="flex items-center space-x-1.5">
|
||||||
<Avatar className="w-7 h-7 flex-shrink-0">
|
<Avatar className="w-7 h-7 flex-shrink-0">
|
||||||
<AvatarFallback className="bg-green-500 text-white text-xs">
|
<AvatarFallback className="bg-green-500 text-white text-xs">
|
||||||
{getInitials(user?.organization?.name || 'ФФ')}
|
{getInitials(order.partner.name || order.partner.fullName)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-white font-medium text-sm truncate max-w-[100px]">
|
<h3 className="text-white font-medium text-sm truncate max-w-[100px]">
|
||||||
{user?.organization?.name || 'ФФ-центр'}
|
{order.partner.name || order.partner.fullName}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-white/60 text-xs">Наш ФФ</p>
|
<p className="text-white/60 text-xs">{order.partner.inn}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -596,12 +669,12 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
<div className="flex items-center space-x-1.5">
|
<div className="flex items-center space-x-1.5">
|
||||||
<Avatar className="w-6 h-6 flex-shrink-0">
|
<Avatar className="w-6 h-6 flex-shrink-0">
|
||||||
<AvatarFallback className="bg-green-500 text-white text-xs">
|
<AvatarFallback className="bg-green-500 text-white text-xs">
|
||||||
{getInitials(user?.organization?.name || 'ФФ')}
|
{getInitials(order.partner.name || order.partner.fullName)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-white font-medium text-sm truncate">
|
<h3 className="text-white font-medium text-sm truncate">
|
||||||
{user?.organization?.name || 'Фулфилмент-центр'}
|
{order.partner.name || order.partner.fullName}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -719,13 +792,41 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Информация о заказчике */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<h4 className="text-white font-semibold mb-1.5 flex items-center text-sm">
|
||||||
|
<Store className="h-4 w-4 mr-1.5 text-blue-400" />
|
||||||
|
Информация о {order.consumableType === 'SELLER_CONSUMABLES' ? 'селлере' : 'заказчике'}
|
||||||
|
</h4>
|
||||||
|
<div className="bg-white/5 rounded p-2 space-y-1.5">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Building className="h-3 w-3 text-white/60 flex-shrink-0" />
|
||||||
|
<span className="text-white/80 text-sm font-medium">
|
||||||
|
{order.organization?.name || order.organization?.fullName || 'Не указано'}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/60 text-xs">
|
||||||
|
({order.organization?.type || 'Н/Д'})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Информация о поставщике */}
|
{/* Информация о поставщике */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<h4 className="text-white font-semibold mb-1.5 flex items-center text-sm">
|
<h4 className="text-white font-semibold mb-1.5 flex items-center text-sm">
|
||||||
<Building className="h-4 w-4 mr-1.5 text-blue-400" />
|
<Building className="h-4 w-4 mr-1.5 text-green-400" />
|
||||||
Информация о селлере
|
Информация о поставщике
|
||||||
</h4>
|
</h4>
|
||||||
<div className="bg-white/5 rounded p-2 space-y-1.5">
|
<div className="bg-white/5 rounded p-2 space-y-1.5">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Building className="h-3 w-3 text-white/60 flex-shrink-0" />
|
||||||
|
<span className="text-white/80 text-sm font-medium">
|
||||||
|
{order.partner.name || order.partner.fullName}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/60 text-xs">
|
||||||
|
ИНН: {order.partner.inn}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{order.partner.address && (
|
{order.partner.address && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<MapPin className="h-3 w-3 text-white/60 flex-shrink-0" />
|
<MapPin className="h-3 w-3 text-white/60 flex-shrink-0" />
|
||||||
@ -751,23 +852,23 @@ export function FulfillmentConsumablesOrdersTab() {
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="text-white font-semibold mb-1.5 flex items-center text-sm">
|
<h4 className="text-white font-semibold mb-1.5 flex items-center text-sm">
|
||||||
<Package className="h-4 w-4 mr-1.5 text-green-400" />
|
<Package className="h-4 w-4 mr-1.5 text-green-400" />
|
||||||
Товары ({order.items.length})
|
Товары ({order.items?.length || 0})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{order.items.map((item) => (
|
{order.items?.map((item) => (
|
||||||
<div key={item.id} className="bg-white/5 rounded p-2 flex items-center justify-between">
|
<div key={item.id} className="bg-white/5 rounded p-2 flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||||
{item.product.mainImage && (
|
{item.product?.mainImage && (
|
||||||
<img
|
<img
|
||||||
src={item.product.mainImage}
|
src={item.product.mainImage}
|
||||||
alt={item.product.name}
|
alt={item.product?.name || 'Товар'}
|
||||||
className="w-8 h-8 rounded object-cover flex-shrink-0"
|
className="w-8 h-8 rounded object-cover flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h5 className="text-white font-medium text-sm truncate">{item.product.name}</h5>
|
<h5 className="text-white font-medium text-sm truncate">{item.product?.name || 'Без названия'}</h5>
|
||||||
<p className="text-white/60 text-xs">{item.product.article}</p>
|
<p className="text-white/60 text-xs">{item.product?.article || 'Без артикула'}</p>
|
||||||
{item.product.category && (
|
{item.product?.category?.name && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="bg-blue-500/20 text-blue-300 text-xs mt-0.5 px-1.5 py-0.5"
|
className="bg-blue-500/20 text-blue-300 text-xs mt-0.5 px-1.5 py-0.5"
|
||||||
|
@ -10,7 +10,7 @@ import { toast } from 'sonner'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { UPDATE_SUPPLY_PRICE } from '@/graphql/mutations'
|
import { UPDATE_FULFILLMENT_INVENTORY_PRICE } from '@/graphql/mutations'
|
||||||
import { GET_MY_SUPPLIES } from '@/graphql/queries'
|
import { GET_MY_SUPPLIES } from '@/graphql/queries'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
|
||||||
@ -55,16 +55,16 @@ export function SuppliesTab() {
|
|||||||
const [isInitialized, setIsInitialized] = useState(false)
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
|
|
||||||
// Debug информация
|
// Debug информация
|
||||||
console.log('SuppliesTab - User:', user?.phone, 'Type:', user?.organization?.type)
|
console.warn('SuppliesTab - User:', user?.phone, 'Type:', user?.organization?.type)
|
||||||
|
|
||||||
// GraphQL запросы и мутации
|
// GraphQL запросы и мутации
|
||||||
const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, {
|
const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, {
|
||||||
skip: !user || user?.organization?.type !== 'FULFILLMENT',
|
skip: !user || user?.organization?.type !== 'FULFILLMENT',
|
||||||
})
|
})
|
||||||
const [updateSupplyPrice] = useMutation(UPDATE_SUPPLY_PRICE)
|
const [updateFulfillmentInventoryPrice] = useMutation(UPDATE_FULFILLMENT_INVENTORY_PRICE)
|
||||||
|
|
||||||
// Debug GraphQL запроса
|
// Debug GraphQL запроса
|
||||||
console.log('SuppliesTab - Query:', {
|
console.warn('SuppliesTab - Query:', {
|
||||||
skip: !user || user?.organization?.type !== 'FULFILLMENT',
|
skip: !user || user?.organization?.type !== 'FULFILLMENT',
|
||||||
loading,
|
loading,
|
||||||
error: error?.message,
|
error: error?.message,
|
||||||
@ -167,7 +167,7 @@ export function SuppliesTab() {
|
|||||||
// Проверяем валидность цены (может быть пустой)
|
// Проверяем валидность цены (может быть пустой)
|
||||||
const pricePerUnit = supply.pricePerUnit.trim() ? parseFloat(supply.pricePerUnit) : null
|
const pricePerUnit = supply.pricePerUnit.trim() ? parseFloat(supply.pricePerUnit) : null
|
||||||
|
|
||||||
if (supply.pricePerUnit.trim() && (isNaN(pricePerUnit!) || pricePerUnit! <= 0)) {
|
if (supply.pricePerUnit.trim() && (pricePerUnit === null || isNaN(pricePerUnit) || pricePerUnit <= 0)) {
|
||||||
toast.error('Введите корректную цену')
|
toast.error('Введите корректную цену')
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
return
|
return
|
||||||
@ -177,17 +177,18 @@ export function SuppliesTab() {
|
|||||||
pricePerUnit: pricePerUnit,
|
pricePerUnit: pricePerUnit,
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateSupplyPrice({
|
await updateFulfillmentInventoryPrice({
|
||||||
variables: { id: supply.id, input },
|
variables: { id: supply.id, input },
|
||||||
update: (cache, { data }) => {
|
update: (cache, { data }) => {
|
||||||
if (data?.updateSupplyPrice?.supply) {
|
if (data?.updateFulfillmentInventoryPrice?.item) {
|
||||||
const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null
|
const existingData = cache.readQuery({ query: GET_MY_SUPPLIES }) as { mySupplies: Supply[] } | null
|
||||||
if (existingData) {
|
if (existingData) {
|
||||||
|
const updatedItem = data.updateFulfillmentInventoryPrice.item
|
||||||
cache.writeQuery({
|
cache.writeQuery({
|
||||||
query: GET_MY_SUPPLIES,
|
query: GET_MY_SUPPLIES,
|
||||||
data: {
|
data: {
|
||||||
mySupplies: existingData.mySupplies.map((s: Supply) =>
|
mySupplies: existingData.mySupplies.map((s: Supply) =>
|
||||||
s.id === data.updateSupplyPrice.supply.id ? data.updateSupplyPrice.supply : s,
|
s.id === updatedItem.id ? updatedItem : s,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -643,7 +643,34 @@ export const DELETE_SERVICE = gql`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
// Мутации для расходников - только обновление цены разрешено
|
// V2 мутация для обновления цены расходников в инвентаре фулфилмента
|
||||||
|
export const UPDATE_FULFILLMENT_INVENTORY_PRICE = gql`
|
||||||
|
mutation UpdateFulfillmentInventoryPrice($id: ID!, $input: UpdateSupplyPriceInput!) {
|
||||||
|
updateFulfillmentInventoryPrice(id: $id, input: $input) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
item {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
pricePerUnit
|
||||||
|
unit
|
||||||
|
imageUrl
|
||||||
|
warehouseStock
|
||||||
|
isAvailable
|
||||||
|
warehouseConsumableId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
organization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// DEPRECATED: Мутации для расходников - только обновление цены разрешено
|
||||||
export const UPDATE_SUPPLY_PRICE = gql`
|
export const UPDATE_SUPPLY_PRICE = gql`
|
||||||
mutation UpdateSupplyPrice($id: ID!, $input: UpdateSupplyPriceInput!) {
|
mutation UpdateSupplyPrice($id: ID!, $input: UpdateSupplyPriceInput!) {
|
||||||
updateSupplyPrice(id: $id, input: $input) {
|
updateSupplyPrice(id: $id, input: $input) {
|
||||||
|
@ -1155,6 +1155,10 @@ export const GET_SUPPLY_ORDERS = gql`
|
|||||||
name
|
name
|
||||||
article
|
article
|
||||||
description
|
description
|
||||||
|
price
|
||||||
|
quantity
|
||||||
|
images
|
||||||
|
mainImage
|
||||||
category {
|
category {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
320
src/graphql/queries/seller-consumables-v2.ts
Normal file
320
src/graphql/queries/seller-consumables-v2.ts
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 📦 GraphQL ЗАПРОСЫ ДЛЯ СИСТЕМЫ ПОСТАВОК РАСХОДНИКОВ СЕЛЛЕРА V2
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { gql } from '@apollo/client'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 🔍 QUERY - ПОЛУЧЕНИЕ ДАННЫХ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const GET_MY_SELLER_CONSUMABLE_SUPPLIES = gql`
|
||||||
|
query GetMySellerConsumableSupplies {
|
||||||
|
mySellerConsumableSupplies {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
sellerId
|
||||||
|
seller {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
fulfillmentCenterId
|
||||||
|
fulfillmentCenter {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
requestedDeliveryDate
|
||||||
|
notes
|
||||||
|
|
||||||
|
# Данные поставщика
|
||||||
|
supplierId
|
||||||
|
supplier {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
supplierApprovedAt
|
||||||
|
packagesCount
|
||||||
|
estimatedVolume
|
||||||
|
supplierContractId
|
||||||
|
supplierNotes
|
||||||
|
|
||||||
|
# Данные логистики
|
||||||
|
logisticsPartnerId
|
||||||
|
logisticsPartner {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
estimatedDeliveryDate
|
||||||
|
routeId
|
||||||
|
logisticsCost
|
||||||
|
logisticsNotes
|
||||||
|
|
||||||
|
# Данные отгрузки
|
||||||
|
shippedAt
|
||||||
|
trackingNumber
|
||||||
|
|
||||||
|
# Данные приемки
|
||||||
|
deliveredAt
|
||||||
|
receivedById
|
||||||
|
receivedBy {
|
||||||
|
id
|
||||||
|
managerName
|
||||||
|
phone
|
||||||
|
}
|
||||||
|
actualQuantity
|
||||||
|
defectQuantity
|
||||||
|
receiptNotes
|
||||||
|
|
||||||
|
# Экономика
|
||||||
|
totalCostWithDelivery
|
||||||
|
estimatedStorageCost
|
||||||
|
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
productId
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
article
|
||||||
|
price
|
||||||
|
quantity
|
||||||
|
mainImage
|
||||||
|
}
|
||||||
|
requestedQuantity
|
||||||
|
approvedQuantity
|
||||||
|
shippedQuantity
|
||||||
|
receivedQuantity
|
||||||
|
defectQuantity
|
||||||
|
unitPrice
|
||||||
|
totalPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_SELLER_CONSUMABLE_SUPPLY = gql`
|
||||||
|
query GetSellerConsumableSupply($id: ID!) {
|
||||||
|
sellerConsumableSupply(id: $id) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
sellerId
|
||||||
|
seller {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
fulfillmentCenterId
|
||||||
|
fulfillmentCenter {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
requestedDeliveryDate
|
||||||
|
notes
|
||||||
|
|
||||||
|
# Данные поставщика
|
||||||
|
supplierId
|
||||||
|
supplier {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
supplierApprovedAt
|
||||||
|
packagesCount
|
||||||
|
estimatedVolume
|
||||||
|
supplierContractId
|
||||||
|
supplierNotes
|
||||||
|
|
||||||
|
# Данные логистики
|
||||||
|
logisticsPartnerId
|
||||||
|
logisticsPartner {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
estimatedDeliveryDate
|
||||||
|
routeId
|
||||||
|
logisticsCost
|
||||||
|
logisticsNotes
|
||||||
|
|
||||||
|
# Данные отгрузки
|
||||||
|
shippedAt
|
||||||
|
trackingNumber
|
||||||
|
|
||||||
|
# Данные приемки
|
||||||
|
deliveredAt
|
||||||
|
receivedById
|
||||||
|
receivedBy {
|
||||||
|
id
|
||||||
|
managerName
|
||||||
|
phone
|
||||||
|
}
|
||||||
|
actualQuantity
|
||||||
|
defectQuantity
|
||||||
|
receiptNotes
|
||||||
|
|
||||||
|
# Экономика
|
||||||
|
totalCostWithDelivery
|
||||||
|
estimatedStorageCost
|
||||||
|
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
productId
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
article
|
||||||
|
price
|
||||||
|
quantity
|
||||||
|
mainImage
|
||||||
|
}
|
||||||
|
requestedQuantity
|
||||||
|
approvedQuantity
|
||||||
|
shippedQuantity
|
||||||
|
receivedQuantity
|
||||||
|
defectQuantity
|
||||||
|
unitPrice
|
||||||
|
totalPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// Для других типов организаций (фулфилмент, поставщики)
|
||||||
|
export const GET_INCOMING_SELLER_SUPPLIES = gql`
|
||||||
|
query GetIncomingSellerSupplies {
|
||||||
|
incomingSellerSupplies {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
sellerId
|
||||||
|
seller {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
fulfillmentCenterId
|
||||||
|
fulfillmentCenter {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
requestedDeliveryDate
|
||||||
|
notes
|
||||||
|
|
||||||
|
supplierId
|
||||||
|
supplier {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCostWithDelivery
|
||||||
|
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
article
|
||||||
|
}
|
||||||
|
requestedQuantity
|
||||||
|
unitPrice
|
||||||
|
totalPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_MY_SELLER_SUPPLY_REQUESTS = gql`
|
||||||
|
query GetMySellerSupplyRequests {
|
||||||
|
mySellerSupplyRequests {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
sellerId
|
||||||
|
seller {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
fulfillmentCenterId
|
||||||
|
fulfillmentCenter {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inn
|
||||||
|
}
|
||||||
|
requestedDeliveryDate
|
||||||
|
notes
|
||||||
|
|
||||||
|
totalCostWithDelivery
|
||||||
|
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
article
|
||||||
|
}
|
||||||
|
requestedQuantity
|
||||||
|
unitPrice
|
||||||
|
totalPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ✏️ MUTATIONS - ИЗМЕНЕНИЕ ДАННЫХ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const CREATE_SELLER_CONSUMABLE_SUPPLY = gql`
|
||||||
|
mutation CreateSellerConsumableSupply($input: CreateSellerConsumableSupplyInput!) {
|
||||||
|
createSellerConsumableSupply(input: $input) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
supplyOrder {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const UPDATE_SELLER_SUPPLY_STATUS = gql`
|
||||||
|
mutation UpdateSellerSupplyStatus($id: ID!, $status: SellerSupplyOrderStatus!, $notes: String) {
|
||||||
|
updateSellerSupplyStatus(id: $id, status: $status, notes: $notes) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
updatedAt
|
||||||
|
supplierApprovedAt
|
||||||
|
shippedAt
|
||||||
|
deliveredAt
|
||||||
|
supplierNotes
|
||||||
|
receiptNotes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const CANCEL_SELLER_SUPPLY = gql`
|
||||||
|
mutation CancelSellerSupply($id: ID!) {
|
||||||
|
cancelSellerSupply(id: $id) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
@ -11,16 +11,14 @@ import { SmsService } from '@/services/sms-service'
|
|||||||
import { WildberriesService } from '@/services/wildberries-service'
|
import { WildberriesService } from '@/services/wildberries-service'
|
||||||
|
|
||||||
import '@/lib/seed-init' // Автоматическая инициализация БД
|
import '@/lib/seed-init' // Автоматическая инициализация БД
|
||||||
|
|
||||||
// Импорт новых resolvers для системы поставок v2
|
// Импорт новых resolvers для системы поставок v2
|
||||||
import { fulfillmentConsumableV2Queries, fulfillmentConsumableV2Mutations } from './resolvers/fulfillment-consumables-v2'
|
import { fulfillmentConsumableV2Queries, fulfillmentConsumableV2Mutations } from './resolvers/fulfillment-consumables-v2'
|
||||||
|
import { fulfillmentInventoryV2Queries } from './resolvers/fulfillment-inventory-v2'
|
||||||
// 🔒 СИСТЕМА БЕЗОПАСНОСТИ - импорты
|
|
||||||
import { CommercialDataAudit } from './security/commercial-data-audit'
|
import { CommercialDataAudit } from './security/commercial-data-audit'
|
||||||
import { createSecurityContext } from './security/index'
|
import { createSecurityContext } from './security/index'
|
||||||
|
|
||||||
// 🔒 HELPER: Создание безопасного контекста с организационными данными
|
// 🔒 HELPER: Создание безопасного контекста с организационными данными
|
||||||
function createSecureContextWithOrgData(context: Context, currentUser: any) {
|
function createSecureContextWithOrgData(context: Context, currentUser: { organization: { id: string; type: string } }) {
|
||||||
return {
|
return {
|
||||||
...context,
|
...context,
|
||||||
user: {
|
user: {
|
||||||
@ -793,27 +791,30 @@ export const resolvers = {
|
|||||||
return [] // Только фулфилменты имеют расходники
|
return [] // Только фулфилменты имеют расходники
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем ВСЕ расходники из таблицы supply для фулфилмента
|
// Получаем расходники из V2 инвентаря фулфилмента
|
||||||
const allSupplies = await prisma.supply.findMany({
|
const inventoryItems = await prisma.fulfillmentConsumableInventory.findMany({
|
||||||
where: { organizationId: currentUser.organization.id },
|
where: { fulfillmentCenterId: currentUser.organization.id },
|
||||||
include: { organization: true },
|
include: {
|
||||||
orderBy: { createdAt: 'desc' },
|
product: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
},
|
||||||
|
orderBy: { lastSupplyDate: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Преобразуем старую структуру в новую согласно GraphQL схеме
|
// Преобразуем V2 структуру в формат для services/supplies
|
||||||
const transformedSupplies = allSupplies.map((supply) => ({
|
const transformedSupplies = inventoryItems.map((item) => ({
|
||||||
id: supply.id,
|
id: item.id,
|
||||||
name: supply.name,
|
name: item.product.name,
|
||||||
description: supply.description,
|
description: item.product.description || '',
|
||||||
pricePerUnit: supply.price ? parseFloat(supply.price.toString()) : null, // Конвертируем Decimal в Number
|
pricePerUnit: item.resalePrice ? parseFloat(item.resalePrice.toString()) : null, // Цена перепродажи
|
||||||
unit: supply.unit || 'шт', // Единица измерения
|
unit: 'шт', // TODO: добавить unit в Product модель
|
||||||
imageUrl: supply.imageUrl,
|
imageUrl: item.product.mainImage,
|
||||||
warehouseStock: supply.currentStock || 0, // Остаток на складе
|
warehouseStock: item.currentStock, // Текущий остаток V2
|
||||||
isAvailable: (supply.currentStock || 0) > 0, // Есть ли в наличии
|
isAvailable: item.currentStock > 0, // Есть ли в наличии
|
||||||
warehouseConsumableId: supply.id, // Связь со складом (пока используем тот же ID)
|
warehouseConsumableId: item.id, // ID из V2 инвентаря
|
||||||
createdAt: supply.createdAt,
|
createdAt: item.createdAt,
|
||||||
updatedAt: supply.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
organization: supply.organization,
|
organization: item.fulfillmentCenter,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
console.warn('🔥 SUPPLIES RESOLVER - NEW FORMAT:', {
|
console.warn('🔥 SUPPLIES RESOLVER - NEW FORMAT:', {
|
||||||
@ -898,39 +899,68 @@ export const resolvers = {
|
|||||||
throw new GraphQLError('Доступ только для фулфилмент центров')
|
throw new GraphQLError('Доступ только для фулфилмент центров')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем расходники фулфилмента из таблицы Supply
|
// Получаем расходники фулфилмента из V2 таблицы FulfillmentConsumableInventory
|
||||||
const supplies = await prisma.supply.findMany({
|
const inventoryItems = await prisma.fulfillmentConsumableInventory.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId: currentUser.organization.id,
|
fulfillmentCenterId: currentUser.organization.id,
|
||||||
type: 'FULFILLMENT_CONSUMABLES', // Только расходники фулфилмента
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
organization: true,
|
product: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { lastSupplyDate: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Логирование для отладки
|
// Логирование для отладки
|
||||||
console.warn('🔥🔥🔥 FULFILLMENT SUPPLIES RESOLVER CALLED (NEW ARCHITECTURE) 🔥🔥🔥')
|
console.warn('🔥🔥🔥 FULFILLMENT SUPPLIES RESOLVER CALLED (V2 ARCHITECTURE) 🔥🔥🔥')
|
||||||
console.warn('📊 Расходники фулфилмента из склада:', {
|
console.warn('📊 Расходники фулфилмента из V2 инвентаря:', {
|
||||||
organizationId: currentUser.organization.id,
|
organizationId: currentUser.organization.id,
|
||||||
organizationType: currentUser.organization.type,
|
organizationType: currentUser.organization.type,
|
||||||
suppliesCount: supplies.length,
|
inventoryItemsCount: inventoryItems.length,
|
||||||
supplies: supplies.map((s) => ({
|
inventoryItems: inventoryItems.map((item) => ({
|
||||||
id: s.id,
|
id: item.id,
|
||||||
name: s.name,
|
productName: item.product.name,
|
||||||
type: s.type,
|
currentStock: item.currentStock,
|
||||||
status: s.status,
|
totalReceived: item.totalReceived,
|
||||||
currentStock: s.currentStock,
|
totalShipped: item.totalShipped,
|
||||||
quantity: s.quantity,
|
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Преобразуем в формат для фронтенда
|
// Преобразуем V2 инвентарь в V1 формат для совместимости с фронтом
|
||||||
return supplies.map((supply) => ({
|
return inventoryItems.map((item) => ({
|
||||||
...supply,
|
// === ОСНОВНЫЕ ПОЛЯ ===
|
||||||
price: supply.price ? parseFloat(supply.price.toString()) : 0,
|
id: item.id,
|
||||||
shippedQuantity: 0, // Добавляем для совместимости
|
name: item.product.name,
|
||||||
|
article: item.product.article,
|
||||||
|
description: item.product.description || '',
|
||||||
|
category: item.product.category?.name || 'Расходники',
|
||||||
|
unit: 'шт', // TODO: добавить в Product модель
|
||||||
|
|
||||||
|
// === СКЛАДСКИЕ ДАННЫЕ ===
|
||||||
|
currentStock: item.currentStock,
|
||||||
|
minStock: item.minStock,
|
||||||
|
usedStock: item.totalShipped, // V2: всего отгружено = использовано
|
||||||
|
quantity: item.totalReceived, // V2: всего получено = количество
|
||||||
|
shippedQuantity: item.totalShipped, // Для совместимости
|
||||||
|
|
||||||
|
// === ЦЕНЫ ===
|
||||||
|
price: parseFloat(item.averageCost.toString()),
|
||||||
|
|
||||||
|
// === СТАТУС ===
|
||||||
|
status: item.currentStock > 0 ? 'in-stock' : 'out-of-stock',
|
||||||
|
|
||||||
|
// === ДАТЫ ===
|
||||||
|
date: item.lastSupplyDate?.toISOString() || item.createdAt.toISOString(),
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
updatedAt: item.updatedAt.toISOString(),
|
||||||
|
|
||||||
|
// === ПОСТАВЩИК ===
|
||||||
|
supplier: 'Различные поставщики', // V2 инвентарь агрегирует данные от разных поставщиков
|
||||||
|
|
||||||
|
// === ДОПОЛНИТЕЛЬНО ===
|
||||||
|
imageUrl: item.product.mainImage,
|
||||||
|
type: 'FULFILLMENT_CONSUMABLES', // Для совместимости
|
||||||
|
organizationId: item.fulfillmentCenterId,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -970,28 +1000,15 @@ export const resolvers = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
partner: {
|
partner: true,
|
||||||
include: {
|
organization: true,
|
||||||
users: true,
|
fulfillmentCenter: true,
|
||||||
},
|
|
||||||
},
|
|
||||||
organization: {
|
|
||||||
include: {
|
|
||||||
users: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fulfillmentCenter: {
|
|
||||||
include: {
|
|
||||||
users: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logisticsPartner: true,
|
logisticsPartner: true,
|
||||||
items: {
|
items: {
|
||||||
include: {
|
include: {
|
||||||
product: {
|
product: {
|
||||||
include: {
|
include: {
|
||||||
category: true,
|
category: true,
|
||||||
organization: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -2093,11 +2110,41 @@ export const resolvers = {
|
|||||||
throw new GraphQLError('Расходники доступны только у фулфилмент центров')
|
throw new GraphQLError('Расходники доступны только у фулфилмент центров')
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.supply.findMany({
|
// Получаем расходники из V2 инвентаря фулфилмента с правильными ценами
|
||||||
where: { organizationId: args.organizationId },
|
const inventoryItems = await prisma.fulfillmentConsumableInventory.findMany({
|
||||||
include: { organization: true },
|
where: {
|
||||||
orderBy: { createdAt: 'desc' },
|
fulfillmentCenterId: args.organizationId,
|
||||||
|
currentStock: { gt: 0 }, // Только те, что есть в наличии
|
||||||
|
resalePrice: { not: null }, // Только те, у которых установлена цена
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
},
|
||||||
|
orderBy: { lastSupplyDate: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.warn('🔥 COUNTERPARTY SUPPLIES - V2 FORMAT:', {
|
||||||
|
organizationId: args.organizationId,
|
||||||
|
itemsCount: inventoryItems.length,
|
||||||
|
itemsWithPrices: inventoryItems.filter(item => item.resalePrice).length,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Преобразуем V2 формат в формат старого Supply для обратной совместимости
|
||||||
|
return inventoryItems.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.product.name,
|
||||||
|
description: item.product.description || '',
|
||||||
|
price: item.resalePrice ? parseFloat(item.resalePrice.toString()) : 0, // Цена перепродажи из V2
|
||||||
|
quantity: item.currentStock, // Текущий остаток
|
||||||
|
unit: 'шт', // TODO: добавить unit в Product модель
|
||||||
|
category: 'CONSUMABLE',
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
imageUrl: item.product.mainImage,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
organization: item.fulfillmentCenter,
|
||||||
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
// Корзина пользователя
|
// Корзина пользователя
|
||||||
@ -2799,6 +2846,9 @@ export const resolvers = {
|
|||||||
|
|
||||||
// Новая система поставок v2
|
// Новая система поставок v2
|
||||||
...fulfillmentConsumableV2Queries,
|
...fulfillmentConsumableV2Queries,
|
||||||
|
|
||||||
|
// Новая система складских остатков V2 (заменяет старый myFulfillmentSupplies)
|
||||||
|
...fulfillmentInventoryV2Queries,
|
||||||
},
|
},
|
||||||
|
|
||||||
Mutation: {
|
Mutation: {
|
||||||
@ -4760,47 +4810,54 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Находим и обновляем расходник
|
// Находим и обновляем расходник в V2 таблице FulfillmentConsumableInventory
|
||||||
const existingSupply = await prisma.supply.findFirst({
|
const existingInventoryItem = await prisma.fulfillmentConsumableInventory.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: args.id,
|
id: args.id,
|
||||||
organizationId: currentUser.organization.id,
|
fulfillmentCenterId: currentUser.organization.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!existingSupply) {
|
if (!existingInventoryItem) {
|
||||||
throw new GraphQLError('Расходник не найден')
|
throw new GraphQLError('Расходник не найден в инвентаре')
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSupply = await prisma.supply.update({
|
const updatedInventoryItem = await prisma.fulfillmentConsumableInventory.update({
|
||||||
where: { id: args.id },
|
where: { id: args.id },
|
||||||
data: {
|
data: {
|
||||||
pricePerUnit: args.input.pricePerUnit, // Обновляем цену продажи, НЕ цену закупки
|
resalePrice: args.input.pricePerUnit, // Обновляем цену перепродажи в V2
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
include: { organization: true },
|
include: {
|
||||||
|
product: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Преобразуем в новый формат для GraphQL
|
// Преобразуем V2 данные в формат для GraphQL (аналогично mySupplies resolver)
|
||||||
const transformedSupply = {
|
const transformedSupply = {
|
||||||
id: updatedSupply.id,
|
id: updatedInventoryItem.id,
|
||||||
name: updatedSupply.name,
|
name: updatedInventoryItem.product.name,
|
||||||
description: updatedSupply.description,
|
description: updatedInventoryItem.product.description || '',
|
||||||
pricePerUnit: updatedSupply.price ? parseFloat(updatedSupply.price.toString()) : null, // Конвертируем Decimal в Number
|
pricePerUnit: updatedInventoryItem.resalePrice ? parseFloat(updatedInventoryItem.resalePrice.toString()) : null,
|
||||||
unit: updatedSupply.unit || 'шт',
|
unit: 'шт', // TODO: добавить unit в Product модель
|
||||||
imageUrl: updatedSupply.imageUrl,
|
imageUrl: updatedInventoryItem.product.mainImage,
|
||||||
warehouseStock: updatedSupply.currentStock || 0,
|
warehouseStock: updatedInventoryItem.currentStock,
|
||||||
isAvailable: (updatedSupply.currentStock || 0) > 0,
|
isAvailable: updatedInventoryItem.currentStock > 0,
|
||||||
warehouseConsumableId: updatedSupply.id,
|
warehouseConsumableId: updatedInventoryItem.id,
|
||||||
createdAt: updatedSupply.createdAt,
|
createdAt: updatedInventoryItem.createdAt,
|
||||||
updatedAt: updatedSupply.updatedAt,
|
updatedAt: updatedInventoryItem.updatedAt,
|
||||||
organization: updatedSupply.organization,
|
organization: updatedInventoryItem.fulfillmentCenter,
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('🔥 SUPPLY PRICE UPDATED:', {
|
console.warn('🔥 V2 SUPPLY PRICE UPDATED:', {
|
||||||
id: transformedSupply.id,
|
id: transformedSupply.id,
|
||||||
name: transformedSupply.name,
|
name: transformedSupply.name,
|
||||||
oldPrice: existingSupply.price,
|
oldPrice: existingInventoryItem.resalePrice,
|
||||||
newPrice: transformedSupply.pricePerUnit,
|
newPrice: transformedSupply.pricePerUnit,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -4818,6 +4875,88 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// V2 мутация для обновления цены в инвентаре фулфилмента
|
||||||
|
updateFulfillmentInventoryPrice: async (
|
||||||
|
_: unknown,
|
||||||
|
args: {
|
||||||
|
id: string
|
||||||
|
input: {
|
||||||
|
pricePerUnit?: number | null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
context: Context,
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Обновление цен расходников доступно только для фулфилмент центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedInventoryItem = await prisma.fulfillmentConsumableInventory.update({
|
||||||
|
where: {
|
||||||
|
id: args.id,
|
||||||
|
fulfillmentCenterId: currentUser.organization.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
resalePrice: args.input.pricePerUnit,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const transformedItem = {
|
||||||
|
id: updatedInventoryItem.id,
|
||||||
|
name: updatedInventoryItem.product.name,
|
||||||
|
description: updatedInventoryItem.product.description || '',
|
||||||
|
pricePerUnit: updatedInventoryItem.resalePrice ? parseFloat(updatedInventoryItem.resalePrice.toString()) : null,
|
||||||
|
unit: 'шт',
|
||||||
|
imageUrl: updatedInventoryItem.product.mainImage,
|
||||||
|
warehouseStock: updatedInventoryItem.currentStock,
|
||||||
|
isAvailable: updatedInventoryItem.currentStock > 0,
|
||||||
|
warehouseConsumableId: updatedInventoryItem.id,
|
||||||
|
createdAt: updatedInventoryItem.createdAt,
|
||||||
|
updatedAt: updatedInventoryItem.updatedAt,
|
||||||
|
organization: updatedInventoryItem.fulfillmentCenter,
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('🔥 V2 FULFILLMENT INVENTORY PRICE UPDATED:', {
|
||||||
|
id: transformedItem.id,
|
||||||
|
name: transformedItem.name,
|
||||||
|
newPrice: transformedItem.pricePerUnit,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Цена расходника успешно обновлена',
|
||||||
|
item: transformedItem,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating fulfillment inventory price:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при обновлении цены расходника',
|
||||||
|
item: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Использовать расходники фулфилмента
|
// Использовать расходники фулфилмента
|
||||||
useFulfillmentSupplies: async (
|
useFulfillmentSupplies: async (
|
||||||
_: unknown,
|
_: unknown,
|
||||||
@ -8217,6 +8356,120 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Сначала пытаемся найти селлерскую поставку
|
||||||
|
const sellerSupply = await prisma.sellerConsumableSupplyOrder.findFirst({
|
||||||
|
where: {
|
||||||
|
id: args.id,
|
||||||
|
fulfillmentCenterId: currentUser.organization.id,
|
||||||
|
status: 'SHIPPED',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seller: true, // Селлер-создатель заказа
|
||||||
|
supplier: true, // Поставщик
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Если нашли селлерскую поставку, обрабатываем её
|
||||||
|
if (sellerSupply) {
|
||||||
|
// Обновляем статус селлерской поставки
|
||||||
|
const updatedSellerOrder = await prisma.sellerConsumableSupplyOrder.update({
|
||||||
|
where: { id: args.id },
|
||||||
|
data: {
|
||||||
|
status: 'DELIVERED',
|
||||||
|
deliveredAt: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seller: true,
|
||||||
|
supplier: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Добавляем расходники в склад фулфилмента как SELLER_CONSUMABLES
|
||||||
|
console.warn('📦 Обновляем склад фулфилмента для селлерской поставки...')
|
||||||
|
for (const item of sellerSupply.items) {
|
||||||
|
const existingSupply = await prisma.supply.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
article: item.product.article,
|
||||||
|
type: 'SELLER_CONSUMABLES',
|
||||||
|
sellerOwnerId: sellerSupply.sellerId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingSupply) {
|
||||||
|
await prisma.supply.update({
|
||||||
|
where: { id: existingSupply.id },
|
||||||
|
data: {
|
||||||
|
currentStock: existingSupply.currentStock + item.requestedQuantity,
|
||||||
|
status: 'in-stock',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.warn(
|
||||||
|
`📈 Обновлен расходник селлера "${item.product.name}" (владелец: ${sellerSupply.seller.name}): ${existingSupply.currentStock} -> ${existingSupply.currentStock + item.requestedQuantity}`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await prisma.supply.create({
|
||||||
|
data: {
|
||||||
|
name: item.product.name,
|
||||||
|
article: item.product.article,
|
||||||
|
description: `Расходники селлера ${sellerSupply.seller.name || sellerSupply.seller.fullName}`,
|
||||||
|
price: item.unitPrice,
|
||||||
|
quantity: item.requestedQuantity,
|
||||||
|
actualQuantity: item.requestedQuantity,
|
||||||
|
currentStock: item.requestedQuantity,
|
||||||
|
usedStock: 0,
|
||||||
|
unit: 'шт',
|
||||||
|
category: item.product.category?.name || 'Расходники',
|
||||||
|
status: 'in-stock',
|
||||||
|
supplier: sellerSupply.supplier?.name || sellerSupply.supplier?.fullName || 'Поставщик',
|
||||||
|
type: 'SELLER_CONSUMABLES',
|
||||||
|
sellerOwnerId: sellerSupply.sellerId,
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.warn(
|
||||||
|
`➕ Создан новый расходник селлера "${item.product.name}" (владелец: ${sellerSupply.seller.name}): ${item.requestedQuantity} единиц`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Селлерская поставка принята фулфилментом. Расходники добавлены на склад.',
|
||||||
|
order: {
|
||||||
|
id: updatedSellerOrder.id,
|
||||||
|
status: updatedSellerOrder.status,
|
||||||
|
deliveryDate: updatedSellerOrder.requestedDeliveryDate,
|
||||||
|
totalAmount: updatedSellerOrder.items.reduce((sum, item) => sum + (item.unitPrice * item.requestedQuantity), 0),
|
||||||
|
totalItems: updatedSellerOrder.items.reduce((sum, item) => sum + item.requestedQuantity, 0),
|
||||||
|
partner: updatedSellerOrder.supplier,
|
||||||
|
organization: updatedSellerOrder.seller,
|
||||||
|
fulfillmentCenter: updatedSellerOrder.fulfillmentCenter,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если селлерской поставки нет, ищем старую поставку
|
||||||
const existingOrder = await prisma.supplyOrder.findFirst({
|
const existingOrder = await prisma.supplyOrder.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: args.id,
|
id: args.id,
|
||||||
@ -10239,7 +10492,7 @@ resolvers.Mutation = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Добавляем v2 mutations через spread
|
// Добавляем v2 mutations через spread
|
||||||
...fulfillmentConsumableV2Mutations
|
...fulfillmentConsumableV2Mutations,
|
||||||
}
|
}
|
||||||
|
|
||||||
/* // Резолвер для парсинга JSON рецептуры в SupplyOrderItem
|
/* // Резолвер для парсинга JSON рецептуры в SupplyOrderItem
|
||||||
|
@ -3,12 +3,13 @@ import { JSONScalar, DateTimeScalar } from '../scalars'
|
|||||||
|
|
||||||
import { authResolvers } from './auth'
|
import { authResolvers } from './auth'
|
||||||
import { employeeResolvers } from './employees'
|
import { employeeResolvers } from './employees'
|
||||||
|
import { fulfillmentConsumableV2Queries, fulfillmentConsumableV2Mutations } from './fulfillment-consumables-v2'
|
||||||
import { logisticsResolvers } from './logistics'
|
import { logisticsResolvers } from './logistics'
|
||||||
import { referralResolvers } from './referrals'
|
import { referralResolvers } from './referrals'
|
||||||
import { integrateSecurityWithExistingResolvers } from './secure-integration'
|
import { integrateSecurityWithExistingResolvers } from './secure-integration'
|
||||||
import { secureSuppliesResolvers } from './secure-supplies'
|
import { secureSuppliesResolvers } from './secure-supplies'
|
||||||
|
import { sellerConsumableQueries, sellerConsumableMutations } from './seller-consumables'
|
||||||
import { suppliesResolvers } from './supplies'
|
import { suppliesResolvers } from './supplies'
|
||||||
import { fulfillmentConsumableV2Queries, fulfillmentConsumableV2Mutations } from './fulfillment-consumables-v2'
|
|
||||||
|
|
||||||
// Типы для резолверов
|
// Типы для резолверов
|
||||||
interface ResolverObject {
|
interface ResolverObject {
|
||||||
@ -111,6 +112,12 @@ const mergedResolvers = mergeResolvers(
|
|||||||
Query: fulfillmentConsumableV2Queries,
|
Query: fulfillmentConsumableV2Queries,
|
||||||
Mutation: fulfillmentConsumableV2Mutations,
|
Mutation: fulfillmentConsumableV2Mutations,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// НОВЫЕ резолверы для системы поставок селлера
|
||||||
|
{
|
||||||
|
Query: sellerConsumableQueries,
|
||||||
|
Mutation: sellerConsumableMutations,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Применяем middleware безопасности ко всем резолверам
|
// Применяем middleware безопасности ко всем резолверам
|
||||||
|
701
src/graphql/resolvers/seller-consumables.ts
Normal file
701
src/graphql/resolvers/seller-consumables.ts
Normal file
@ -0,0 +1,701 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 📦 РЕЗОЛВЕРЫ ДЛЯ СИСТЕМЫ ПОСТАВОК РАСХОДНИКОВ СЕЛЛЕРА
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { notifyOrganization } from '@/lib/realtime'
|
||||||
|
|
||||||
|
import { Context } from '../context'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 🔍 QUERY RESOLVERS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const sellerConsumableQueries = {
|
||||||
|
// Мои поставки (для селлеров - заказы которые я создал)
|
||||||
|
mySellerConsumableSupplies: 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 !== 'SELLER') {
|
||||||
|
return [] // Возвращаем пустой массив если пользователь не селлер
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplies = await prisma.sellerConsumableSupplyOrder.findMany({
|
||||||
|
where: {
|
||||||
|
sellerId: user.organizationId!,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
supplier: true,
|
||||||
|
logisticsPartner: true,
|
||||||
|
receivedBy: true,
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return supplies
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching seller consumable supplies:', error)
|
||||||
|
return [] // Возвращаем пустой массив вместо throw
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Входящие заказы от селлеров (для фулфилмента - заказы в мой ФФ)
|
||||||
|
incomingSellerSupplies: 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') {
|
||||||
|
return [] // Доступно только для фулфилмент-центров
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplies = await prisma.sellerConsumableSupplyOrder.findMany({
|
||||||
|
where: {
|
||||||
|
fulfillmentCenterId: user.organizationId!,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
supplier: true,
|
||||||
|
logisticsPartner: true,
|
||||||
|
receivedBy: true,
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return supplies
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching incoming seller supplies:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Заказы от селлеров (для поставщиков - заказы которые нужно выполнить)
|
||||||
|
mySellerSupplyRequests: 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.sellerConsumableSupplyOrder.findMany({
|
||||||
|
where: {
|
||||||
|
supplierId: user.organizationId!,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
supplier: true,
|
||||||
|
logisticsPartner: true,
|
||||||
|
receivedBy: true,
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return supplies
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching seller supply requests:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получение конкретной поставки селлера
|
||||||
|
sellerConsumableSupply: 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.sellerConsumableSupplyOrder.findUnique({
|
||||||
|
where: { id: args.id },
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
supplier: true,
|
||||||
|
logisticsPartner: true,
|
||||||
|
receivedBy: true,
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supply) {
|
||||||
|
throw new GraphQLError('Поставка не найдена')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка доступа в зависимости от типа организации
|
||||||
|
const hasAccess =
|
||||||
|
(user.organization.type === 'SELLER' && supply.sellerId === user.organizationId) ||
|
||||||
|
(user.organization.type === 'FULFILLMENT' && supply.fulfillmentCenterId === user.organizationId) ||
|
||||||
|
(user.organization.type === 'WHOLESALE' && supply.supplierId === user.organizationId) ||
|
||||||
|
(user.organization.type === 'LOGIST' && supply.logisticsPartnerId === user.organizationId)
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
throw new GraphQLError('Нет доступа к этой поставке')
|
||||||
|
}
|
||||||
|
|
||||||
|
return supply
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching seller consumable supply:', error)
|
||||||
|
throw new GraphQLError('Ошибка получения поставки')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ✏️ MUTATION RESOLVERS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const sellerConsumableMutations = {
|
||||||
|
// Создание поставки расходников селлера
|
||||||
|
createSellerConsumableSupply: async (_: unknown, args: { input: any }, 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 !== 'SELLER') {
|
||||||
|
throw new GraphQLError('Доступно только для селлеров')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fulfillmentCenterId, supplierId, logisticsPartnerId, requestedDeliveryDate, items, notes } = args.input
|
||||||
|
|
||||||
|
// 🔍 ВАЛИДАЦИЯ ПАРТНЕРОВ
|
||||||
|
|
||||||
|
// Проверяем что фулфилмент-центр существует и является партнером
|
||||||
|
const fulfillmentCenter = await prisma.organization.findUnique({
|
||||||
|
where: { id: fulfillmentCenterId },
|
||||||
|
include: {
|
||||||
|
counterpartiesAsCounterparty: {
|
||||||
|
where: { organizationId: user.organizationId! },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fulfillmentCenter || fulfillmentCenter.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Фулфилмент-центр не найден или имеет неверный тип')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fulfillmentCenter.counterpartiesAsCounterparty.length === 0) {
|
||||||
|
throw new GraphQLError('Нет партнерских отношений с данным фулфилмент-центром')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем поставщика
|
||||||
|
const supplier = await prisma.organization.findUnique({
|
||||||
|
where: { id: supplierId },
|
||||||
|
include: {
|
||||||
|
counterpartiesAsCounterparty: {
|
||||||
|
where: { organizationId: user.organizationId! },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supplier || supplier.type !== 'WHOLESALE') {
|
||||||
|
throw new GraphQLError('Поставщик не найден или имеет неверный тип')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supplier.counterpartiesAsCounterparty.length === 0) {
|
||||||
|
throw new GraphQLError('Нет партнерских отношений с данным поставщиком')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔍 ВАЛИДАЦИЯ ТОВАРОВ И ОСТАТКОВ
|
||||||
|
let totalAmount = 0
|
||||||
|
const validatedItems = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const product = await prisma.product.findUnique({
|
||||||
|
where: { id: item.productId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new GraphQLError(`Товар с ID ${item.productId} не найден`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.organizationId !== supplierId) {
|
||||||
|
throw new GraphQLError(`Товар ${product.name} не принадлежит выбранному поставщику`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.type !== 'CONSUMABLE') {
|
||||||
|
throw new GraphQLError(`Товар ${product.name} не является расходником`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ ПРОВЕРКА ОСТАТКОВ У ПОСТАВЩИКА
|
||||||
|
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0)
|
||||||
|
|
||||||
|
if (item.requestedQuantity > availableStock) {
|
||||||
|
throw new GraphQLError(
|
||||||
|
`Недостаточно остатков товара "${product.name}". ` +
|
||||||
|
`Доступно: ${availableStock} шт., запрашивается: ${item.requestedQuantity} шт.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemTotalPrice = product.price.toNumber() * item.requestedQuantity
|
||||||
|
totalAmount += itemTotalPrice
|
||||||
|
|
||||||
|
validatedItems.push({
|
||||||
|
productId: item.productId,
|
||||||
|
requestedQuantity: item.requestedQuantity,
|
||||||
|
unitPrice: product.price,
|
||||||
|
totalPrice: itemTotalPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚀 СОЗДАНИЕ ПОСТАВКИ В ТРАНЗАКЦИИ
|
||||||
|
const supplyOrder = await prisma.$transaction(async (tx) => {
|
||||||
|
// Создаем заказ поставки
|
||||||
|
const newOrder = await tx.sellerConsumableSupplyOrder.create({
|
||||||
|
data: {
|
||||||
|
sellerId: user.organizationId!,
|
||||||
|
fulfillmentCenterId,
|
||||||
|
supplierId,
|
||||||
|
logisticsPartnerId,
|
||||||
|
requestedDeliveryDate: new Date(requestedDeliveryDate),
|
||||||
|
notes,
|
||||||
|
status: 'PENDING',
|
||||||
|
totalCostWithDelivery: totalAmount,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
supplier: true,
|
||||||
|
logisticsPartner: true,
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Создаем позиции заказа
|
||||||
|
for (const item of validatedItems) {
|
||||||
|
await tx.sellerConsumableSupplyItem.create({
|
||||||
|
data: {
|
||||||
|
supplyOrderId: newOrder.id,
|
||||||
|
...item,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Резервируем товар у поставщика (увеличиваем ordered)
|
||||||
|
await tx.product.update({
|
||||||
|
where: { id: item.productId },
|
||||||
|
data: {
|
||||||
|
ordered: {
|
||||||
|
increment: item.requestedQuantity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return newOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📨 УВЕДОМЛЕНИЯ
|
||||||
|
// Уведомляем поставщика о новом заказе
|
||||||
|
await notifyOrganization(supplierId, `Новый заказ от селлера ${user.organization.name}`, 'SUPPLY_ORDER_CREATED', {
|
||||||
|
orderId: supplyOrder.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Уведомляем фулфилмент о входящей поставке
|
||||||
|
await notifyOrganization(
|
||||||
|
fulfillmentCenterId,
|
||||||
|
`Селлер ${user.organization.name} оформил поставку на ваш склад`,
|
||||||
|
'INCOMING_SUPPLY_ORDER',
|
||||||
|
{ orderId: supplyOrder.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Поставка успешно создана',
|
||||||
|
supplyOrder: await prisma.sellerConsumableSupplyOrder.findUnique({
|
||||||
|
where: { id: supplyOrder.id },
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
supplier: true,
|
||||||
|
logisticsPartner: true,
|
||||||
|
receivedBy: true,
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating seller consumable supply:', error)
|
||||||
|
|
||||||
|
if (error instanceof GraphQLError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new GraphQLError('Ошибка создания поставки')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновление статуса поставки (для поставщиков и фулфилмента)
|
||||||
|
updateSellerSupplyStatus: async (
|
||||||
|
_: unknown,
|
||||||
|
args: { id: string; status: string; notes?: string },
|
||||||
|
context: Context,
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация')
|
||||||
|
}
|
||||||
|
|
||||||
|
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.sellerConsumableSupplyOrder.findUnique({
|
||||||
|
where: { id: args.id },
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
supplier: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supply) {
|
||||||
|
throw new GraphQLError('Поставка не найдена')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔐 ПРОВЕРКА ПРАВ И ЛОГИКИ ПЕРЕХОДОВ СТАТУСОВ
|
||||||
|
|
||||||
|
const { status } = args
|
||||||
|
const currentStatus = supply.status
|
||||||
|
const orgType = user.organization.type
|
||||||
|
|
||||||
|
// Только поставщики могут переводить PENDING → APPROVED
|
||||||
|
if (status === 'APPROVED' && currentStatus === 'PENDING') {
|
||||||
|
if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) {
|
||||||
|
throw new GraphQLError('Только поставщик может одобрить заказ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Только поставщики могут переводить APPROVED → SHIPPED
|
||||||
|
else if (status === 'SHIPPED' && currentStatus === 'APPROVED') {
|
||||||
|
if (orgType !== 'WHOLESALE' || supply.supplierId !== user.organizationId) {
|
||||||
|
throw new GraphQLError('Только поставщик может отметить отгрузку')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Только фулфилмент может переводить SHIPPED → DELIVERED
|
||||||
|
else if (status === 'DELIVERED' && currentStatus === 'SHIPPED') {
|
||||||
|
if (orgType !== 'FULFILLMENT' || supply.fulfillmentCenterId !== user.organizationId) {
|
||||||
|
throw new GraphQLError('Только фулфилмент-центр может подтвердить получение')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Только фулфилмент может переводить DELIVERED → COMPLETED
|
||||||
|
else if (status === 'COMPLETED' && currentStatus === 'DELIVERED') {
|
||||||
|
if (orgType !== 'FULFILLMENT' || supply.fulfillmentCenterId !== user.organizationId) {
|
||||||
|
throw new GraphQLError('Только фулфилмент-центр может завершить поставку')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new GraphQLError('Недопустимый переход статуса')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 📅 ОБНОВЛЕНИЕ ВРЕМЕННЫХ МЕТОК
|
||||||
|
const updateData: any = {
|
||||||
|
status,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'APPROVED' && orgType === 'WHOLESALE') {
|
||||||
|
updateData.supplierApprovedAt = new Date()
|
||||||
|
updateData.supplierNotes = args.notes
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'SHIPPED' && orgType === 'WHOLESALE') {
|
||||||
|
updateData.shippedAt = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'DELIVERED' && orgType === 'FULFILLMENT') {
|
||||||
|
updateData.deliveredAt = new Date()
|
||||||
|
updateData.receivedById = user.id
|
||||||
|
updateData.receiptNotes = args.notes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 ОБНОВЛЕНИЕ В БАЗЕ
|
||||||
|
const updatedSupply = await prisma.sellerConsumableSupplyOrder.update({
|
||||||
|
where: { id: args.id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
supplier: true,
|
||||||
|
logisticsPartner: true,
|
||||||
|
receivedBy: true,
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📨 УВЕДОМЛЕНИЯ О СМЕНЕ СТАТУСА
|
||||||
|
if (status === 'APPROVED') {
|
||||||
|
await notifyOrganization(
|
||||||
|
supply.sellerId,
|
||||||
|
`Поставка одобрена поставщиком ${user.organization.name}`,
|
||||||
|
'SUPPLY_APPROVED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'SHIPPED') {
|
||||||
|
await notifyOrganization(
|
||||||
|
supply.sellerId,
|
||||||
|
`Поставка отгружена поставщиком ${user.organization.name}`,
|
||||||
|
'SUPPLY_SHIPPED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
await notifyOrganization(
|
||||||
|
supply.fulfillmentCenterId,
|
||||||
|
'Поставка в пути. Ожидается доставка',
|
||||||
|
'SUPPLY_IN_TRANSIT',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'DELIVERED') {
|
||||||
|
await notifyOrganization(
|
||||||
|
supply.sellerId,
|
||||||
|
`Поставка доставлена в ${supply.fulfillmentCenter.name}`,
|
||||||
|
'SUPPLY_DELIVERED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'COMPLETED') {
|
||||||
|
// 📦 СОЗДАНИЕ РАСХОДНИКОВ НА СКЛАДЕ ФУЛФИЛМЕНТА
|
||||||
|
|
||||||
|
for (const item of updatedSupply.items) {
|
||||||
|
await prisma.supply.create({
|
||||||
|
data: {
|
||||||
|
name: item.product.name,
|
||||||
|
article: item.product.article || `SELLER-${item.product.id}`,
|
||||||
|
description: `Расходники селлера ${supply.seller.name}`,
|
||||||
|
price: item.unitPrice,
|
||||||
|
quantity: item.receivedQuantity || item.requestedQuantity,
|
||||||
|
currentStock: item.receivedQuantity || item.requestedQuantity,
|
||||||
|
usedStock: 0,
|
||||||
|
type: 'SELLER_CONSUMABLES', // ✅ Тип для селлерских расходников
|
||||||
|
sellerOwnerId: supply.sellerId, // ✅ Владелец - селлер
|
||||||
|
organizationId: supply.fulfillmentCenterId, // ✅ Хранитель - фулфилмент
|
||||||
|
category: item.product.category || 'Расходники селлера',
|
||||||
|
status: 'available',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await notifyOrganization(
|
||||||
|
supply.sellerId,
|
||||||
|
`Поставка завершена. Расходники размещены на складе ${supply.fulfillmentCenter.name}`,
|
||||||
|
'SUPPLY_COMPLETED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedSupply
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating seller supply status:', error)
|
||||||
|
|
||||||
|
if (error instanceof GraphQLError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new GraphQLError('Ошибка обновления статуса поставки')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Отмена поставки селлером (только PENDING/APPROVED)
|
||||||
|
cancelSellerSupply: async (_: unknown, args: { id: string }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user?.organization || user.organization.type !== 'SELLER') {
|
||||||
|
throw new GraphQLError('Только селлеры могут отменять свои поставки')
|
||||||
|
}
|
||||||
|
|
||||||
|
const supply = await prisma.sellerConsumableSupplyOrder.findUnique({
|
||||||
|
where: { id: args.id },
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!supply) {
|
||||||
|
throw new GraphQLError('Поставка не найдена')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supply.sellerId !== user.organizationId) {
|
||||||
|
throw new GraphQLError('Вы можете отменить только свои поставки')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ ПРОВЕРКА ВОЗМОЖНОСТИ ОТМЕНЫ (только PENDING и APPROVED)
|
||||||
|
if (!['PENDING', 'APPROVED'].includes(supply.status)) {
|
||||||
|
throw new GraphQLError('Поставку можно отменить только в статусе PENDING или APPROVED')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 ОТМЕНА В ТРАНЗАКЦИИ
|
||||||
|
const cancelledSupply = await prisma.$transaction(async (tx) => {
|
||||||
|
// Обновляем статус
|
||||||
|
const updated = await tx.sellerConsumableSupplyOrder.update({
|
||||||
|
where: { id: args.id },
|
||||||
|
data: {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seller: true,
|
||||||
|
fulfillmentCenter: true,
|
||||||
|
supplier: true,
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
product: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Освобождаем зарезервированные товары у поставщика
|
||||||
|
for (const item of supply.items) {
|
||||||
|
await tx.product.update({
|
||||||
|
where: { id: item.productId },
|
||||||
|
data: {
|
||||||
|
ordered: {
|
||||||
|
decrement: item.requestedQuantity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📨 УВЕДОМЛЕНИЯ ОБ ОТМЕНЕ
|
||||||
|
if (supply.supplierId) {
|
||||||
|
await notifyOrganization(
|
||||||
|
supply.supplierId,
|
||||||
|
`Селлер ${user.organization.name} отменил заказ`,
|
||||||
|
'SUPPLY_CANCELLED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await notifyOrganization(
|
||||||
|
supply.fulfillmentCenterId,
|
||||||
|
`Селлер ${user.organization.name} отменил поставку`,
|
||||||
|
'SUPPLY_CANCELLED',
|
||||||
|
{ orderId: args.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
return cancelledSupply
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cancelling seller supply:', error)
|
||||||
|
|
||||||
|
if (error instanceof GraphQLError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new GraphQLError('Ошибка отмены поставки')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
@ -203,7 +203,9 @@ export const typeDefs = gql`
|
|||||||
deleteService(id: ID!): Boolean!
|
deleteService(id: ID!): Boolean!
|
||||||
|
|
||||||
# Работа с расходниками (только обновление цены разрешено)
|
# Работа с расходниками (только обновление цены разрешено)
|
||||||
|
# DEPRECATED: используйте updateFulfillmentInventoryPrice
|
||||||
updateSupplyPrice(id: ID!, input: UpdateSupplyPriceInput!): SupplyResponse!
|
updateSupplyPrice(id: ID!, input: UpdateSupplyPriceInput!): SupplyResponse!
|
||||||
|
updateFulfillmentInventoryPrice(id: ID!, input: UpdateSupplyPriceInput!): FulfillmentInventoryResponse!
|
||||||
|
|
||||||
# Использование расходников фулфилмента
|
# Использование расходников фулфилмента
|
||||||
useFulfillmentSupplies(input: UseFulfillmentSuppliesInput!): SupplyResponse!
|
useFulfillmentSupplies(input: UseFulfillmentSuppliesInput!): SupplyResponse!
|
||||||
@ -653,6 +655,28 @@ export const typeDefs = gql`
|
|||||||
pricePerUnit: Float # Может быть null (цена не установлена)
|
pricePerUnit: Float # Может быть null (цена не установлена)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# V2 типы для инвентаря фулфилмента
|
||||||
|
type FulfillmentInventoryItem {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
description: String
|
||||||
|
pricePerUnit: Float # Цена перепродажи
|
||||||
|
unit: String!
|
||||||
|
imageUrl: String
|
||||||
|
warehouseStock: Int!
|
||||||
|
isAvailable: Boolean!
|
||||||
|
warehouseConsumableId: ID!
|
||||||
|
createdAt: DateTime!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
organization: Organization!
|
||||||
|
}
|
||||||
|
|
||||||
|
type FulfillmentInventoryResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String!
|
||||||
|
item: FulfillmentInventoryItem
|
||||||
|
}
|
||||||
|
|
||||||
input UseFulfillmentSuppliesInput {
|
input UseFulfillmentSuppliesInput {
|
||||||
supplyId: ID!
|
supplyId: ID!
|
||||||
quantityUsed: Int!
|
quantityUsed: Int!
|
||||||
@ -1734,6 +1758,7 @@ export const typeDefs = gql`
|
|||||||
# Input типы для создания поставок
|
# Input типы для создания поставок
|
||||||
input CreateFulfillmentConsumableSupplyInput {
|
input CreateFulfillmentConsumableSupplyInput {
|
||||||
supplierId: ID!
|
supplierId: ID!
|
||||||
|
logisticsPartnerId: ID # Логистический партнер (опционально)
|
||||||
requestedDeliveryDate: DateTime!
|
requestedDeliveryDate: DateTime!
|
||||||
items: [FulfillmentConsumableSupplyItemInput!]!
|
items: [FulfillmentConsumableSupplyItemInput!]!
|
||||||
notes: String
|
notes: String
|
||||||
@ -1744,6 +1769,19 @@ export const typeDefs = gql`
|
|||||||
requestedQuantity: Int!
|
requestedQuantity: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Input для приемки поставки
|
||||||
|
input ReceiveFulfillmentConsumableSupplyInput {
|
||||||
|
supplyOrderId: ID!
|
||||||
|
items: [ReceiveFulfillmentConsumableSupplyItemInput!]!
|
||||||
|
notes: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input ReceiveFulfillmentConsumableSupplyItemInput {
|
||||||
|
productId: ID!
|
||||||
|
receivedQuantity: Int!
|
||||||
|
defectQuantity: Int
|
||||||
|
}
|
||||||
|
|
||||||
# Response типы
|
# Response типы
|
||||||
type CreateFulfillmentConsumableSupplyResult {
|
type CreateFulfillmentConsumableSupplyResult {
|
||||||
success: Boolean!
|
success: Boolean!
|
||||||
@ -1751,11 +1789,18 @@ export const typeDefs = gql`
|
|||||||
supplyOrder: FulfillmentConsumableSupplyOrder
|
supplyOrder: FulfillmentConsumableSupplyOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SupplierConsumableSupplyResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String!
|
||||||
|
order: FulfillmentConsumableSupplyOrder
|
||||||
|
}
|
||||||
|
|
||||||
# Расширяем Query и Mutation для новой системы
|
# Расширяем Query и Mutation для новой системы
|
||||||
extend type Query {
|
extend type Query {
|
||||||
# Новые запросы для системы поставок v2
|
# Новые запросы для системы поставок v2
|
||||||
myFulfillmentConsumableSupplies: [FulfillmentConsumableSupplyOrder!]!
|
myFulfillmentConsumableSupplies: [FulfillmentConsumableSupplyOrder!]!
|
||||||
mySupplierConsumableSupplies: [FulfillmentConsumableSupplyOrder!]!
|
mySupplierConsumableSupplies: [FulfillmentConsumableSupplyOrder!]!
|
||||||
|
myLogisticsConsumableSupplies: [FulfillmentConsumableSupplyOrder!]!
|
||||||
fulfillmentConsumableSupply(id: ID!): FulfillmentConsumableSupplyOrder
|
fulfillmentConsumableSupply(id: ID!): FulfillmentConsumableSupplyOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1764,5 +1809,162 @@ export const typeDefs = gql`
|
|||||||
createFulfillmentConsumableSupply(
|
createFulfillmentConsumableSupply(
|
||||||
input: CreateFulfillmentConsumableSupplyInput!
|
input: CreateFulfillmentConsumableSupplyInput!
|
||||||
): CreateFulfillmentConsumableSupplyResult!
|
): CreateFulfillmentConsumableSupplyResult!
|
||||||
|
|
||||||
|
# Приемка поставки с автоматическим обновлением инвентаря
|
||||||
|
receiveFulfillmentConsumableSupply(
|
||||||
|
input: ReceiveFulfillmentConsumableSupplyInput!
|
||||||
|
): CreateFulfillmentConsumableSupplyResult!
|
||||||
|
|
||||||
|
# Мутации поставщика для V2 расходников фулфилмента
|
||||||
|
supplierApproveConsumableSupply(id: ID!): SupplierConsumableSupplyResponse!
|
||||||
|
supplierRejectConsumableSupply(id: ID!, reason: String): SupplierConsumableSupplyResponse!
|
||||||
|
supplierShipConsumableSupply(id: ID!): SupplierConsumableSupplyResponse!
|
||||||
|
|
||||||
|
# Мутации логистики для V2 расходников фулфилмента
|
||||||
|
logisticsConfirmConsumableSupply(id: ID!): SupplierConsumableSupplyResponse!
|
||||||
|
logisticsRejectConsumableSupply(id: ID!, reason: String): SupplierConsumableSupplyResponse!
|
||||||
|
|
||||||
|
# Мутация фулфилмента для приемки V2 расходников
|
||||||
|
fulfillmentReceiveConsumableSupply(
|
||||||
|
id: ID!
|
||||||
|
items: [ReceiveFulfillmentConsumableSupplyItemInput!]!
|
||||||
|
notes: String
|
||||||
|
): SupplierConsumableSupplyResponse!
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 📦 СИСТЕМА ПОСТАВОК РАСХОДНИКОВ СЕЛЛЕРА
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# 5-статусная система для поставок селлера
|
||||||
|
enum SellerSupplyOrderStatus {
|
||||||
|
PENDING # Ожидает одобрения поставщика
|
||||||
|
APPROVED # Одобрено поставщиком
|
||||||
|
SHIPPED # Отгружено
|
||||||
|
DELIVERED # Доставлено
|
||||||
|
COMPLETED # Завершено
|
||||||
|
CANCELLED # Отменено
|
||||||
|
}
|
||||||
|
|
||||||
|
# Основной тип для поставки расходников селлера
|
||||||
|
type SellerConsumableSupplyOrder {
|
||||||
|
id: ID!
|
||||||
|
status: SellerSupplyOrderStatus!
|
||||||
|
|
||||||
|
# Данные селлера (создатель)
|
||||||
|
sellerId: ID!
|
||||||
|
seller: Organization!
|
||||||
|
fulfillmentCenterId: ID!
|
||||||
|
fulfillmentCenter: Organization!
|
||||||
|
requestedDeliveryDate: DateTime!
|
||||||
|
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
|
||||||
|
|
||||||
|
# Экономика (для будущего раздела экономики)
|
||||||
|
totalCostWithDelivery: Float
|
||||||
|
estimatedStorageCost: Float
|
||||||
|
|
||||||
|
items: [SellerConsumableSupplyItem!]!
|
||||||
|
createdAt: DateTime!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
# Позиция в поставке селлера
|
||||||
|
type SellerConsumableSupplyItem {
|
||||||
|
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 CreateSellerConsumableSupplyInput {
|
||||||
|
fulfillmentCenterId: ID! # куда доставлять (FULFILLMENT партнер)
|
||||||
|
supplierId: ID! # от кого заказывать (WHOLESALE партнер)
|
||||||
|
logisticsPartnerId: ID # кто везет (LOGIST партнер, опционально)
|
||||||
|
requestedDeliveryDate: DateTime! # когда нужно
|
||||||
|
items: [SellerConsumableSupplyItemInput!]!
|
||||||
|
notes: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input SellerConsumableSupplyItemInput {
|
||||||
|
productId: ID! # какой расходник заказываем
|
||||||
|
requestedQuantity: Int! # сколько нужно
|
||||||
|
}
|
||||||
|
|
||||||
|
# Response типы для селлера
|
||||||
|
type CreateSellerConsumableSupplyResult {
|
||||||
|
success: Boolean!
|
||||||
|
message: String!
|
||||||
|
supplyOrder: SellerConsumableSupplyOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
# Расширяем Query для селлерских поставок
|
||||||
|
extend type Query {
|
||||||
|
# Поставки селлера (мои заказы)
|
||||||
|
mySellerConsumableSupplies: [SellerConsumableSupplyOrder!]!
|
||||||
|
|
||||||
|
# Входящие заказы от селлеров (для фулфилмента)
|
||||||
|
incomingSellerSupplies: [SellerConsumableSupplyOrder!]!
|
||||||
|
|
||||||
|
# Поставки селлеров для поставщиков
|
||||||
|
mySellerSupplyRequests: [SellerConsumableSupplyOrder!]!
|
||||||
|
|
||||||
|
# Конкретная поставка селлера
|
||||||
|
sellerConsumableSupply(id: ID!): SellerConsumableSupplyOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
# Расширяем Mutation для селлерских поставок
|
||||||
|
extend type Mutation {
|
||||||
|
# Создание поставки расходников селлера
|
||||||
|
createSellerConsumableSupply(
|
||||||
|
input: CreateSellerConsumableSupplyInput!
|
||||||
|
): CreateSellerConsumableSupplyResult!
|
||||||
|
|
||||||
|
# Обновление статуса поставки (для поставщиков и фулфилмента)
|
||||||
|
updateSellerSupplyStatus(
|
||||||
|
id: ID!
|
||||||
|
status: SellerSupplyOrderStatus!
|
||||||
|
notes: String
|
||||||
|
): SellerConsumableSupplyOrder!
|
||||||
|
|
||||||
|
# Отмена поставки селлером (только PENDING/APPROVED)
|
||||||
|
cancelSellerSupply(id: ID!): SellerConsumableSupplyOrder!
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
Reference in New Issue
Block a user