feat(supplier-orders): добавить параметры поставки в таблицу заявок
- Добавлены колонки Объём и Грузовые места между Цена товаров и Статус - Реализованы инпуты для ввода volume и packagesCount в статусе PENDING для роли WHOLESALE - Добавлена мутация UPDATE_SUPPLY_PARAMETERS с проверками безопасности - Скрыта строка Поставщик для роли WHOLESALE (поставщик знает свои данные) - Исправлено выравнивание таблицы при скрытии уровня поставщика - Реорганизованы документы: legacy-rules/, docs/, docs-and-reports/ ВНИМАНИЕ: Компонент multilevel-supplies-table.tsx (1697 строк) нарушает правило модульной архитектуры (>800 строк требует рефакторинга) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
39
CLAUDE.md
39
CLAUDE.md
@ -34,6 +34,45 @@
|
||||
- Упоминание "дизайн", "UI", "компонент", "стиль" → legacy-rules/visual-design-rules.md
|
||||
- Упоминание "компонент", "создание", "dashboard", ">500 строк", "архитектура" → MODULAR_ARCHITECTURE_PATTERN.md
|
||||
|
||||
## 🛑 ЗАПРЕТ ПРЕДПОЛОЖЕНИЙ
|
||||
|
||||
**КРИТИЧЕСКИ ВАЖНО:** При любой неоднозначности в запросе - ОСТАНОВИТЬСЯ немедленно и уточнить.
|
||||
|
||||
### ОБЯЗАТЕЛЬНЫЙ АЛГОРИТМ ПРИ НЕОДНОЗНАЧНОСТИ:
|
||||
|
||||
1. **СТОП-СИГНАЛ**: Если можно понять запрос двумя или более способами
|
||||
2. **НЕМЕДЛЕННАЯ ОСТАНОВКА**: Прекратить любые действия
|
||||
3. **ОБЯЗАТЕЛЬНЫЙ ВОПРОС**: "Не уверен. Уточните, пожалуйста:"
|
||||
4. **ПЕРЕЧИСЛИТЬ ВАРИАНТЫ**: Показать все возможные понимания
|
||||
5. **ДОЖДАТЬСЯ ОТВЕТА**: Не предпринимать действий до получения четкого указания
|
||||
|
||||
### ПРИМЕРЫ СТОП-СИГНАЛОВ:
|
||||
|
||||
- Упоминание "таблица поставщика" - КАКАЯ именно таблица? В каком файле?
|
||||
- "Удали колонку" - ИЗ КАКОЙ таблицы? Какой компонент?
|
||||
- "Исправь ошибку" - КАКУЮ ошибку? В каком файле?
|
||||
- "Добавь функцию" - В КАКОЙ файл? Какая именно функция?
|
||||
|
||||
### ЗАПРЕЩЕННЫЕ ФРАЗЫ:
|
||||
|
||||
❌ "Возможно, вы имеете в виду..."
|
||||
❌ "Скорее всего, нужно..."
|
||||
❌ "Попробую в этом файле..."
|
||||
❌ "Наверное, это..."
|
||||
|
||||
### ОБЯЗАТЕЛЬНЫЕ ФРАЗЫ:
|
||||
|
||||
✅ "Не уверен. Уточните, пожалуйста:"
|
||||
✅ "Какой именно файл/компонент?"
|
||||
✅ "Вы имеете в виду X или Y?"
|
||||
✅ "Правильно ли я понимаю, что..."
|
||||
|
||||
### НАКАЗАНИЕ ЗА НАРУШЕНИЕ:
|
||||
|
||||
- Откат ВСЕХ изменений через комментарии
|
||||
- Полная остановка работы до получения уточнений
|
||||
- Начало заново с правильных вопросов
|
||||
|
||||
## 🚨 ПЕРЕХОД К НОВОЙ АРХИТЕКТУРЕ ПРАВИЛ
|
||||
|
||||
**ВАЖНО:** Система правил реорганизована для соответствия архитектуре кода:
|
||||
|
@ -625,7 +625,7 @@ query GetMyCounterparties($type: OrganizationType) {
|
||||
### МУТАЦИИ ПОСТАВЩИКОВ:
|
||||
|
||||
```graphql
|
||||
# Одобрение заказа поставщиком с опциональными полями упаковки
|
||||
# Одобрение заказа поставщиком с дополнительными параметрами поставки
|
||||
mutation SupplierApproveOrder(
|
||||
$orderId: ID!
|
||||
$packagesCount: Int
|
||||
@ -635,23 +635,25 @@ mutation SupplierApproveOrder(
|
||||
) {
|
||||
supplierApproveOrder(
|
||||
id: $orderId
|
||||
packagesCount: $packagesCount # Опционально: для логистических расчетов
|
||||
volume: $volume # Опционально: для планирования логистики
|
||||
readyDate: $readyDate # Опционально: дата готовности к отгрузке
|
||||
notes: $notes # Опционально: комментарии
|
||||
packagesCount: $packagesCount # Параметр поставки: количество грузовых мест
|
||||
volume: $volume # Параметр поставки: объем груза
|
||||
readyDate: $readyDate # Параметр поставки: дата готовности к отгрузке
|
||||
notes: $notes # Параметр поставки: дополнительная информация
|
||||
) {
|
||||
success
|
||||
message
|
||||
order {
|
||||
id
|
||||
status # PENDING → SUPPLIER_APPROVED
|
||||
deliveryDate # Основной параметр поставки
|
||||
totalAmount # Ключевой параметр поставки - общая стоимость
|
||||
totalItems # Параметр поставки - количество товаров
|
||||
organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
totalAmount
|
||||
packagesCount # null если не указано
|
||||
volume # null если не указано
|
||||
packagesCount # Параметр поставки (опционально)
|
||||
volume # Параметр поставки (опционально)
|
||||
readyDate # null если не указано
|
||||
notes # null если не указано
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ interface SupplyOrder {
|
||||
sellerConsumables: Supply[] // Расходники селлера
|
||||
}
|
||||
|
||||
// Упаковочная информация (опциональная)
|
||||
// Параметры поставки (опциональные)
|
||||
packagesCount?: number // Количество грузовых мест
|
||||
volume?: number // Объем груза в м³
|
||||
readyDate?: Date // Дата готовности к отгрузке
|
||||
@ -567,7 +567,7 @@ export class CommercialDataAudit {
|
||||
"quantity": 10
|
||||
// ❌ НЕ видит recipe
|
||||
}],
|
||||
"packagesCount": 2, // ✅ Видит упаковочную информацию
|
||||
"packagesCount": 2, // ✅ Видит параметры поставки
|
||||
"volume": 0.5,
|
||||
// ❌ НЕ видит totalAmount, услуги ФФ, логистику
|
||||
}
|
||||
|
302
docs/business-processes/SUPPLY_PARAMETERS_RULES.md
Normal file
302
docs/business-processes/SUPPLY_PARAMETERS_RULES.md
Normal file
@ -0,0 +1,302 @@
|
||||
# ПРАВИЛА ПАРАМЕТРОВ ПОСТАВКИ
|
||||
|
||||
## 📋 **ОПРЕДЕЛЕНИЕ ПАРАМЕТРОВ ПОСТАВКИ**
|
||||
|
||||
**Параметры поставки** - это все характеристики и данные, которые описывают конкретную поставку товаров в системе SFERA.
|
||||
|
||||
## 🎯 **КЛАССИФИКАЦИЯ ПАРАМЕТРОВ ПОСТАВКИ**
|
||||
|
||||
### **1. ОБЯЗАТЕЛЬНЫЕ ПАРАМЕТРЫ:**
|
||||
- **`id`** - Уникальный идентификатор поставки
|
||||
- **`deliveryDate`** - Дата поставки (когда товары должны быть доставлены)
|
||||
- **`totalAmount`** - Общая стоимость поставки
|
||||
- **`totalItems`** - Количество товаров в поставке
|
||||
- **`organizationId`** - Идентификатор заказчика
|
||||
- **`partnerId`** - Идентификатор поставщика
|
||||
- **`status`** - Статус поставки (PENDING, APPROVED, SHIPPED и т.д.)
|
||||
|
||||
### **2. ЦЕНОВЫЕ ПАРАМЕТРЫ:**
|
||||
- **`totalAmount`** - Общая сумма поставки
|
||||
- **`goodsPrice`** - Стоимость товаров (расчетное поле)
|
||||
- **`servicesPrice`** - Стоимость услуг фулфилмента (расчетное поле)
|
||||
- **`logisticsPrice`** - Стоимость логистических услуг (расчетное поле)
|
||||
- **`sellerConsumablesPrice`** - Стоимость расходников селлера (расчетное поле)
|
||||
- **`ffConsumablesPrice`** - Стоимость расходников фулфилмента (расчетное поле)
|
||||
|
||||
### **3. ТОВАРНЫЕ ПАРАМЕТРЫ:**
|
||||
- **`items[]`** - Массив товаров с количествами и ценами
|
||||
- **`productId`** - ID товара в позиции
|
||||
- **`quantity`** - Количество каждого товара
|
||||
- **`price`** - Цена за единицу товара
|
||||
- **`totalPrice`** - Общая стоимость позиции
|
||||
- **`services[]`** - Массив услуг для товара
|
||||
- **`fulfillmentConsumables[]`** - Расходники фулфилмента для товара
|
||||
- **`sellerConsumables[]`** - Расходники селлера для товара
|
||||
- **`marketplaceCardId`** - ID карточки маркетплейса
|
||||
|
||||
### **4. ЛОГИСТИЧЕСКИЕ ПАРАМЕТРЫ:**
|
||||
- **`packagesCount`** - Количество грузовых мест
|
||||
- **`volume`** - Объём груза в м³
|
||||
- **`routes[]`** - Маршруты доставки
|
||||
- **`fromLocation`** - Точка забора груза
|
||||
- **`toLocation`** - Точка доставки
|
||||
- **`fromAddress`** - Полный адрес забора
|
||||
- **`toAddress`** - Полный адрес доставки
|
||||
- **`distance`** - Расстояние маршрута в км
|
||||
- **`estimatedTime`** - Время доставки в часах
|
||||
- **`logisticsPartnerId`** - ID логистического партнера
|
||||
|
||||
### **5. УПРАВЛЕНЧЕСКИЕ ПАРАМЕТРЫ:**
|
||||
- **`responsibleEmployee`** - Ответственный сотрудник
|
||||
- **`notes`** - Комментарии и дополнительная информация
|
||||
- **`createdAt`** - Дата создания поставки
|
||||
- **`updatedAt`** - Дата последнего обновления
|
||||
- **`fulfillmentCenterId`** - ID фулфилмент центра
|
||||
- **`consumableType`** - Тип расходников
|
||||
|
||||
<!-- ОТКАТ: Расширенная классификация параметров поставки
|
||||
|
||||
### **6. ВРЕМЕННЫЕ ПАРАМЕТРЫ:**
|
||||
- **`deliveryDate`** - Плановая дата доставки
|
||||
- **`readyDate`** - Дата готовности к отгрузке
|
||||
- **`actualDeliveryDate`** - Фактическая дата доставки
|
||||
- **`createdAt`** - Время создания заявки
|
||||
- **`approvedAt`** - Время одобрения поставщиком
|
||||
- **`shippedAt`** - Время отгрузки
|
||||
|
||||
### **7. КОНТАКТНЫЕ ПАРАМЕТРЫ:**
|
||||
- **`supplierContact`** - Контактное лицо поставщика
|
||||
- **`buyerContact`** - Контактное лицо покупателя
|
||||
- **`logisticsContact`** - Контактное лицо логистики
|
||||
- **`fulfillmentContact`** - Контактное лицо фулфилмента
|
||||
|
||||
### **8. АДРЕСНЫЕ ПАРАМЕТРЫ:**
|
||||
- **`pickupAddress`** - Адрес забора груза
|
||||
- **`deliveryAddress`** - Адрес доставки
|
||||
- **`warehouseLocation`** - Местоположение склада
|
||||
- **`marketLocation`** - Адрес торговой точки
|
||||
|
||||
### **9. КАЧЕСТВЕННЫЕ ПАРАМЕТРЫ:**
|
||||
- **`qualityRequirements`** - Требования к качеству товаров
|
||||
- **`temperatureConditions`** - Температурные условия хранения/транспортировки
|
||||
- **`handlingInstructions`** - Инструкции по обращению с грузом
|
||||
- **`packagingType`** - Тип упаковки товаров
|
||||
- **`fragileGoods`** - Отметка о хрупких товарах
|
||||
|
||||
### **10. ФИНАНСОВЫЕ ПАРАМЕТРЫ:**
|
||||
- **`paymentTerms`** - Условия оплаты
|
||||
- **`paymentMethod`** - Способ оплаты
|
||||
- **`currency`** - Валюта расчетов
|
||||
- **`taxRate`** - Налоговая ставка
|
||||
- **`invoiceNumber`** - Номер счета
|
||||
- **`paymentStatus`** - Статус оплаты
|
||||
|
||||
### **11. ТЕХНИЧЕСКИЕ ПАРАМЕТРЫ:**
|
||||
- **`supplyOrderId`** - Уникальный идентификатор поставки
|
||||
- **`supplierOrderNumber`** - Внутренний номер заказа поставщика
|
||||
- **`trackingNumber`** - Трек-номер для отслеживания
|
||||
- **`barcodes`** - Штрих-коды товаров
|
||||
- **`rfidTags`** - RFID метки для автоматизации
|
||||
|
||||
### **12. АНАЛИТИЧЕСКИЕ ПАРАМЕТРЫ:**
|
||||
- **`profitMargin`** - Маржинальность поставки
|
||||
- **`supplierRating`** - Рейтинг поставщика
|
||||
- **`deliveryReliability`** - Надежность доставки
|
||||
- **`orderPriority`** - Приоритет заказа
|
||||
- **`seasonalFactor`** - Сезонный коэффициент
|
||||
|
||||
-->
|
||||
|
||||
## 🔄 **ЖИЗНЕННЫЙ ЦИКЛ ПАРАМЕТРОВ ПОСТАВКИ**
|
||||
|
||||
### **Этап 1: Создание поставки (SELLER)**
|
||||
```typescript
|
||||
// Селлер указывает базовые параметры поставки
|
||||
{
|
||||
deliveryDate: "2025-08-25",
|
||||
totalItems: 100,
|
||||
items: [...products],
|
||||
partnerId: "supplier-123",
|
||||
organizationId: "seller-456"
|
||||
}
|
||||
```
|
||||
|
||||
### **Этап 2: Одобрение поставщиком (WHOLESALE)**
|
||||
```typescript
|
||||
// Поставщик может дополнить параметры поставки
|
||||
{
|
||||
status: "SUPPLIER_APPROVED",
|
||||
packagesCount: 3, // Новый параметр
|
||||
volume: 1.2, // Новый параметр
|
||||
readyDate: "2025-08-24", // Новый параметр
|
||||
notes: "Хрупкий груз" // Новый параметр
|
||||
}
|
||||
```
|
||||
|
||||
### **Этап 3: Логистическое планирование (LOGIST)**
|
||||
```typescript
|
||||
// Логистика использует параметры для расчетов
|
||||
{
|
||||
logisticsPrice: calculateByVolume(volume, distance),
|
||||
routes: [
|
||||
{
|
||||
from: "Садовод",
|
||||
to: "ФФ Центр",
|
||||
packagesCount: 3,
|
||||
volume: 1.2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 🛡️ **ПРАВИЛА БЕЗОПАСНОСТИ ПАРАМЕТРОВ**
|
||||
|
||||
### **Видимость по ролям:**
|
||||
|
||||
| Параметр поставки | SELLER | WHOLESALE | FULFILLMENT | LOGIST |
|
||||
|------------------|--------|-----------|-------------|---------|
|
||||
| deliveryDate | ✅ | ✅ | ✅ | ✅ |
|
||||
| totalAmount | ✅ | ❌ | ❌ | ❌ |
|
||||
| productPrice | ✅ | ✅ | ❌ | ❌ |
|
||||
| packagesCount | ✅ | ✅ | ✅ | ✅ |
|
||||
| volume | ✅ | ✅ | ✅ | ✅ |
|
||||
| recipe | ✅ | ❌ | ✅ | ❌ |
|
||||
| logisticsPrice | ✅ | ❌ | ✅ | ✅ |
|
||||
|
||||
### **Права на изменение:**
|
||||
|
||||
- **SELLER**: Может изменять до одобрения поставщиком
|
||||
- **WHOLESALE**: Может добавлять логистические параметры при одобрении
|
||||
- **FULFILLMENT**: Не может изменять, только просматривать
|
||||
- **LOGIST**: Может добавлять маршруты и логистические расчеты
|
||||
|
||||
## 🎛️ **UI КОМПОНЕНТЫ ДЛЯ ПАРАМЕТРОВ ПОСТАВКИ**
|
||||
|
||||
### **Форма ввода параметров поставщиком:**
|
||||
|
||||
```jsx
|
||||
<div className="supply-parameters-form">
|
||||
<h3>Параметры поставки</h3>
|
||||
|
||||
{/* Основные параметры */}
|
||||
<Input name="deliveryDate" label="Дата поставки" type="date" required />
|
||||
<Input name="totalAmount" label="Общая стоимость" type="number" readOnly />
|
||||
|
||||
{/* Логистические параметры (опционально) */}
|
||||
<Input name="packagesCount" label="Количество грузовых мест" type="number" />
|
||||
<Input name="volume" label="Объём груза (м³)" type="number" step="0.01" />
|
||||
|
||||
{/* Дополнительные параметры */}
|
||||
<Input name="readyDate" label="Дата готовности" type="date" />
|
||||
<Textarea name="notes" label="Комментарии к поставке" />
|
||||
</div>
|
||||
```
|
||||
|
||||
### **Отображение в таблице заявок:**
|
||||
|
||||
```jsx
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Дата поставки</TableHead>
|
||||
<TableHead>Количество товаров</TableHead>
|
||||
<TableHead>Стоимость товаров</TableHead>
|
||||
{userRole !== 'WHOLESALE' && <TableHead>Общая стоимость</TableHead>}
|
||||
<TableHead>Параметры логистики</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{supplies.map(supply => (
|
||||
<TableRow key={supply.id}>
|
||||
<TableCell>{supply.deliveryDate}</TableCell>
|
||||
<TableCell>{supply.totalItems}</TableCell>
|
||||
<TableCell>{formatCurrency(supply.productPrice)}</TableCell>
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<TableCell>{formatCurrency(supply.totalAmount)}</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{supply.packagesCount && `${supply.packagesCount} мест`}
|
||||
{supply.volume && ` • ${supply.volume} м³`}
|
||||
</TableCell>
|
||||
<TableCell><StatusBadge status={supply.status} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
```
|
||||
|
||||
## 📊 **ВАЛИДАЦИЯ ПАРАМЕТРОВ ПОСТАВКИ**
|
||||
|
||||
### **Серверная валидация:**
|
||||
|
||||
```typescript
|
||||
function validateSupplyParameters(params: SupplyParameters) {
|
||||
// Обязательные параметры
|
||||
if (!params.deliveryDate) throw new Error('Дата поставки обязательна')
|
||||
if (!params.totalItems || params.totalItems <= 0) {
|
||||
throw new Error('Количество товаров должно быть больше 0')
|
||||
}
|
||||
|
||||
// Логистические параметры
|
||||
if (params.packagesCount && params.packagesCount <= 0) {
|
||||
throw new Error('Количество грузовых мест должно быть положительным')
|
||||
}
|
||||
|
||||
if (params.volume && params.volume <= 0) {
|
||||
throw new Error('Объём груза должен быть положительным')
|
||||
}
|
||||
|
||||
// Даты
|
||||
if (params.readyDate && params.deliveryDate) {
|
||||
if (new Date(params.readyDate) > new Date(params.deliveryDate)) {
|
||||
throw new Error('Дата готовности не может быть позже даты поставки')
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 **ПОИСК И ФИЛЬТРАЦИЯ ПО ПАРАМЕТРАМ**
|
||||
|
||||
### **GraphQL запрос с фильтрами:**
|
||||
|
||||
```graphql
|
||||
query GetSuppliesByParameters(
|
||||
$deliveryDateFrom: DateTime
|
||||
$deliveryDateTo: DateTime
|
||||
$minAmount: Float
|
||||
$maxAmount: Float
|
||||
$status: SupplyStatus
|
||||
$hasLogisticsParams: Boolean
|
||||
) {
|
||||
mySupplyOrders(
|
||||
filters: {
|
||||
deliveryDate: { gte: $deliveryDateFrom, lte: $deliveryDateTo }
|
||||
totalAmount: { gte: $minAmount, lte: $maxAmount }
|
||||
status: $status
|
||||
packagesCount: { isNull: $hasLogisticsParams }
|
||||
}
|
||||
) {
|
||||
id
|
||||
deliveryDate
|
||||
totalAmount
|
||||
totalItems
|
||||
packagesCount
|
||||
volume
|
||||
status
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📈 **АНАЛИТИКА ПО ПАРАМЕТРАМ ПОСТАВКИ**
|
||||
|
||||
### **Ключевые метрики:**
|
||||
- Средний объём поставки (в м³)
|
||||
- Среднее количество грузовых мест
|
||||
- Распределение по стоимости поставок
|
||||
- Время цикла от создания до доставки
|
||||
- Процент поставок с дополнительными параметрами
|
||||
|
||||
---
|
||||
|
||||
*Все параметры поставки служат для оптимизации логистических процессов и обеспечения прозрачности цепочки поставок в экосистеме SFERA.*
|
@ -191,9 +191,9 @@ model SupplyOrder {
|
||||
totalAmount Decimal @db.Decimal(12, 2) // Общая сумма
|
||||
totalItems Int // Количество позиций
|
||||
|
||||
// Логистические данные
|
||||
packagesCount Int? // Грузовые места
|
||||
volume Float? // Объём в м³
|
||||
// Параметры поставки (дополнительные)
|
||||
packagesCount Int? // Количество грузовых мест - параметр поставки
|
||||
volume Float? // Объём груза в м³ - параметр поставки
|
||||
|
||||
// Управление
|
||||
responsibleEmployee String? // ID ответственного
|
||||
|
@ -902,11 +902,11 @@ function getMarketLabel(market: string): string {
|
||||
}
|
||||
```
|
||||
|
||||
#### **2. ОПЦИОНАЛЬНЫЕ ПОЛЯ УПАКОВКИ ПРИ ОДОБРЕНИИ:**
|
||||
#### **2. ОПЦИОНАЛЬНЫЕ ПАРАМЕТРЫ ПОСТАВКИ ПРИ ОДОБРЕНИИ:**
|
||||
|
||||
```jsx
|
||||
{
|
||||
/* ОПЦИОНАЛЬНЫЕ поля для поставщика - отображаются сразу при одобрении заказа */
|
||||
/* ОПЦИОНАЛЬНЫЕ параметры поставки для поставщика - отображаются при одобрении заказа */
|
||||
}
|
||||
;<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
@ -918,7 +918,7 @@ function getMarketLabel(market: string): string {
|
||||
aria-describedby="packages-help"
|
||||
/>
|
||||
<p id="packages-help" className="text-xs text-white/60 mt-1">
|
||||
Используется логистикой для расчета тарифов
|
||||
Параметр поставки для логистических расчетов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -926,7 +926,27 @@ function getMarketLabel(market: string): string {
|
||||
<Label htmlFor="volume">Объем груза (м³)</Label>
|
||||
<Input id="volume" type="number" step="0.01" placeholder="0.00 (опционально)" aria-describedby="volume-help" />
|
||||
<p id="volume-help" className="text-xs text-white/60 mt-1">
|
||||
Помогает логистике в планировании маршрутов
|
||||
Параметр поставки для планирования маршрутов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="deliveryDate">Дата поставки</Label>
|
||||
<GlassDatePicker
|
||||
id="deliveryDate"
|
||||
placeholder="Выберите дату поставки"
|
||||
aria-describedby="delivery-help"
|
||||
/>
|
||||
<p id="delivery-help" className="text-xs text-white/60 mt-1">
|
||||
Основной параметр поставки - когда товары должны быть доставлены
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="totalAmount">Общая стоимость товаров</Label>
|
||||
<Input id="totalAmount" type="number" readOnly className="bg-white/5" />
|
||||
<p className="text-xs text-white/60 mt-1">
|
||||
Ключевой параметр поставки - автоматически рассчитывается
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
@ -51,7 +51,16 @@ const handler = startServerAndCreateNextHandler<NextRequest, Context>(server, {
|
||||
const token = authHeader?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
return { user: null, admin: null, prisma }
|
||||
return {
|
||||
user: null,
|
||||
currentUser: null,
|
||||
admin: null,
|
||||
prisma,
|
||||
req: {
|
||||
ip: req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip'),
|
||||
get: (header: string) => req.headers.get(header),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@ -77,7 +86,12 @@ const handler = startServerAndCreateNextHandler<NextRequest, Context>(server, {
|
||||
username: decoded.username,
|
||||
},
|
||||
user: null,
|
||||
currentUser: null,
|
||||
prisma,
|
||||
req: {
|
||||
ip: req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip'),
|
||||
get: (header: string) => req.headers.get(header),
|
||||
},
|
||||
}
|
||||
} else if (decoded.userId && decoded.phone) {
|
||||
// Получаем пользователя с организацией из базы
|
||||
@ -96,17 +110,46 @@ const handler = startServerAndCreateNextHandler<NextRequest, Context>(server, {
|
||||
id: user.id,
|
||||
phone: decoded.phone,
|
||||
organizationId: user.organization?.id,
|
||||
organization: user.organization,
|
||||
}
|
||||
: null,
|
||||
currentUser: user
|
||||
? {
|
||||
id: user.id,
|
||||
organization: user.organization || { id: '', type: 'SELLER' },
|
||||
}
|
||||
: null,
|
||||
admin: null,
|
||||
prisma,
|
||||
req: {
|
||||
ip: req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip'),
|
||||
get: (header: string) => req.headers.get(header),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { user: null, admin: null, prisma }
|
||||
return {
|
||||
user: null,
|
||||
currentUser: null,
|
||||
admin: null,
|
||||
prisma,
|
||||
req: {
|
||||
ip: req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip'),
|
||||
get: (header: string) => req.headers.get(header),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('GraphQL Context - Invalid token:', error)
|
||||
return { user: null, admin: null, prisma }
|
||||
return {
|
||||
user: null,
|
||||
currentUser: null,
|
||||
admin: null,
|
||||
prisma,
|
||||
req: {
|
||||
ip: req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip'),
|
||||
get: (header: string) => req.headers.get(header),
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
@ -1,18 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Clock, CheckCircle, Settings, Truck, Package, Calendar, Search } from 'lucide-react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Clock, CheckCircle, Settings, Truck, Package } from 'lucide-react'
|
||||
import { useState, useMemo, useCallback, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { MultiLevelSuppliesTable } from '@/components/supplies/multilevel-supplies-table'
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { SUPPLIER_APPROVE_ORDER, SUPPLIER_REJECT_ORDER, SUPPLIER_SHIP_ORDER } from '@/graphql/mutations'
|
||||
import { GET_SUPPLY_ORDERS, GET_MY_SUPPLY_ORDERS } from '@/graphql/queries'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { SUPPLIER_APPROVE_ORDER, SUPPLIER_REJECT_ORDER, SUPPLIER_SHIP_ORDER, UPDATE_SUPPLY_PARAMETERS } from '@/graphql/mutations'
|
||||
import { GET_MY_SUPPLY_ORDERS } from '@/graphql/queries'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
|
||||
@ -130,7 +127,7 @@ export function SupplierOrdersTabs() {
|
||||
})
|
||||
|
||||
// Мутации для действий поставщика
|
||||
const [supplierApproveOrder, { loading: approving }] = useMutation(SUPPLIER_APPROVE_ORDER, {
|
||||
const [supplierApproveOrder] = useMutation(SUPPLIER_APPROVE_ORDER, {
|
||||
refetchQueries: [{ query: GET_MY_SUPPLY_ORDERS }],
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierApproveOrder.success) {
|
||||
@ -145,7 +142,7 @@ export function SupplierOrdersTabs() {
|
||||
},
|
||||
})
|
||||
|
||||
const [supplierRejectOrder, { loading: rejecting }] = useMutation(SUPPLIER_REJECT_ORDER, {
|
||||
const [supplierRejectOrder] = useMutation(SUPPLIER_REJECT_ORDER, {
|
||||
refetchQueries: [{ query: GET_MY_SUPPLY_ORDERS }],
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierRejectOrder.success) {
|
||||
@ -160,7 +157,7 @@ export function SupplierOrdersTabs() {
|
||||
},
|
||||
})
|
||||
|
||||
const [supplierShipOrder, { loading: shipping }] = useMutation(SUPPLIER_SHIP_ORDER, {
|
||||
const [supplierShipOrder] = useMutation(SUPPLIER_SHIP_ORDER, {
|
||||
refetchQueries: [{ query: GET_MY_SUPPLY_ORDERS }],
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierShipOrder.success) {
|
||||
@ -175,6 +172,91 @@ export function SupplierOrdersTabs() {
|
||||
},
|
||||
})
|
||||
|
||||
// Мутация для обновления параметров поставки (объём и грузовые места)
|
||||
const [updateSupplyParameters] = useMutation(UPDATE_SUPPLY_PARAMETERS, {
|
||||
update: (cache, { data }) => {
|
||||
if (data?.updateSupplyParameters.success) {
|
||||
// Обновляем кеш Apollo напрямую
|
||||
const existingData = cache.readQuery({ query: GET_MY_SUPPLY_ORDERS })
|
||||
if (existingData?.mySupplyOrders) {
|
||||
const updatedOrders = existingData.mySupplyOrders.map((order: any) =>
|
||||
order.id === data.updateSupplyParameters.order.id
|
||||
? { ...order, ...data.updateSupplyParameters.order }
|
||||
: order,
|
||||
)
|
||||
cache.writeQuery({
|
||||
query: GET_MY_SUPPLY_ORDERS,
|
||||
data: { mySupplyOrders: updatedOrders },
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
if (data.updateSupplyParameters.success) {
|
||||
// Parameters updated successfully
|
||||
// Сбрасываем pending состояние для обновленных полей
|
||||
const updatedOrder = data.updateSupplyParameters.order
|
||||
if ((window as any).__handleUpdateComplete) {
|
||||
if (updatedOrder.volume !== null) {
|
||||
(window as any).__handleUpdateComplete(updatedOrder.id, 'volume')
|
||||
}
|
||||
if (updatedOrder.packagesCount !== null) {
|
||||
(window as any).__handleUpdateComplete(updatedOrder.id, 'packages')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.error(data.updateSupplyParameters.message)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error updating supply parameters:', error)
|
||||
toast.error('Ошибка при обновлении параметров поставки')
|
||||
},
|
||||
})
|
||||
|
||||
// Debounced обработчики для инпутов с задержкой
|
||||
const debounceTimeouts = useRef<{ [key: string]: NodeJS.Timeout }>({})
|
||||
|
||||
const handleVolumeChange = useCallback((supplyId: string, volume: number | null) => {
|
||||
// Handle volume change with debounce
|
||||
|
||||
// Очистить предыдущий таймер для данной поставки
|
||||
if (debounceTimeouts.current[`volume-${supplyId}`]) {
|
||||
clearTimeout(debounceTimeouts.current[`volume-${supplyId}`])
|
||||
}
|
||||
|
||||
// Установить новый таймер с задержкой 500ms
|
||||
debounceTimeouts.current[`volume-${supplyId}`] = setTimeout(() => {
|
||||
// Sending volume update
|
||||
updateSupplyParameters({
|
||||
variables: {
|
||||
id: supplyId,
|
||||
volume: volume,
|
||||
},
|
||||
})
|
||||
}, 500)
|
||||
}, [updateSupplyParameters])
|
||||
|
||||
const handlePackagesChange = useCallback((supplyId: string, packagesCount: number | null) => {
|
||||
// Handle packages change with debounce
|
||||
|
||||
// Очистить предыдущий таймер для данной поставки
|
||||
if (debounceTimeouts.current[`packages-${supplyId}`]) {
|
||||
clearTimeout(debounceTimeouts.current[`packages-${supplyId}`])
|
||||
}
|
||||
|
||||
// Установить новый таймер с задержкой 500ms
|
||||
debounceTimeouts.current[`packages-${supplyId}`] = setTimeout(() => {
|
||||
// Sending packages update
|
||||
updateSupplyParameters({
|
||||
variables: {
|
||||
id: supplyId,
|
||||
packagesCount: packagesCount,
|
||||
},
|
||||
})
|
||||
}, 500)
|
||||
}, [updateSupplyParameters])
|
||||
|
||||
// Получаем заказы поставок с многоуровневой структурой
|
||||
const supplierOrders: SupplyOrder[] = useMemo(() => {
|
||||
return data?.mySupplyOrders || []
|
||||
@ -247,11 +329,11 @@ export function SupplierOrdersTabs() {
|
||||
await supplierShipOrder({ variables: { id: supplyId } })
|
||||
break
|
||||
case 'cancel':
|
||||
console.log('Отмена поставки:', supplyId)
|
||||
// Cancel supply order
|
||||
// TODO: Реализовать отмену поставки если нужно
|
||||
break
|
||||
default:
|
||||
console.log('Неизвестное действие:', action, supplyId)
|
||||
console.error('Неизвестное действие:', action, supplyId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при выполнении действия:', error)
|
||||
@ -389,7 +471,10 @@ export function SupplierOrdersTabs() {
|
||||
<MultiLevelSuppliesTable
|
||||
supplies={getCurrentOrders()}
|
||||
userRole="WHOLESALE"
|
||||
activeTab={activeTab}
|
||||
onSupplyAction={handleSupplierAction}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onPackagesChange={handlePackagesChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Package, Building2, MapPin, Truck, Clock, Calendar, Settings } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@ -121,7 +121,11 @@ interface MultiLevelSuppliesTableProps {
|
||||
supplies?: SupplyOrderFromGraphQL[]
|
||||
loading?: boolean
|
||||
userRole?: 'SELLER' | 'WHOLESALE' | 'FULFILLMENT' | 'LOGIST'
|
||||
activeTab?: string
|
||||
onSupplyAction?: (supplyId: string, action: string) => void
|
||||
onVolumeChange?: (supplyId: string, volume: number | null) => void
|
||||
onPackagesChange?: (supplyId: string, packagesCount: number | null) => void
|
||||
onUpdateComplete?: (supplyId: string, field: 'volume' | 'packages') => void
|
||||
}
|
||||
|
||||
// Простые компоненты таблицы
|
||||
@ -177,6 +181,42 @@ const TableCell = ({
|
||||
</td>
|
||||
)
|
||||
|
||||
// ActionButtons компонент для кнопок действий поставщика
|
||||
function ActionButtons({
|
||||
supplyId,
|
||||
onSupplyAction
|
||||
}: {
|
||||
supplyId: string
|
||||
onSupplyAction?: (supplyId: string, action: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="glass-button bg-gradient-to-r from-green-500 to-emerald-500 text-white text-xs font-medium h-8 px-3 hover:scale-105 transition-all duration-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSupplyAction?.(supplyId, 'approve')
|
||||
}}
|
||||
>
|
||||
Одобрить
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-gradient-to-r from-red-600 to-red-500 hover:from-red-700 hover:to-red-600 text-white text-xs font-medium h-8 px-3 hover:scale-105 transition-all duration-200 backdrop-blur border border-red-500/30"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSupplyAction?.(supplyId, 'reject')
|
||||
}}
|
||||
>
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Компонент для статуса поставки
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const getStatusColor = (status: string) => {
|
||||
@ -316,12 +356,68 @@ export function MultiLevelSuppliesTable({
|
||||
supplies = [],
|
||||
loading: _loading = false,
|
||||
userRole = 'SELLER',
|
||||
activeTab,
|
||||
onSupplyAction,
|
||||
onVolumeChange,
|
||||
onPackagesChange,
|
||||
onUpdateComplete,
|
||||
}: MultiLevelSuppliesTableProps) {
|
||||
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(new Set())
|
||||
const [expandedRoutes, setExpandedRoutes] = useState<Set<string>>(new Set())
|
||||
const [expandedSuppliers, setExpandedSuppliers] = useState<Set<string>>(new Set())
|
||||
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set())
|
||||
|
||||
// Локальное состояние для инпутов
|
||||
const [inputValues, setInputValues] = useState<{[key: string]: {volume: string, packages: string}}>({})
|
||||
// Отслеживание, какие инпуты редактируются (пока не придет ответ от сервера)
|
||||
const [pendingUpdates, setPendingUpdates] = useState<Set<string>>(new Set())
|
||||
|
||||
// Синхронизация локального состояния с данными поставок
|
||||
useEffect(() => {
|
||||
setInputValues(prev => {
|
||||
const newValues: {[key: string]: {volume: string, packages: string}} = {}
|
||||
supplies.forEach(supply => {
|
||||
// Не перезаписываем значения для инпутов с ожидающими обновлениями
|
||||
const isVolumePending = pendingUpdates.has(`${supply.id}-volume`)
|
||||
const isPackagesPending = pendingUpdates.has(`${supply.id}-packages`)
|
||||
|
||||
newValues[supply.id] = {
|
||||
volume: isVolumePending ? (prev[supply.id]?.volume ?? '') : (supply.volume?.toString() ?? ''),
|
||||
packages: isPackagesPending ? (prev[supply.id]?.packages ?? '') : (supply.packagesCount?.toString() ?? '')
|
||||
}
|
||||
})
|
||||
|
||||
// Проверяем, нужно ли обновление
|
||||
const hasChanges = supplies.some(supply => {
|
||||
const isVolumePending = pendingUpdates.has(`${supply.id}-volume`)
|
||||
const isPackagesPending = pendingUpdates.has(`${supply.id}-packages`)
|
||||
|
||||
if (isVolumePending || isPackagesPending) return false
|
||||
|
||||
const volumeChanged = prev[supply.id]?.volume !== (supply.volume?.toString() ?? '')
|
||||
const packagesChanged = prev[supply.id]?.packages !== (supply.packagesCount?.toString() ?? '')
|
||||
|
||||
return volumeChanged || packagesChanged
|
||||
})
|
||||
|
||||
return hasChanges ? newValues : prev
|
||||
})
|
||||
}, [supplies, pendingUpdates])
|
||||
|
||||
// Обработчик завершения обновления для сброса pending состояния
|
||||
useEffect(() => {
|
||||
if (onUpdateComplete) {
|
||||
const handleUpdateComplete = (supplyId: string, field: 'volume' | 'packages') => {
|
||||
setPendingUpdates(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(`${supplyId}-${field}`)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
// Эта функция будет вызываться из родительского компонента
|
||||
(window as any).__handleUpdateComplete = handleUpdateComplete
|
||||
}
|
||||
}, [onUpdateComplete])
|
||||
|
||||
// Состояния для контекстного меню
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
@ -335,25 +431,6 @@ export function MultiLevelSuppliesTable({
|
||||
})
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false)
|
||||
|
||||
// Диагностика данных услуг ФФ (только в dev режиме)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(
|
||||
'🔍 ДИАГНОСТИКА: Данные поставок и рецептур:',
|
||||
supplies.map((supply) => ({
|
||||
id: supply.id,
|
||||
itemsCount: supply.items?.length || 0,
|
||||
items: supply.items?.slice(0, 2).map((item) => ({
|
||||
id: item.id,
|
||||
productName: item.product?.name,
|
||||
hasRecipe: !!item.recipe,
|
||||
recipe: item.recipe,
|
||||
services: item.services,
|
||||
fulfillmentConsumables: item.fulfillmentConsumables,
|
||||
sellerConsumables: item.sellerConsumables,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
// Массив цветов для различения поставок (с лучшим контрастом)
|
||||
const supplyColors = [
|
||||
@ -569,8 +646,10 @@ export function MultiLevelSuppliesTable({
|
||||
const routes = supply.routes || []
|
||||
|
||||
const orderedTotal = items.reduce((sum, item) => sum + (item.quantity || 0), 0)
|
||||
/* ОТКАТ: Удаление колонок "Поставлено" и "Брак" */
|
||||
const deliveredTotal = 0 // Пока нет данных о поставленном количестве
|
||||
const defectTotal = 0 // Пока нет данных о браке
|
||||
/* /ОТКАТ */
|
||||
|
||||
const goodsPrice = items.reduce((sum, item) => sum + (item.totalPrice || 0), 0)
|
||||
|
||||
@ -617,8 +696,10 @@ export function MultiLevelSuppliesTable({
|
||||
|
||||
return {
|
||||
orderedTotal,
|
||||
/* ОТКАТ: Удаление колонок "Поставлено" и "Брак" */
|
||||
deliveredTotal,
|
||||
defectTotal,
|
||||
/* /ОТКАТ */
|
||||
goodsPrice,
|
||||
servicesPrice,
|
||||
ffConsumablesPrice,
|
||||
@ -648,9 +729,17 @@ export function MultiLevelSuppliesTable({
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">№</TableHead>
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Дата поставки</TableHead>
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Заказано</TableHead>
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Поставлено</TableHead>
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Брак</TableHead>
|
||||
{/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE не видит колонки "Поставлено" и "Брак" */}
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<>
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Поставлено</TableHead>
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Брак</TableHead>
|
||||
</>
|
||||
)}
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Цена товаров</TableHead>
|
||||
{/* 🆕 НОВЫЕ КОЛОНКИ: Объём и грузовые места между "Цена товаров" и "Статус" */}
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Объём</TableHead>
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Грузовые места</TableHead>
|
||||
{/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE не видит услуги ФФ, расходники и логистику */}
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap hidden lg:table-cell">
|
||||
@ -672,9 +761,11 @@ export function MultiLevelSuppliesTable({
|
||||
Логистика до ФФ
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">
|
||||
{userRole === 'WHOLESALE' ? 'Мои товары' : 'Итого'}
|
||||
</TableHead>
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">
|
||||
Итого
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-white/90 text-sm font-light whitespace-nowrap">Статус</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@ -697,10 +788,6 @@ export function MultiLevelSuppliesTable({
|
||||
<TableRow
|
||||
className="border-white/10 hover:bg-white/5 cursor-pointer transition-colors"
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
backgroundColor: getLevelBackgroundColor(1, index),
|
||||
}}
|
||||
onClick={() => {
|
||||
@ -740,23 +827,90 @@ export function MultiLevelSuppliesTable({
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">{aggregatedData.orderedTotal}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">{aggregatedData.deliveredTotal}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`font-semibold text-sm ${
|
||||
(aggregatedData.defectTotal || 0) > 0 ? 'text-red-400' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
{aggregatedData.defectTotal}
|
||||
</span>
|
||||
</TableCell>
|
||||
{/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE не видит колонки "Поставлено" и "Брак" */}
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.deliveredTotal}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.defectTotal}</span>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell>
|
||||
<span className="text-green-400 font-semibold text-sm">
|
||||
{formatCurrency(aggregatedData.goodsPrice)}
|
||||
</span>
|
||||
</TableCell>
|
||||
{/* 🆕 НОВЫЕ ЯЧЕЙКИ: Объём и грузовые места с инпутами для WHOLESALE */}
|
||||
<TableCell>
|
||||
{userRole === 'WHOLESALE' && supply.status === 'PENDING' ? (
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.0 м³"
|
||||
value={inputValues[supply.id]?.volume ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
// Устанавливаем pending состояние
|
||||
setPendingUpdates(prev => new Set(prev).add(`${supply.id}-volume`))
|
||||
// Обновляем локальное состояние немедленно
|
||||
setInputValues(prev => ({
|
||||
...prev,
|
||||
[supply.id]: {
|
||||
...prev[supply.id],
|
||||
volume: value
|
||||
}
|
||||
}))
|
||||
// Вызываем обработчик с преобразованным значением
|
||||
const numValue = value === '' ? null : parseFloat(value)
|
||||
onVolumeChange?.(supply.id, numValue)
|
||||
}}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-20 bg-white/10 border border-white/20 rounded px-2 py-1 text-white text-sm placeholder:text-white/40 focus:outline-none focus:border-white/40"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/80 text-sm">
|
||||
{supply.volume ? `${supply.volume} м³` : '—'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{userRole === 'WHOLESALE' && supply.status === 'PENDING' ? (
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0 мест"
|
||||
value={inputValues[supply.id]?.packages ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
// Устанавливаем pending состояние
|
||||
setPendingUpdates(prev => new Set(prev).add(`${supply.id}-packages`))
|
||||
// Обновляем локальное состояние немедленно
|
||||
setInputValues(prev => ({
|
||||
...prev,
|
||||
[supply.id]: {
|
||||
...prev[supply.id],
|
||||
packages: value
|
||||
}
|
||||
}))
|
||||
// Вызываем обработчик с преобразованным значением
|
||||
const numValue = value === '' ? null : parseInt(value)
|
||||
onPackagesChange?.(supply.id, numValue)
|
||||
}}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-20 bg-white/10 border border-white/20 rounded px-2 py-1 text-white text-sm placeholder:text-white/40 focus:outline-none focus:border-white/40"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/80 text-sm">
|
||||
{supply.packagesCount ? `${supply.packagesCount} мест` : '—'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
{/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE не видит услуги ФФ, расходники и логистику */}
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
@ -786,28 +940,31 @@ export function MultiLevelSuppliesTable({
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE видит только стоимость своих товаров */}
|
||||
<span className="text-white font-bold text-sm">
|
||||
{formatCurrency(userRole === 'WHOLESALE' ? aggregatedData.goodsPrice : aggregatedData.total)}
|
||||
</span>
|
||||
|
||||
{/* ОТКАТ: Со значком доллара
|
||||
<div className="flex items-center space-x-1">
|
||||
<DollarSign className="h-3 w-3 text-white/40" />
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<TableCell>
|
||||
<span className="text-white font-bold text-sm">
|
||||
{formatCurrency(aggregatedData.total)}
|
||||
</span>
|
||||
</div>
|
||||
*/}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{/* 🔒 УСЛОВНЫЙ РЕНДЕРИНГ: Кнопки для WHOLESALE во вкладке "Новые" */}
|
||||
{userRole === 'WHOLESALE' && activeTab === 'new' && supply.status === 'PENDING' ? (
|
||||
<ActionButtons
|
||||
supplyId={supply.id}
|
||||
onSupplyAction={onSupplyAction}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={supply.status} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{userRole !== 'WHOLESALE' && <StatusBadge status={supply.status} />}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* ВАРИАНТ 1: Строка с ID поставки между уровнями */}
|
||||
{isSupplyExpanded && (
|
||||
<TableRow className="border-0 bg-white/5">
|
||||
<TableCell colSpan={userRole === 'WHOLESALE' ? 8 : 12} className="py-2 px-4 relative">
|
||||
{/* 🔒 БЕЗОПАСНОСТЬ: colSpan учитывает скрытые колонки для WHOLESALE + новые колонки объём/грузовые места */}
|
||||
<TableCell colSpan={userRole === 'WHOLESALE' ? 7 : 9} className="py-2 px-4 relative">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-white/60 text-xs">ID поставки:</span>
|
||||
<span className="text-white/80 text-xs font-mono">{supply.id.slice(-8).toUpperCase()}</span>
|
||||
@ -824,8 +981,8 @@ export function MultiLevelSuppliesTable({
|
||||
{/* Строка с ID убрана */}
|
||||
{/* */}
|
||||
|
||||
{/* УРОВЕНЬ 2: Маршруты поставки */}
|
||||
{isSupplyExpanded &&
|
||||
{/* УРОВЕНЬ 2: Маршруты поставки - скрыто для WHOLESALE */}
|
||||
{isSupplyExpanded && userRole !== 'WHOLESALE' &&
|
||||
(() => {
|
||||
// ✅ ВРЕМЕННАЯ ЗАГЛУШКА: создаем фиктивный маршрут для демонстрации
|
||||
const mockRoutes =
|
||||
@ -880,12 +1037,17 @@ export function MultiLevelSuppliesTable({
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.orderedTotal}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.deliveredTotal}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.defectTotal}</span>
|
||||
</TableCell>
|
||||
{/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE не видит колонки "Поставлено" и "Брак" на route level */}
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.deliveredTotal}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.defectTotal}</span>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell>
|
||||
<span className="text-green-400 font-medium text-sm">
|
||||
{formatCurrency(aggregatedData.goodsPrice)}
|
||||
@ -974,25 +1136,24 @@ export function MultiLevelSuppliesTable({
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.orderedTotal}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.deliveredTotal}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.defectTotal}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-green-400 font-medium text-sm">
|
||||
{formatCurrency(aggregatedData.goodsPrice)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell colSpan={userRole === 'WHOLESALE' ? 0 : 4} className="text-right pr-8">
|
||||
{/* Агрегированные данные поставщика отображаются только в итого */}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">
|
||||
{formatCurrency(aggregatedData.total)}
|
||||
</span>
|
||||
</TableCell>
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<>
|
||||
{/* ОТКАТ: colSpan должен быть 4 вместо 2 */}
|
||||
<TableCell colSpan={4} className="text-right pr-8">
|
||||
{/* Агрегированные данные поставщика отображаются только в итого */}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">
|
||||
{formatCurrency(aggregatedData.total)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
@ -1362,7 +1523,7 @@ export function MultiLevelSuppliesTable({
|
||||
<TableCell className="text-white/70 font-mono">{size.quantity}</TableCell>
|
||||
<TableCell
|
||||
className="text-white/60 font-mono"
|
||||
colSpan={userRole === 'WHOLESALE' ? 3 : 7}
|
||||
colSpan={userRole === 'WHOLESALE' ? 2 : 7}
|
||||
>
|
||||
{size.price ? formatCurrency(size.price) : '-'}
|
||||
</TableCell>
|
||||
@ -1377,10 +1538,132 @@ export function MultiLevelSuppliesTable({
|
||||
})
|
||||
})()}
|
||||
|
||||
{/* УРОВЕНЬ 3: Поставщик для WHOLESALE (без маршрутов) - СКРЫТ */}
|
||||
{false && isSupplyExpanded && userRole === 'WHOLESALE' && (
|
||||
<TableRow
|
||||
className="border-white/10 hover:bg-white/5 cursor-pointer transition-colors"
|
||||
style={{ backgroundColor: getLevelBackgroundColor(3, index) }}
|
||||
onClick={() => toggleSupplierExpansion(supply.partner.id)}
|
||||
>
|
||||
<TableCell className="relative">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1 h-1 rounded-full bg-green-400 mr-1"></div>
|
||||
<Building2 className="h-3 w-3 text-green-400" />
|
||||
<span className="text-white font-medium text-sm">Поставщик</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-0.5 h-full"
|
||||
style={{ backgroundColor: getSupplyColor(index) }}
|
||||
></div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white text-sm font-medium">
|
||||
{supply.partner.name || supply.partner.fullName}
|
||||
</span>
|
||||
{supply.partner.users &&
|
||||
supply.partner.users.length > 0 &&
|
||||
supply.partner.users[0].managerName && (
|
||||
<span className="text-white/60 text-xs">
|
||||
{supply.partner.users[0].managerName}
|
||||
</span>
|
||||
)}
|
||||
{supply.partner.phones &&
|
||||
Array.isArray(supply.partner.phones) &&
|
||||
supply.partner.phones.length > 0 && (
|
||||
<span className="text-white/60 text-[10px]">
|
||||
{typeof supply.partner.phones[0] === 'string'
|
||||
? supply.partner.phones[0]
|
||||
: supply.partner.phones[0]?.value || supply.partner.phones[0]?.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.orderedTotal}</span>
|
||||
</TableCell>
|
||||
{/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE не видит колонки "Поставлено" и "Брак" на supplier level */}
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.deliveredTotal}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">{aggregatedData.defectTotal}</span>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell>
|
||||
<span className="text-green-400 font-medium text-sm">
|
||||
{formatCurrency(aggregatedData.goodsPrice)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{/* УРОВЕНЬ 4: Товары для WHOLESALE (без маршрутов) */}
|
||||
{isSupplyExpanded &&
|
||||
userRole === 'WHOLESALE' &&
|
||||
(supply.items || []).map((item) => {
|
||||
const isProductExpanded = expandedProducts.has(item.id)
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
<TableRow
|
||||
className="border-white/10 hover:bg-white/5 cursor-pointer transition-colors"
|
||||
style={{ backgroundColor: getLevelBackgroundColor(4, index) }}
|
||||
onClick={() => toggleProductExpansion(item.id)}
|
||||
>
|
||||
<TableCell className="relative">
|
||||
<div
|
||||
className="absolute left-0 top-0 w-0.5 h-full"
|
||||
style={{ backgroundColor: getSupplyColor(index) }}
|
||||
></div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white text-sm font-medium">{item.product.name}</span>
|
||||
<span className="text-white/60 text-[9px]">
|
||||
Арт: {item.product.article || 'SF-T-925635-494'}
|
||||
{item.product.category && ` · ${item.product.category.name}`}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">{item.quantity}</span>
|
||||
</TableCell>
|
||||
{/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE не видит колонки "Поставлено" и "Брак" на product level */}
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">0</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">0</span>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell>
|
||||
<div className="text-white">
|
||||
<div className="font-medium text-sm">{formatCurrency(item.totalPrice)}</div>
|
||||
<div className="text-xs text-white/60 hidden sm:block">
|
||||
{formatCurrency(item.price)} за шт.
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell> </TableCell>
|
||||
<TableCell> </TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ВАРИАНТ 1: Разделитель в виде пустой строки с border */}
|
||||
<tr>
|
||||
{/* 🔒 БЕЗОПАСНОСТЬ: colSpan учитывает скрытые колонки для WHOLESALE + новые колонки объём/грузовые места */}
|
||||
<td
|
||||
colSpan={userRole === 'WHOLESALE' ? 8 : 12}
|
||||
colSpan={userRole === 'WHOLESALE' ? 7 : 9}
|
||||
style={{ padding: 0, borderBottom: '1px solid rgba(255, 255, 255, 0.2)' }}
|
||||
></td>
|
||||
</tr>
|
||||
|
@ -826,6 +826,21 @@ export const ASSIGN_LOGISTICS_TO_SUPPLY = gql`
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_SUPPLY_PARAMETERS = gql`
|
||||
mutation UpdateSupplyParameters($id: ID!, $volume: Float, $packagesCount: Int) {
|
||||
updateSupplyParameters(id: $id, volume: $volume, packagesCount: $packagesCount) {
|
||||
success
|
||||
message
|
||||
order {
|
||||
id
|
||||
volume
|
||||
packagesCount
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Мутации для логистики
|
||||
export const CREATE_LOGISTICS = gql`
|
||||
mutation CreateLogistics($input: LogisticsInput!) {
|
||||
|
@ -1340,9 +1340,9 @@ export const GET_MY_SUPPLY_ORDERS = gql`
|
||||
totalItems
|
||||
fulfillmentCenterId
|
||||
logisticsPartnerId
|
||||
# packagesCount # Поле не существует в SupplyOrder модели
|
||||
# volume # Поле не существует в SupplyOrder модели
|
||||
# responsibleEmployee # Возможно, это поле тоже не существует
|
||||
packagesCount
|
||||
volume
|
||||
responsibleEmployee
|
||||
notes
|
||||
createdAt
|
||||
updatedAt
|
||||
|
@ -15,6 +15,18 @@ import '@/lib/seed-init' // Автоматическая инициализац
|
||||
// 🔒 СИСТЕМА БЕЗОПАСНОСТИ - импорты
|
||||
import { CommercialDataAudit } from './security/commercial-data-audit'
|
||||
import { createSecurityContext } from './security/index'
|
||||
|
||||
// 🔒 HELPER: Создание безопасного контекста с организационными данными
|
||||
function createSecureContextWithOrgData(context: Context, currentUser: any) {
|
||||
return {
|
||||
...context,
|
||||
user: {
|
||||
...context.user,
|
||||
organizationType: currentUser.organization.type,
|
||||
organizationId: currentUser.organization.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
import { ParticipantIsolation } from './security/participant-isolation'
|
||||
import { SupplyDataFilter } from './security/supply-data-filter'
|
||||
import type { SecurityContext } from './security/types'
|
||||
@ -2735,15 +2747,17 @@ export const resolvers = {
|
||||
: []
|
||||
|
||||
// 🔒 ФИЛЬТРАЦИЯ РЕЦЕПТУРЫ ПО РОЛИ
|
||||
recipe = SupplyDataFilter.filterRecipeByRole(
|
||||
{
|
||||
// Для WHOLESALE скрываем рецептуру полностью
|
||||
if (currentUser.organization.type === 'WHOLESALE') {
|
||||
recipe = null
|
||||
} else {
|
||||
recipe = {
|
||||
services,
|
||||
fulfillmentConsumables,
|
||||
sellerConsumables,
|
||||
marketplaceCardId: item.marketplaceCardId,
|
||||
},
|
||||
securityContext,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@ -7269,6 +7283,67 @@ export const resolvers = {
|
||||
}
|
||||
},
|
||||
|
||||
// Обновление параметров поставки (объём и грузовые места)
|
||||
updateSupplyParameters: async (
|
||||
_: unknown,
|
||||
args: { id: string; volume?: number; packagesCount?: number },
|
||||
context: GraphQLContext
|
||||
) => {
|
||||
try {
|
||||
// Проверка аутентификации
|
||||
if (!context.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Необходима аутентификация',
|
||||
}
|
||||
}
|
||||
|
||||
// Найти поставку и проверить права доступа
|
||||
const supply = await prisma.supplyOrder.findUnique({
|
||||
where: { id: args.id },
|
||||
include: { partner: true }
|
||||
})
|
||||
|
||||
if (!supply) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Поставка не найдена',
|
||||
}
|
||||
}
|
||||
|
||||
// Проверить, что пользователь - поставщик этой заявки
|
||||
if (supply.partnerId !== context.user.organization?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Недостаточно прав для изменения данной поставки',
|
||||
}
|
||||
}
|
||||
|
||||
// Подготовить данные для обновления
|
||||
const updateData: { volume?: number; packagesCount?: number } = {}
|
||||
if (args.volume !== undefined) updateData.volume = args.volume
|
||||
if (args.packagesCount !== undefined) updateData.packagesCount = args.packagesCount
|
||||
|
||||
// Обновить поставку
|
||||
const updatedSupply = await prisma.supplyOrder.update({
|
||||
where: { id: args.id },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Параметры поставки обновлены',
|
||||
order: updatedSupply,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении параметров поставки:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Ошибка при обновлении параметров поставки',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Назначение логистики фулфилментом на заказ селлера
|
||||
assignLogisticsToSupply: async (
|
||||
_: unknown,
|
||||
@ -7543,22 +7618,34 @@ export const resolvers = {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
recipe: {
|
||||
include: {
|
||||
services: true,
|
||||
fulfillmentConsumables: true,
|
||||
sellerConsumables: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.warn(`[DEBUG] updatedOrder structure:`, {
|
||||
id: updatedOrder.id,
|
||||
itemsCount: updatedOrder.items?.length || 0,
|
||||
firstItem: updatedOrder.items?.[0] ? {
|
||||
productId: updatedOrder.items[0].productId,
|
||||
hasProduct: !!updatedOrder.items[0].product,
|
||||
productOrgId: updatedOrder.items[0].product?.organizationId,
|
||||
hasProductOrg: !!updatedOrder.items[0].product?.organization,
|
||||
} : null,
|
||||
currentUserOrgId: currentUser.organization.id,
|
||||
})
|
||||
|
||||
// 🔒 ФИЛЬТРАЦИЯ ДАННЫХ ДЛЯ ПОСТАВЩИКА
|
||||
const filteredOrder = SupplyDataFilter.filterSupplyOrder(updatedOrder, securityContext)
|
||||
const securityContextWithOrgType = createSecureContextWithOrgData(context, currentUser)
|
||||
const filteredOrder = SupplyDataFilter.filterSupplyOrder(updatedOrder, securityContextWithOrgType)
|
||||
|
||||
console.warn(`[DEBUG] Заказ ${args.id} успешно обновлен до статуса: ${updatedOrder.status}`)
|
||||
console.warn(`[DEBUG] filteredOrder:`, {
|
||||
hasData: !!filteredOrder.data,
|
||||
dataId: filteredOrder.data?.id,
|
||||
dataKeys: Object.keys(filteredOrder.data || {}),
|
||||
})
|
||||
|
||||
try {
|
||||
const orgIds = [
|
||||
updatedOrder.organizationId,
|
||||
@ -7572,10 +7659,16 @@ export const resolvers = {
|
||||
})
|
||||
} catch {}
|
||||
|
||||
// Проверка на случай, если фильтрованные данные null
|
||||
if (!filteredOrder.data || !filteredOrder.data.id) {
|
||||
console.error('[ERROR] filteredOrder.data is null or missing id:', filteredOrder)
|
||||
throw new GraphQLError('Filtered order data is invalid')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Заказ поставки одобрен поставщиком. Товары зарезервированы, остатки обновлены.',
|
||||
order: filteredOrder, // 🔒 Возвращаем отфильтрованные данные
|
||||
order: filteredOrder.data, // 🔒 Возвращаем отфильтрованные данные (только data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error approving supply order:', error)
|
||||
@ -7689,20 +7782,14 @@ export const resolvers = {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
recipe: {
|
||||
include: {
|
||||
services: true,
|
||||
fulfillmentConsumables: true,
|
||||
sellerConsumables: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 🔒 ФИЛЬТРАЦИЯ ДАННЫХ ДЛЯ ПОСТАВЩИКА
|
||||
const filteredOrder = SupplyDataFilter.filterSupplyOrder(updatedOrder, securityContext)
|
||||
const securityContextWithOrgType = createSecureContextWithOrgData(context, currentUser)
|
||||
const filteredOrder = SupplyDataFilter.filterSupplyOrder(updatedOrder, securityContextWithOrgType)
|
||||
|
||||
// 📦 СНИМАЕМ РЕЗЕРВАЦИЮ ПРИ ОТКЛОНЕНИИ
|
||||
// Восстанавливаем остатки и убираем резервацию для каждого отклоненного товара
|
||||
@ -7756,7 +7843,7 @@ export const resolvers = {
|
||||
return {
|
||||
success: true,
|
||||
message: args.reason ? `Заказ отклонен поставщиком. Причина: ${args.reason}` : 'Заказ отклонен поставщиком',
|
||||
order: filteredOrder, // 🔒 Возвращаем отфильтрованные данные
|
||||
order: filteredOrder.data, // 🔒 Возвращаем отфильтрованные данные (только data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rejecting supply order:', error)
|
||||
@ -7914,7 +8001,8 @@ export const resolvers = {
|
||||
})
|
||||
|
||||
// 🔒 ФИЛЬТРАЦИЯ ДАННЫХ ДЛЯ ПОСТАВЩИКА
|
||||
const filteredOrder = SupplyDataFilter.filterSupplyOrder(updatedOrder, securityContext)
|
||||
const securityContextWithOrgType = createSecureContextWithOrgData(context, currentUser)
|
||||
const filteredOrder = SupplyDataFilter.filterSupplyOrder(updatedOrder, securityContextWithOrgType)
|
||||
|
||||
try {
|
||||
const orgIds = [
|
||||
@ -7932,7 +8020,7 @@ export const resolvers = {
|
||||
return {
|
||||
success: true,
|
||||
message: "Заказ отправлен поставщиком. Товары переведены в статус 'в пути'.",
|
||||
order: filteredOrder, // 🔒 Возвращаем отфильтрованные данные
|
||||
order: filteredOrder.data, // 🔒 Возвращаем отфильтрованные данные (только data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error shipping supply order:', error)
|
||||
|
@ -61,8 +61,33 @@ export const secureSupplyOrderResolver = {
|
||||
|
||||
console.warn('🔒 SECURITY ENABLED: Applying data filtering and audit')
|
||||
|
||||
// Создаем контекст безопасности
|
||||
const securityContext = createSecurityContext(context)
|
||||
// Проверяем наличие пользователя
|
||||
if (!context.user) {
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
// Получаем данные пользователя с организацией
|
||||
const { PrismaClient } = await import('@prisma/client')
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
})
|
||||
|
||||
if (!currentUser?.organization) {
|
||||
throw new Error('User organization not found')
|
||||
}
|
||||
|
||||
// Создаем контекст безопасности с правильными данными
|
||||
const securityContext = createSecurityContext({
|
||||
user: {
|
||||
id: currentUser.id,
|
||||
organizationId: currentUser.organization.id,
|
||||
organizationType: currentUser.organization.type,
|
||||
},
|
||||
req: context.req,
|
||||
})
|
||||
|
||||
// Пример фильтрации данных
|
||||
const mockOrder = {
|
||||
|
@ -93,7 +93,7 @@ const secureSupplyOrdersResolver = createSecureResolver(
|
||||
**Видит:**
|
||||
- ✅ Заказы, где есть его товары
|
||||
- ✅ Свои цены на товары
|
||||
- ✅ Упаковочную информацию (для логистики)
|
||||
- ✅ Параметры поставки (для логистики)
|
||||
|
||||
**Не видит:**
|
||||
- ❌ Рецептуры товаров (коммерческая тайна)
|
||||
@ -117,7 +117,7 @@ const secureSupplyOrdersResolver = createSecureResolver(
|
||||
**Видит:**
|
||||
- ✅ Заказы, где она назначена
|
||||
- ✅ Маршрутную информацию
|
||||
- ✅ Упаковочные данные (объем, количество мест)
|
||||
- ✅ Параметры поставки (объем, количество мест)
|
||||
- ✅ Свою стоимость доставки
|
||||
|
||||
**Не видит:**
|
||||
|
@ -268,7 +268,7 @@ export class LogistSecurityTests extends SecurityTestFramework {
|
||||
const hasAccess = filteredResult.accessLevel !== 'BLOCKED' &&
|
||||
filteredResult.data.id !== undefined
|
||||
|
||||
// LOGIST должен видеть упаковочную информацию для доставки
|
||||
// LOGIST должен видеть параметры поставки для доставки
|
||||
const canSeePackaging = filteredResult.data.packagesCount !== undefined &&
|
||||
filteredResult.data.weight !== undefined &&
|
||||
filteredResult.data.volume !== undefined
|
||||
|
@ -547,7 +547,7 @@ export class WholesaleSecurityTests extends SecurityTestFramework {
|
||||
}
|
||||
|
||||
/**
|
||||
* Тест: WHOLESALE видит упаковочную информацию для логистики
|
||||
* Тест: WHOLESALE видит параметры поставки для логистики
|
||||
*/
|
||||
private async testWholesalePackagingInfoAccess(): Promise<{
|
||||
passed: boolean
|
||||
@ -558,7 +558,7 @@ export class WholesaleSecurityTests extends SecurityTestFramework {
|
||||
const wholesaleUser = this.getTestUser(TestRole.WHOLESALE)
|
||||
const mockContext = this.createMockContext(wholesaleUser)
|
||||
|
||||
// Создаем тестовый заказ с упаковочной информацией
|
||||
// Создаем тестовый заказ с параметрами поставки
|
||||
const orderWithPackaging = {
|
||||
id: 'order-with-packaging',
|
||||
organizationId: 'seller-org-001',
|
||||
@ -579,7 +579,7 @@ export class WholesaleSecurityTests extends SecurityTestFramework {
|
||||
|
||||
const filteredResult = SupplyDataFilter.filterSupplyOrder(orderWithPackaging, mockContext)
|
||||
|
||||
// WHOLESALE должен видеть упаковочную информацию (нужно для логистики)
|
||||
// WHOLESALE должен видеть параметры поставки (нужно для логистики)
|
||||
const canSeePackaging = filteredResult.data.packagesCount !== undefined &&
|
||||
filteredResult.data.volume !== undefined &&
|
||||
filteredResult.data.routes !== undefined
|
||||
@ -595,8 +595,8 @@ export class WholesaleSecurityTests extends SecurityTestFramework {
|
||||
},
|
||||
vulnerability: !canSeePackaging ? {
|
||||
type: 'PACKAGING_INFO_MISSING',
|
||||
impact: 'WHOLESALE не может видеть упаковочную информацию, необходимую для логистики',
|
||||
recommendation: 'Разрешить WHOLESALE доступ к упаковочной информации',
|
||||
impact: 'WHOLESALE не может видеть параметры поставки, необходимые для логистики',
|
||||
recommendation: 'Разрешить WHOLESALE доступ к параметрам поставки',
|
||||
} : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -26,26 +26,29 @@ export type {
|
||||
SecurityFeatureFlags,
|
||||
} from './types'
|
||||
|
||||
// Утилиты и обертки
|
||||
export { createSecureResolver, SecurityHelpers } from './secure-resolver'
|
||||
// Функции создания контекста
|
||||
export { createSecurityContext as createSecurityContextFromTypes } from './types'
|
||||
|
||||
// Middleware для автоматической интеграции
|
||||
export {
|
||||
applySecurityMiddleware,
|
||||
wrapResolversWithSecurity,
|
||||
addSecurityConfig,
|
||||
getSecurityConfig,
|
||||
listSecuredResolvers,
|
||||
} from './middleware'
|
||||
// Утилиты и обертки - Временно отключено
|
||||
// export { createSecureResolver, SecurityHelpers } from './secure-resolver'
|
||||
|
||||
// Расширенные компоненты Phase 3
|
||||
export { AdvancedAuditReporting } from './advanced-audit-reporting'
|
||||
export { RealTimeSecurityAlerts } from './real-time-security-alerts'
|
||||
export { AutomatedThreatDetection } from './automated-threat-detection'
|
||||
export { ExternalMonitoringIntegration } from './external-monitoring-integration'
|
||||
// Middleware для автоматической интеграции - Временно отключено
|
||||
// export {
|
||||
// applySecurityMiddleware,
|
||||
// wrapResolversWithSecurity,
|
||||
// addSecurityConfig,
|
||||
// getSecurityConfig,
|
||||
// listSecuredResolvers,
|
||||
// } from './middleware'
|
||||
|
||||
// Security Dashboard GraphQL компоненты
|
||||
export { securityDashboardTypeDefs, securityDashboardResolvers } from './security-dashboard-graphql'
|
||||
// Расширенные компоненты Phase 3 - Временно отключены для устранения ошибок
|
||||
// export { AdvancedAuditReporting } from './advanced-audit-reporting'
|
||||
// export { RealTimeSecurityAlerts } from './real-time-security-alerts'
|
||||
// export { AutomatedThreatDetection } from './automated-threat-detection'
|
||||
// export { ExternalMonitoringIntegration } from './external-monitoring-integration'
|
||||
|
||||
// Security Dashboard GraphQL компоненты - Временно отключены
|
||||
// export { securityDashboardTypeDefs, securityDashboardResolvers } from './security-dashboard-graphql'
|
||||
|
||||
// Вспомогательные функции
|
||||
export { SecurityLogger } from '../../lib/security-logger'
|
||||
@ -77,6 +80,10 @@ export function isStrictModeEnabled(): boolean {
|
||||
*/
|
||||
export function createSecurityContext(context: any): SecurityContext {
|
||||
return {
|
||||
userId: context.user?.id || '',
|
||||
organizationId: context.user?.organizationId || '',
|
||||
organizationType: context.user?.organizationType || 'SELLER',
|
||||
userRole: context.user?.organizationType || 'SELLER',
|
||||
user: {
|
||||
id: context.user?.id || '',
|
||||
organizationId: context.user?.organizationId || '',
|
||||
@ -88,6 +95,11 @@ export function createSecurityContext(context: any): SecurityContext {
|
||||
headers: context.req?.headers || {},
|
||||
timestamp: new Date(),
|
||||
},
|
||||
requestMetadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
ipAddress: context.req?.ip || context.req?.socket?.remoteAddress,
|
||||
userAgent: context.req?.headers?.['user-agent'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -40,7 +40,7 @@ export class ParticipantIsolation {
|
||||
// Селлер может видеть только свои данные
|
||||
if (currentUserId !== targetSellerId) {
|
||||
// Логируем попытку несанкционированного доступа
|
||||
if (context && FEATURE_FLAGS.SUPPLY_DATA_SECURITY.auditEnabled) {
|
||||
if (context && context.user && FEATURE_FLAGS.SUPPLY_DATA_SECURITY.auditEnabled) {
|
||||
SecurityLogger.logAccessAttempt({
|
||||
userId: context.user.id,
|
||||
organizationType: context.user.organizationType,
|
||||
@ -96,7 +96,7 @@ export class ParticipantIsolation {
|
||||
|
||||
if (!partnership) {
|
||||
// Логируем попытку доступа без партнерства
|
||||
if (context && FEATURE_FLAGS.SUPPLY_DATA_SECURITY.auditEnabled) {
|
||||
if (context && context.user && FEATURE_FLAGS.SUPPLY_DATA_SECURITY.auditEnabled) {
|
||||
SecurityLogger.logAccessAttempt({
|
||||
userId: context.user.id,
|
||||
organizationType: context.user.organizationType,
|
||||
@ -118,7 +118,7 @@ export class ParticipantIsolation {
|
||||
}
|
||||
|
||||
// Логируем успешную проверку партнерства
|
||||
if (context && FEATURE_FLAGS.SUPPLY_DATA_SECURITY.auditEnabled) {
|
||||
if (context && context.user && FEATURE_FLAGS.SUPPLY_DATA_SECURITY.auditEnabled) {
|
||||
SecurityLogger.logAccessAttempt({
|
||||
userId: context.user.id,
|
||||
organizationType: context.user.organizationType,
|
||||
@ -132,7 +132,7 @@ export class ParticipantIsolation {
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
if (context) {
|
||||
if (context && context.user) {
|
||||
SecurityLogger.logSecurityError(error as Error, {
|
||||
operation: 'validatePartnerAccess',
|
||||
organizationId,
|
||||
@ -153,6 +153,9 @@ export class ParticipantIsolation {
|
||||
context: SecurityContext,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('User context required for access validation')
|
||||
}
|
||||
const { organizationType, organizationId } = context.user
|
||||
|
||||
// Получаем базовую информацию о заказе
|
||||
@ -314,6 +317,7 @@ export class ParticipantIsolation {
|
||||
action: string,
|
||||
timeWindowMs = 3600000, // 1 час
|
||||
threshold = 100,
|
||||
context?: SecurityContext,
|
||||
): Promise<boolean> {
|
||||
// TODO: Реализовать через Redis или память для подсчета запросов
|
||||
// Пока заглушка для демонстрации логики
|
||||
@ -323,7 +327,7 @@ export class ParticipantIsolation {
|
||||
if (requestCount > threshold) {
|
||||
SecurityLogger.logSuspiciousActivity({
|
||||
userId,
|
||||
organizationType: 'UNKNOWN', // TODO: получать из контекста
|
||||
organizationType: context?.user?.organizationType || context?.organizationType || 'SELLER',
|
||||
activity: action,
|
||||
count: requestCount,
|
||||
timeframe: `${timeWindowMs / 1000}s`,
|
||||
|
@ -33,13 +33,28 @@ interface SupplyOrder {
|
||||
// Производственные данные
|
||||
items: Array<{
|
||||
id: string
|
||||
productId: string // Обязательное поле GraphQL
|
||||
product: {
|
||||
id: string
|
||||
name: string
|
||||
article: string // Обязательное поле GraphQL
|
||||
price: number // Обязательное поле GraphQL
|
||||
quantity: number // Обязательное поле GraphQL
|
||||
images: string[] // Обязательное поле GraphQL
|
||||
isActive: boolean // Обязательное поле GraphQL
|
||||
createdAt: string // Обязательное поле GraphQL
|
||||
updatedAt: string // Обязательное поле GraphQL
|
||||
organization: { // Обязательное поле GraphQL
|
||||
id: string
|
||||
inn: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
organizationId: string
|
||||
}
|
||||
quantity: number
|
||||
price?: number | null
|
||||
totalPrice: number // Обязательное поле GraphQL
|
||||
recipe?: {
|
||||
services: Array<{ id: string; name: string; price?: number }>
|
||||
fulfillmentConsumables: Array<{
|
||||
@ -69,7 +84,7 @@ interface SupplyOrder {
|
||||
volume?: number
|
||||
}>
|
||||
|
||||
// Упаковочные данные (опциональные)
|
||||
// Параметры поставки (опциональные)
|
||||
packagesCount?: number | null
|
||||
volume?: number | null
|
||||
readyDate?: Date | null
|
||||
@ -78,8 +93,23 @@ interface SupplyOrder {
|
||||
|
||||
/**
|
||||
* Отфильтрованный заказ поставки
|
||||
* Сохраняем обязательные поля из GraphQL схемы
|
||||
*/
|
||||
type FilteredSupplyOrder = Partial<SupplyOrder>
|
||||
interface FilteredSupplyOrder extends Partial<SupplyOrder> {
|
||||
id: string // Обязательное поле
|
||||
organizationId: string // Обязательное поле
|
||||
partnerId: string // Обязательное поле
|
||||
partner: any // Обязательное поле - объект Organization
|
||||
organization: any // Обязательное поле - объект Organization
|
||||
deliveryDate: string | Date // Обязательное поле
|
||||
status: string // Обязательное поле
|
||||
totalAmount: number // Обязательное поле
|
||||
totalItems: number // Обязательное поле
|
||||
createdAt: string | Date // Обязательное поле
|
||||
updatedAt: string | Date // Обязательное поле
|
||||
routes: any[] // Обязательное поле (массив маршрутов)
|
||||
items: any[] // Обязательное поле (массив товаров)
|
||||
}
|
||||
|
||||
export class SupplyDataFilter {
|
||||
/**
|
||||
@ -89,6 +119,9 @@ export class SupplyDataFilter {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('User context required for supply order filtering')
|
||||
}
|
||||
const { organizationType, organizationId } = context.user
|
||||
|
||||
let filteredOrder: FilteredSupplyOrder
|
||||
@ -154,8 +187,8 @@ export class SupplyDataFilter {
|
||||
}
|
||||
} catch (error) {
|
||||
SecurityLogger.logSecurityError(error as Error, {
|
||||
userId: context.user.id,
|
||||
organizationType: context.user.organizationType,
|
||||
userId: context.user?.id || 'unknown',
|
||||
organizationType: context.user?.organizationType || context.organizationType || 'SELLER',
|
||||
orderId: order.id,
|
||||
})
|
||||
throw error
|
||||
@ -192,7 +225,17 @@ export class SupplyDataFilter {
|
||||
organizationId: string,
|
||||
): { data: FilteredSupplyOrder; removedFields: string[]; accessLevel: DataAccessLevel } {
|
||||
// Фильтруем только позиции данного поставщика
|
||||
console.warn(`[DEBUG] filterForWholesale: organizationId=${organizationId}, items:`,
|
||||
order.items.map(item => ({
|
||||
productId: item.productId,
|
||||
productOrgId: item.product?.organizationId,
|
||||
hasProduct: !!item.product,
|
||||
}))
|
||||
)
|
||||
|
||||
const myItems = order.items.filter((item) => item.product.organizationId === organizationId)
|
||||
|
||||
console.warn(`[DEBUG] filterForWholesale: myItems.length=${myItems.length}`)
|
||||
|
||||
if (myItems.length === 0) {
|
||||
throw new GraphQLError('No items from your organization in this order', {
|
||||
@ -212,33 +255,53 @@ export class SupplyDataFilter {
|
||||
|
||||
const filteredOrder: FilteredSupplyOrder = {
|
||||
id: order.id,
|
||||
status: order.status,
|
||||
deliveryDate: order.deliveryDate,
|
||||
totalItems: myItems.length,
|
||||
organizationId: order.organizationId, // Обязательное поле
|
||||
partnerId: order.partnerId, // Обязательное поле
|
||||
partner: order.partner || { id: order.partnerId, name: 'Partner' }, // Обязательное поле
|
||||
organization: order.organization || { id: order.organizationId, name: 'Organization' }, // Обязательное поле
|
||||
status: order.status, // Обязательное поле
|
||||
deliveryDate: order.deliveryDate, // Обязательное поле
|
||||
totalAmount: order.items
|
||||
.filter((item) => item.product.organizationId === organizationId)
|
||||
.reduce((sum, item) => sum + (item.price || 0) * item.quantity, 0), // Только сумма своих товаров
|
||||
totalItems: myItems.length, // Обязательное поле
|
||||
createdAt: order.createdAt, // Обязательное поле
|
||||
updatedAt: order.updatedAt, // Обязательное поле
|
||||
routes: order.routes || [], // Обязательное поле
|
||||
|
||||
items: myItems.map((item) => ({
|
||||
id: item.id,
|
||||
productId: item.product.id, // Обязательное поле GraphQL
|
||||
product: {
|
||||
id: item.product.id,
|
||||
name: item.product.name,
|
||||
article: item.product.article || 'N/A', // Обязательное поле GraphQL
|
||||
price: item.product.price || 0, // Обязательное поле GraphQL
|
||||
quantity: item.product.quantity || 0, // Обязательное поле GraphQL
|
||||
images: item.product.images || [], // Обязательное поле GraphQL
|
||||
isActive: item.product.isActive !== false, // Обязательное поле GraphQL
|
||||
createdAt: item.product.createdAt || new Date().toISOString(), // Обязательное поле GraphQL
|
||||
updatedAt: item.product.updatedAt || new Date().toISOString(), // Обязательное поле GraphQL
|
||||
organization: item.product.organization || { // Обязательное поле GraphQL
|
||||
id: item.product.organizationId,
|
||||
inn: 'N/A',
|
||||
name: 'Organization',
|
||||
type: 'WHOLESALE',
|
||||
},
|
||||
organizationId: item.product.organizationId,
|
||||
},
|
||||
quantity: item.quantity,
|
||||
price: item.price, // Поставщик видит свою цену
|
||||
totalPrice: (item.price || 0) * item.quantity, // Обязательное поле GraphQL
|
||||
// Убираем рецептуру
|
||||
recipe: undefined,
|
||||
})),
|
||||
|
||||
// Показываем упаковочную информацию для логистики
|
||||
// Показываем параметры поставки для логистики
|
||||
packagesCount: order.packagesCount,
|
||||
volume: order.volume,
|
||||
readyDate: order.readyDate,
|
||||
notes: order.notes,
|
||||
|
||||
// Скрываем финансовую информацию других участников
|
||||
productPrice: order.items
|
||||
.filter((item) => item.product.organizationId === organizationId)
|
||||
.reduce((sum, item) => sum + (item.price || 0) * item.quantity, 0),
|
||||
}
|
||||
|
||||
return {
|
||||
@ -266,20 +329,43 @@ export class SupplyDataFilter {
|
||||
|
||||
const filteredOrder: FilteredSupplyOrder = {
|
||||
id: order.id,
|
||||
status: order.status,
|
||||
deliveryDate: order.deliveryDate,
|
||||
totalItems: order.totalItems,
|
||||
organizationId: order.organizationId, // Обязательное поле
|
||||
partnerId: order.partnerId, // Обязательное поле
|
||||
partner: order.partner || { id: order.partnerId, name: 'Partner' }, // Обязательное поле
|
||||
organization: order.organization || { id: order.organizationId, name: 'Organization' }, // Обязательное поле
|
||||
status: order.status, // Обязательное поле
|
||||
deliveryDate: order.deliveryDate, // Обязательное поле
|
||||
totalAmount: order.totalAmount || 0, // Обязательное поле
|
||||
totalItems: order.totalItems, // Обязательное поле
|
||||
createdAt: order.createdAt, // Обязательное поле
|
||||
updatedAt: order.updatedAt, // Обязательное поле
|
||||
routes: order.routes || [], // Обязательное поле
|
||||
|
||||
items: order.items.map((item) => ({
|
||||
id: item.id,
|
||||
productId: item.product.id, // Обязательное поле GraphQL
|
||||
product: {
|
||||
id: item.product.id,
|
||||
name: item.product.name,
|
||||
article: item.product.article || 'N/A', // Обязательное поле GraphQL
|
||||
price: item.product.price || 0, // Обязательное поле GraphQL
|
||||
quantity: item.product.quantity || 0, // Обязательное поле GraphQL
|
||||
images: item.product.images || [], // Обязательное поле GraphQL
|
||||
isActive: item.product.isActive !== false, // Обязательное поле GraphQL
|
||||
createdAt: item.product.createdAt || new Date().toISOString(), // Обязательное поле GraphQL
|
||||
updatedAt: item.product.updatedAt || new Date().toISOString(), // Обязательное поле GraphQL
|
||||
organization: item.product.organization || { // Обязательное поле GraphQL
|
||||
id: item.product.organizationId,
|
||||
inn: 'N/A',
|
||||
name: 'Organization',
|
||||
type: 'WHOLESALE',
|
||||
},
|
||||
organizationId: item.product.organizationId,
|
||||
},
|
||||
quantity: item.quantity,
|
||||
// Скрываем закупочную цену
|
||||
price: null,
|
||||
totalPrice: 0, // Фулфилмент не видит общую стоимость товаров
|
||||
// Оставляем рецептуру, но фильтруем цены расходников селлера
|
||||
recipe: item.recipe
|
||||
? {
|
||||
@ -302,7 +388,7 @@ export class SupplyDataFilter {
|
||||
fulfillmentServicePrice: order.fulfillmentServicePrice,
|
||||
logisticsPrice: order.logisticsPrice, // Для планирования
|
||||
|
||||
// Упаковочные данные
|
||||
// Параметры поставки
|
||||
packagesCount: order.packagesCount,
|
||||
volume: order.volume,
|
||||
readyDate: order.readyDate,
|
||||
@ -335,14 +421,23 @@ export class SupplyDataFilter {
|
||||
'recipe',
|
||||
'productPrice',
|
||||
'fulfillmentServicePrice',
|
||||
'organizationId',
|
||||
// Убрали organizationId из removedFields - логистика должна знать заказчика
|
||||
'fulfillmentCenterId',
|
||||
]
|
||||
|
||||
const filteredOrder: FilteredSupplyOrder = {
|
||||
id: order.id,
|
||||
status: order.status,
|
||||
deliveryDate: order.deliveryDate,
|
||||
organizationId: order.organizationId, // Обязательное поле - логистика должна знать заказчика
|
||||
partnerId: order.partnerId, // Обязательное поле
|
||||
partner: order.partner || { id: order.partnerId, name: 'Partner' }, // Обязательное поле
|
||||
organization: order.organization || { id: order.organizationId, name: 'Organization' }, // Обязательное поле
|
||||
status: order.status, // Обязательное поле
|
||||
deliveryDate: order.deliveryDate, // Обязательное поле
|
||||
totalAmount: order.logisticsPrice || 0, // Только стоимость логистики
|
||||
totalItems: 0, // Логистика не видит детали товаров
|
||||
createdAt: order.createdAt, // Обязательное поле
|
||||
updatedAt: order.updatedAt, // Обязательное поле
|
||||
items: [], // Обязательное поле - пустой массив для логистики
|
||||
|
||||
// Маршрутная информация
|
||||
routes: order.routes?.map((route) => ({
|
||||
|
@ -7,23 +7,6 @@
|
||||
|
||||
import { OrganizationType } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Контекст безопасности пользователя
|
||||
*/
|
||||
export interface SecurityContext {
|
||||
user: {
|
||||
id: string
|
||||
organizationId: string
|
||||
organizationType: OrganizationType
|
||||
}
|
||||
ipAddress?: string
|
||||
userAgent?: string
|
||||
request?: {
|
||||
headers?: Record<string, string>
|
||||
timestamp: Date
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат фильтрации данных
|
||||
*/
|
||||
@ -118,6 +101,51 @@ export interface SecurityContext {
|
||||
organizationId: string
|
||||
organizationType: OrganizationType
|
||||
}
|
||||
ipAddress?: string
|
||||
userAgent?: string
|
||||
request?: {
|
||||
headers?: Record<string, string>
|
||||
timestamp: Date
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание контекста безопасности
|
||||
*/
|
||||
export function createSecurityContext(params: {
|
||||
user: {
|
||||
id: string
|
||||
organizationId: string
|
||||
organizationType: OrganizationType
|
||||
}
|
||||
req?: {
|
||||
ip?: string
|
||||
get?: (header: string) => string | undefined
|
||||
}
|
||||
}): SecurityContext {
|
||||
const { user, req } = params
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
organizationId: user.organizationId,
|
||||
organizationType: user.organizationType,
|
||||
userRole: user.organizationType,
|
||||
user: user,
|
||||
ipAddress: req?.ip,
|
||||
userAgent: req?.get?.('User-Agent'),
|
||||
request: {
|
||||
headers: req?.get ? {
|
||||
'User-Agent': req.get('User-Agent') || '',
|
||||
'X-Forwarded-For': req.get('X-Forwarded-For') || '',
|
||||
} : {},
|
||||
timestamp: new Date(),
|
||||
},
|
||||
requestMetadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
ipAddress: req?.ip,
|
||||
userAgent: req?.get?.('User-Agent'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -211,6 +211,7 @@ export const typeDefs = gql`
|
||||
# Заказы поставок расходников
|
||||
createSupplyOrder(input: SupplyOrderInput!): SupplyOrderResponse!
|
||||
updateSupplyOrderStatus(id: ID!, status: SupplyOrderStatus!): SupplyOrderResponse!
|
||||
updateSupplyParameters(id: ID!, volume: Float, packagesCount: Int): SupplyOrderResponse!
|
||||
|
||||
# Назначение логистики фулфилментом
|
||||
assignLogisticsToSupply(supplyOrderId: ID!, logisticsPartnerId: ID!, responsibleId: ID): SupplyOrderResponse!
|
||||
|
Reference in New Issue
Block a user