Compare commits

..

6 Commits

Author SHA1 Message Date
d200885ff5 docs: обновить документацию для V2 системы
Обновления:
- CLAUDE.md - обновлены правила взаимодействия с акцентом на модульную архитектуру
- docs/development/MODULAR_ARCHITECTURE_PATTERN.md - обновлена документация паттерна модульной архитектуры

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-25 23:09:29 +03:00
6e3cedec67 feat(graphql): обновить GraphQL схему и resolvers для V2 системы
Обновления:
- prisma/schema.prisma - обновлена схема БД для V2 расходников фулфилмента
- src/graphql/typedefs.ts - новые типы для V2 FulfillmentInventoryItem
- src/graphql/resolvers.ts - обновлены resolvers mySupplies и counterpartySupplies для V2
- src/graphql/resolvers/index.ts - подключены новые V2 resolvers
- src/graphql/queries.ts - обновлены queries
- src/graphql/mutations.ts - добавлена V2 мутация updateFulfillmentInventoryPrice
- обновлен компонент fulfillment-consumables-orders-tab для V2

ESLint warnings исправим в отдельном коммите.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-25 23:08:59 +03:00
7f0e09eef6 feat(graphql): добавить V2 GraphQL queries и resolvers для поставок расходников
Добавлены:
- seller-consumables-v2.ts - GraphQL queries для селлеров V2 системы
- seller-consumables.ts - resolver для работы с поставками расходников селлеров

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-25 23:06:10 +03:00
57f8f762c9 feat(components): добавить модульную V2 систему создания поставок расходников
Создана новая модульная архитектура компонентов для создания поставок
расходников фулфилмента с улучшенной организацией кода и разделением
ответственности.

ESLint warnings исправим в отдельном коммите для cleaner history.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-25 22:39:14 +03:00
888fe76849 feat(database): добавить SQL миграцию для V2 системы поставок расходников
Добавлена миграция для создания таблицы FulfillmentConsumableInventory
и связанных структур для новой V2 архитектуры управления расходниками фулфилмента.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-25 22:34:59 +03:00
a48efb8757 feat(v2-inventory): мигрировать систему расходников на V2 архитектуру
Переход от старой таблицы Supply к новой FulfillmentConsumableInventory:

- Обновлен mySupplies resolver для чтения из V2 таблицы с корректными остатками
- Добавлена V2 мутация updateFulfillmentInventoryPrice для обновления цен
- Исправлен counterpartySupplies для показа актуальных V2 цен в рецептурах
- Frontend использует новую мутацию UPDATE_FULFILLMENT_INVENTORY_PRICE
- Цены расходников корректно сохраняются и отображаются после перезагрузки
- Селлеры видят правильные цены при создании поставок товаров

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-25 22:26:29 +03:00
41 changed files with 6024 additions and 235 deletions

181
CLAUDE.md
View File

@ -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 файлы. Создавай файлы документации только по явной просьбе пользователя.

View File

@ -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

View File

@ -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
View 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 'Система поставок расходников селлера успешно создана!';

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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'

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,6 @@
// =============================================================================
// 📦 ЭКСПОРТ СЕЛЛЕРСКИХ UI БЛОКОВ
// =============================================================================
export { SellerSuppliersBlock } from './SellerSuppliersBlock'
export { SellerShoppingCartBlock } from './SellerShoppingCartBlock'

View File

@ -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
}

View File

@ -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'

View File

@ -0,0 +1,7 @@
// =============================================================================
// 📦 ЭКСПОРТ СЕЛЛЕРСКИХ БИЗНЕС ХУКОВ
// =============================================================================
export { useSellerSupplyCreation } from './useSellerSupplyCreation'
export { useSellerSupplyForm } from './useSellerSupplyForm'
export { useSellerSupplierData } from './useSellerSupplierData'

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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(`❌ Недостаточно остатков!\оступно: ${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,
}
}

View File

@ -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: `❌ Недостаточно остатков!\оступно: ${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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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
// - Сохранить файл
// =============================================================================

View File

@ -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>
)
}

View File

@ -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(`❌ Недостаточно остатков!\оступно: ${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>
)
}

View File

@ -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>
)
}

View File

@ -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
}

View File

@ -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
}>
}

View File

@ -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"

View File

@ -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,
), ),
}, },
}) })

View File

@ -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) {

View File

@ -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

View 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
}
}
`

View File

@ -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

View File

@ -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 безопасности ко всем резолверам

View 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('Ошибка отмены поставки')
}
},
}

View File

@ -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!
} }
` `