feat: Phase 1 - Implementation of Data Security Infrastructure
Implemented comprehensive data security infrastructure for SFERA platform: ## Security Classes Created: - `SupplyDataFilter`: Role-based data filtering for supply orders - `ParticipantIsolation`: Data isolation between competing organizations - `RecipeAccessControl`: Protection of production recipes and trade secrets - `CommercialDataAudit`: Audit logging and suspicious activity detection - `SecurityLogger`: Centralized security event logging system ## Infrastructure Components: - Feature flags system for gradual security rollout - Database migrations for audit logging (AuditLog, SecurityAlert models) - Secure resolver wrapper for automatic GraphQL security - TypeScript interfaces and type safety throughout ## Security Features: - Role-based access control (SELLER, WHOLESALE, FULFILLMENT, LOGIST) - Commercial data protection between competitors - Production recipe confidentiality - Audit trail for all data access - Real-time security monitoring and alerts - Rate limiting and suspicious activity detection ## Implementation Notes: - All console logging replaced with centralized security logger - Comprehensive TypeScript typing with no explicit 'any' types - Modular architecture following SFERA coding standards - Feature flag controlled rollout for safe deployment This completes Phase 1 of the security implementation plan. Next phases will integrate these classes into existing GraphQL resolvers. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -75,11 +75,12 @@
|
||||
|
||||
Детальное описание ключевых бизнес-процессов системы.
|
||||
|
||||
| Файл | Описание | Статус |
|
||||
| ----------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------- |
|
||||
| **[SUPPLY_CHAIN_WORKFLOW.md](./business-processes/SUPPLY_CHAIN_WORKFLOW.md)** | Цепочка поставок: 8 статусов, роли, переходы, реальные мутации | ✅ |
|
||||
| **[PARTNERSHIP_SYSTEM.md](./business-processes/PARTNERSHIP_SYSTEM.md)** | Система партнерства: заявки, автопартнерство, реферальные бонусы | ✅ |
|
||||
| `REFERRAL_MECHANICS.md` | Механика реферальной системы | 📋 Планируется |
|
||||
| Файл | Описание | Статус |
|
||||
| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -------------- |
|
||||
| **[SUPPLY_CHAIN_WORKFLOW.md](./business-processes/SUPPLY_CHAIN_WORKFLOW.md)** | Цепочка поставок: 8 статусов, роли, переходы, реальные мутации | ✅ |
|
||||
| **[SUPPLY_DATA_SECURITY_RULES.md](./business-processes/SUPPLY_DATA_SECURITY_RULES.md)** | 🔐 Безопасность данных в поставках: изоляция, фильтрация, аудит | ✅ NEW |
|
||||
| **[PARTNERSHIP_SYSTEM.md](./business-processes/PARTNERSHIP_SYSTEM.md)** | Система партнерства: заявки, автопартнерство, реферальные бонусы | ✅ |
|
||||
| `REFERRAL_MECHANICS.md` | Механика реферальной системы | 📋 Планируется |
|
||||
|
||||
### 🛠️ DEVELOPMENT - Разработка
|
||||
|
||||
@ -151,11 +152,12 @@
|
||||
|
||||
- **Права доступа** → [BUSINESS_RULES_CORE.md](./core/BUSINESS_RULES_CORE.md)
|
||||
- **Изоляция данных** → Все **organization-types/\*.md** файлы
|
||||
- **Безопасность коммерческих данных** → [SUPPLY_DATA_SECURITY_RULES.md](./business-processes/SUPPLY_DATA_SECURITY_RULES.md) 🔐
|
||||
- **API безопасность** → [GRAPHQL_SCHEMA_RULES.md](./api-layer/GRAPHQL_SCHEMA_RULES.md)
|
||||
|
||||
## 📈 СТАТУС И ПРОГРЕСС
|
||||
|
||||
### ✅ ЗАВЕРШЕННЫЕ РАЗДЕЛЫ (11 файлов):
|
||||
### ✅ ЗАВЕРШЕННЫЕ РАЗДЕЛЫ (12 файлов):
|
||||
|
||||
Базовая архитектура документации полностью готова + углубленная функциональность:
|
||||
|
||||
@ -164,7 +166,7 @@
|
||||
- **Data Layer**: Prisma модели
|
||||
- **Presentation Layer**: Архитектура компонентов с модульными паттернами
|
||||
- **Organization Types**: Все 4 типа + интеграция с маркетплейсами
|
||||
- **Business Processes**: Workflow поставок + система партнерства
|
||||
- **Business Processes**: Workflow поставок + система партнерства + безопасность данных
|
||||
|
||||
### 📋 ПЛАНИРУЕМЫЕ РАЗДЕЛЫ:
|
||||
|
||||
|
@ -548,6 +548,242 @@ type Organization {
|
||||
referralCode: String
|
||||
referralPoints: Int!
|
||||
|
||||
# Marketplace данные
|
||||
market: String # Физический рынок (для WHOLESALE)
|
||||
# Временные метки
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
```
|
||||
|
||||
## 🏪 СПЕЦИФИЧНЫЕ ПРАВИЛА ДЛЯ ПОСТАВЩИКОВ (WHOLESALE)
|
||||
|
||||
### ЗАПРОСЫ ПОСТАВЩИКОВ:
|
||||
|
||||
```graphql
|
||||
# Получение товаров поставщика
|
||||
query GetMyProducts {
|
||||
myProducts {
|
||||
id
|
||||
name
|
||||
article
|
||||
price
|
||||
quantity
|
||||
organization {
|
||||
id
|
||||
name
|
||||
market # Физический рынок поставщика
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Получение входящих заказов поставщика
|
||||
query GetSupplierOrders($status: SupplyOrderStatus) {
|
||||
supplyOrders(where: { partnerId: $myOrgId, status: $status }) {
|
||||
id
|
||||
status
|
||||
totalAmount
|
||||
deliveryDate
|
||||
organization {
|
||||
name
|
||||
inn
|
||||
} # Заказчик
|
||||
fulfillmentCenter {
|
||||
name
|
||||
address
|
||||
} # Получатель
|
||||
items {
|
||||
id
|
||||
quantity
|
||||
price
|
||||
totalPrice
|
||||
product {
|
||||
id
|
||||
name
|
||||
article
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Получение партнеров поставщика
|
||||
query GetMyCounterparties($type: OrganizationType) {
|
||||
myCounterparties(type: $type) {
|
||||
id
|
||||
name
|
||||
type
|
||||
market
|
||||
fullName
|
||||
inn
|
||||
isCounterparty
|
||||
hasOutgoingRequest
|
||||
hasIncomingRequest
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### МУТАЦИИ ПОСТАВЩИКОВ:
|
||||
|
||||
```graphql
|
||||
# Одобрение заказа поставщиком с опциональными полями упаковки
|
||||
mutation SupplierApproveOrder(
|
||||
$orderId: ID!
|
||||
$packagesCount: Int
|
||||
$volume: Float
|
||||
$readyDate: DateTime
|
||||
$notes: String
|
||||
) {
|
||||
supplierApproveOrder(
|
||||
id: $orderId
|
||||
packagesCount: $packagesCount # Опционально: для логистических расчетов
|
||||
volume: $volume # Опционально: для планирования логистики
|
||||
readyDate: $readyDate # Опционально: дата готовности к отгрузке
|
||||
notes: $notes # Опционально: комментарии
|
||||
) {
|
||||
success
|
||||
message
|
||||
order {
|
||||
id
|
||||
status # PENDING → SUPPLIER_APPROVED
|
||||
organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
totalAmount
|
||||
packagesCount # null если не указано
|
||||
volume # null если не указано
|
||||
readyDate # null если не указано
|
||||
notes # null если не указано
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Отклонение заказа поставщиком
|
||||
mutation SupplierRejectOrder($orderId: ID!, $reason: String) {
|
||||
supplierRejectOrder(id: $orderId, reason: $reason) {
|
||||
success
|
||||
message
|
||||
order {
|
||||
id
|
||||
status # PENDING → CANCELLED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Отгрузка товара поставщиком
|
||||
mutation SupplierShipOrder($orderId: ID!) {
|
||||
supplierShipOrder(id: $orderId) {
|
||||
success
|
||||
message
|
||||
order {
|
||||
id
|
||||
status # LOGISTICS_CONFIRMED → SHIPPED
|
||||
organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
logisticsPartner {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Создание товара поставщиком
|
||||
mutation CreateProduct($input: ProductInput!) {
|
||||
createProduct(input: $input) {
|
||||
success
|
||||
message
|
||||
product {
|
||||
id
|
||||
article
|
||||
name
|
||||
price
|
||||
organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ПРАВИЛА АВТОРИЗАЦИИ ПОСТАВЩИКОВ:
|
||||
|
||||
```typescript
|
||||
// Resolver-level security для поставщиков
|
||||
const wholesaleResolvers = {
|
||||
// Проверка что пользователь - поставщик
|
||||
validateWholesaleAccess: (context) => {
|
||||
if (context.user.organization.type !== 'WHOLESALE') {
|
||||
throw new GraphQLError('Access denied: Wholesale access required')
|
||||
}
|
||||
},
|
||||
|
||||
// Фильтрация заказов для поставщика
|
||||
getSupplierOrders: async (parent, args, context) => {
|
||||
// Поставщик видит только заказы где он является поставщиком
|
||||
return await prisma.supplyOrder.findMany({
|
||||
where: {
|
||||
partnerId: context.user.organization.id, // Мы - поставщик
|
||||
...args.where,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
// Проверка доступа к товарам
|
||||
validateProductAccess: async (productId, context) => {
|
||||
const product = await prisma.product.findFirst({
|
||||
where: {
|
||||
id: productId,
|
||||
organizationId: context.user.organizationId, // Только свои товары
|
||||
},
|
||||
})
|
||||
|
||||
if (!product) {
|
||||
throw new GraphQLError('Product not found or access denied')
|
||||
}
|
||||
return product
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### КРИТИЧЕСКИЕ ПРАВИЛА ПАРТНЕРСТВА:
|
||||
|
||||
```typescript
|
||||
// ✅ ПРАВИЛЬНО: Поставщики берутся ТОЛЬКО из партнеров
|
||||
const getWholesalePartners = `
|
||||
query GetMyCounterparties {
|
||||
myCounterparties(type: WHOLESALE) {
|
||||
id, name, fullName, inn, market
|
||||
isCounterparty # Должно быть true
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ❌ НЕПРАВИЛЬНО: Прямой запрос всех поставщиков
|
||||
const wrongSupplierQuery = `
|
||||
query GetAllSuppliers {
|
||||
organizations(type: WHOLESALE) { # Неправильно - нет проверки партнерства
|
||||
id, name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Правильная фильтрация в резолвере:
|
||||
const correctPartnershipFilter = `
|
||||
// Показываем только организации-партнеры
|
||||
const counterparties = await prisma.counterparty.findMany({
|
||||
where: {
|
||||
initiatorId: currentUser.organization.id,
|
||||
status: 'ACCEPTED',
|
||||
partner: { type: 'WHOLESALE' }
|
||||
},
|
||||
include: { partner: true }
|
||||
})
|
||||
`;
|
||||
|
||||
# Временные метки (обязательно)
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
|
@ -54,20 +54,20 @@ graph TD
|
||||
**GraphQL мутация подтверждения поставщиком:**
|
||||
|
||||
```graphql
|
||||
# Поставщик может указать детали упаковки при подтверждении
|
||||
# Поставщик указывает детали упаковки при одобрении (опционально)
|
||||
mutation SupplierApproveOrderWithPackaging($id: ID!, $packagesCount: Int, $volume: Float) {
|
||||
supplierApproveOrderWithPackaging(
|
||||
id: $id
|
||||
packagesCount: $packagesCount # Количество грузовых мест
|
||||
volume: $volume # Объём в м³ (влияет на логистические тарифы)
|
||||
packagesCount: $packagesCount # Опционально: количество грузовых мест
|
||||
volume: $volume # Опционально: объём в м³ для расчета логистических тарифов
|
||||
) {
|
||||
success
|
||||
message
|
||||
order {
|
||||
id
|
||||
status
|
||||
packagesCount
|
||||
volume
|
||||
packagesCount # null если не указано
|
||||
volume # null если не указано
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -247,6 +247,211 @@ createSupplyOrder(input: {
|
||||
|
||||
**Обработка входящих заказов:**
|
||||
|
||||
```typescript
|
||||
// Поставщик получает заказы где он является поставщиком
|
||||
const supplierOrders = await prisma.supplyOrder.findMany({
|
||||
where: {
|
||||
partnerId: currentUser.organization.id, // Мы - поставщик
|
||||
status: 'PENDING', // Ожидает подтверждения
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Действия поставщика:**
|
||||
|
||||
```graphql
|
||||
# Одобрение заказа
|
||||
mutation SupplierApproveOrder($orderId: ID!) {
|
||||
supplierApproveOrder(id: $orderId) {
|
||||
success
|
||||
order {
|
||||
id
|
||||
status
|
||||
} # PENDING → SUPPLIER_APPROVED
|
||||
}
|
||||
}
|
||||
|
||||
# Отклонение заказа
|
||||
mutation SupplierRejectOrder($orderId: ID!, $reason: String) {
|
||||
supplierRejectOrder(id: $orderId, reason: $reason) {
|
||||
success
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
# Отгрузка товара (после подтверждения логистики)
|
||||
mutation SupplierShipOrder($orderId: ID!) {
|
||||
supplierShipOrder(id: $orderId) {
|
||||
success
|
||||
order {
|
||||
id
|
||||
status
|
||||
} # LOGISTICS_CONFIRMED → SHIPPED
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Компоненты поставщика:**
|
||||
|
||||
```typescript
|
||||
// Техническая реализация кабинета поставщика
|
||||
src/components/supplier-orders/
|
||||
├── supplier-orders-dashboard.tsx # Главный dashboard
|
||||
├── supplier-order-card.tsx # Карточка заказа
|
||||
├── supplier-orders-tabs.tsx # Табы по статусам
|
||||
├── supplier-orders-search.tsx # Поиск и фильтры
|
||||
└── supplier-order-stats.tsx # Статистика заказов
|
||||
```
|
||||
|
||||
**Возможности:**
|
||||
|
||||
- ✅ Просматривать входящие заказы (PENDING)
|
||||
- ✅ Одобрять заказы (PENDING → SUPPLIER_APPROVED)
|
||||
- ✅ Отклонять заказы (PENDING → CANCELLED)
|
||||
- ✅ Отгружать товары (LOGISTICS_CONFIRMED → SHIPPED)
|
||||
- ❌ Изменять детали заказа после создания
|
||||
- ❌ Видеть заказы других поставщиков
|
||||
|
||||
## 🚨 КРИТИЧЕСКИЕ ПРОБЛЕМЫ WORKFLOW
|
||||
|
||||
### ВЫЯВЛЕННЫЕ ПРОБЛЕМЫ В ЦЕПОЧКЕ ПОСТАВОК:
|
||||
|
||||
#### ❌ **ПРОБЛЕМА 1: Неправильное отображение статусов у поставщика**
|
||||
|
||||
```typescript
|
||||
// ПРОБЛЕМА: Поставщик видит "ожидает подтверждения" вместо только кнопок
|
||||
// РЕШЕНИЕ: Показывать только кнопки действий, скрывать статусы
|
||||
|
||||
// Текущий код (неправильно):
|
||||
<StatusBadge status={order.status} />
|
||||
<ActionButtons />
|
||||
|
||||
// Правильный код:
|
||||
{user?.organization?.type === 'WHOLESALE' ? (
|
||||
<ActionButtons only /> // Только кнопки, без статуса
|
||||
) : (
|
||||
<StatusBadge status={order.status} />
|
||||
)}
|
||||
```
|
||||
|
||||
#### ❌ **ПРОБЛЕМА 2: Отсутствие полей ввода у поставщика**
|
||||
|
||||
```typescript
|
||||
// ПРОБЛЕМА: Поставщик не может указать важные данные при одобрении
|
||||
interface SupplierPackagingFields {
|
||||
packagesCount?: number // ОПЦИОНАЛЬНО: Количество грузовых мест
|
||||
volume?: number // ОПЦИОНАЛЬНО: Объем груза для логистических расчетов
|
||||
readyDate?: DateTime // ОПЦИОНАЛЬНО: Дата готовности к отгрузке
|
||||
notes?: string // ОПЦИОНАЛЬНО: Комментарии для логистики
|
||||
}
|
||||
|
||||
// ТРЕБОВАНИЯ:
|
||||
// ✅ Поля НЕ обязательные - заказ можно одобрить без них
|
||||
// ✅ Показываются сразу при одобрении для удобства заполнения
|
||||
// ✅ Используются логистикой для расчета тарифов и планирования
|
||||
// ✅ Отображаются на 1-м уровне визуализации поставки
|
||||
|
||||
// РЕШЕНИЕ: Расширить мутацию supplierApproveOrder
|
||||
mutation SupplierApproveOrder($input: SupplierApprovalInput!) {
|
||||
supplierApproveOrder(input: $input) {
|
||||
success
|
||||
order {
|
||||
id, status, packagesCount, volume, readyDate, notes
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ **ПРОБЛЕМА 3: Конфликт статусов в приемке фулфилмента**
|
||||
|
||||
```typescript
|
||||
// КРИТИЧЕСКАЯ ОШИБКА: Резолвер ожидает SHIPPED, но получает SUPPLIER_APPROVED
|
||||
if (supplyOrder.status !== 'SHIPPED') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Заказ должен быть в статусе SHIPPED для приемки', // ❌ БЛОКИРУЕТ ПРОЦЕСС
|
||||
}
|
||||
}
|
||||
|
||||
// РЕШЕНИЕ: Исправить проверку статуса
|
||||
if (!['SUPPLIER_APPROVED', 'LOGISTICS_CONFIRMED'].includes(supplyOrder.status)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Заказ должен быть одобрен поставщиком для приемки',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ **ПРОБЛЕМА 4: Отсутствие уведомлений поставщика**
|
||||
|
||||
```typescript
|
||||
// ПРОБЛЕМА: Поставщик не знает о новых заказах в реальном времени
|
||||
// РЕШЕНИЕ: Добавить систему уведомлений
|
||||
|
||||
interface SupplierNotifications {
|
||||
newOrder: 'Новый заказ от {sellerName} на сумму {amount}'
|
||||
orderCancelled: 'Заказ #{orderNumber} отменен заказчиком'
|
||||
logistics: 'Логистика подтверждена для заказа #{orderNumber}'
|
||||
}
|
||||
```
|
||||
|
||||
### ПЛАН ИСПРАВЛЕНИЯ WORKFLOW:
|
||||
|
||||
```typescript
|
||||
interface WorkflowFixes {
|
||||
// Фаза 1: UI поставщика
|
||||
supplierInterface: {
|
||||
hideStatuses: 'Показывать только кнопки действий'
|
||||
addFields: 'Поля для packagesCount, volume, readyDate'
|
||||
realtime: 'Уведомления о новых заказах'
|
||||
}
|
||||
|
||||
// Фаза 2: Backend логика
|
||||
backendLogic: {
|
||||
expandMutation: 'Расширить supplierApproveOrder с дополнительными полями'
|
||||
fixStatusCheck: 'Исправить проверку статусов в fulfillmentReceiveOrder'
|
||||
notifications: 'Система реалтайм уведомлений'
|
||||
}
|
||||
|
||||
// Фаза 3: Интеграция
|
||||
integration: {
|
||||
validation: 'Валидация минимальных количеств заказа'
|
||||
inventory: 'Проверка доступности товаров у поставщика'
|
||||
logistics: 'Автоматическое назначение логистики'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ТРЕБОВАНИЯ К РЕАЛИЗАЦИИ:
|
||||
|
||||
```typescript
|
||||
// 1. Исправленная фильтрация заказов для поставщика
|
||||
const fixedSupplierFilter = `
|
||||
if (currentUser.organization.type === 'WHOLESALE') {
|
||||
whereClause = {
|
||||
partnerId: currentUser.organization.id, // Мы - поставщик
|
||||
}
|
||||
} else {
|
||||
whereClause = {
|
||||
organizationId: currentUser.organization.id, // Мы - заказчик
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// 2. Правильная обработка статусов
|
||||
const correctStatusHandling = `
|
||||
// Поставщик видит только кнопки, без статусов
|
||||
{userRole === 'WHOLESALE' && status === 'PENDING' && (
|
||||
<ApproveRejectButtons orderId={order.id} />
|
||||
)}
|
||||
|
||||
// Остальные видят статусы
|
||||
{userRole !== 'WHOLESALE' && (
|
||||
<StatusBadge status={status} />
|
||||
)}
|
||||
`
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Из кода resolvers.ts:
|
||||
const incomingSupplierOrders = await prisma.supplyOrder.count({
|
||||
|
669
docs/business-processes/SUPPLY_DATA_SECURITY_RULES.md
Normal file
669
docs/business-processes/SUPPLY_DATA_SECURITY_RULES.md
Normal file
@ -0,0 +1,669 @@
|
||||
# ПРАВИЛА БЕЗОПАСНОСТИ ДАННЫХ В ПОСТАВКАХ SFERA
|
||||
|
||||
## 🎯 ОБЗОР
|
||||
|
||||
Система безопасности данных в поставках обеспечивает **коммерческую конфиденциальность** и **изоляцию данных** между участниками цепочки поставок: SELLER, WHOLESALE, FULFILLMENT, LOGIST.
|
||||
|
||||
### КЛЮЧЕВЫЕ ПРИНЦИПЫ:
|
||||
|
||||
1. **Принцип минимальных привилегий** - каждый участник видит только необходимые данные
|
||||
2. **Коммерческая тайна** - защита закупочных цен и производственных секретов
|
||||
3. **Изоляция данных** - участники не видят данные друг друга
|
||||
4. **Аудит доступа** - логирование всех обращений к чувствительным данным
|
||||
|
||||
## 🔐 МАТРИЦА ДОСТУПА К ДАННЫМ
|
||||
|
||||
### СТРУКТУРА ДАННЫХ ПОСТАВКИ:
|
||||
|
||||
```typescript
|
||||
interface SupplyOrder {
|
||||
// Базовая информация (видна всем участникам)
|
||||
id: string
|
||||
status: SupplyOrderStatus
|
||||
deliveryDate: Date
|
||||
totalItems: number
|
||||
|
||||
// Коммерческая информация (ограниченный доступ)
|
||||
productPrice: Decimal // Закупочная цена у поставщика
|
||||
fulfillmentServicePrice: Decimal // Стоимость услуг ФФ
|
||||
logisticsPrice: Decimal // Стоимость доставки
|
||||
totalAmount: Decimal // Общая сумма
|
||||
|
||||
// Производственная информация (ограниченный доступ)
|
||||
recipe: {
|
||||
services: Service[] // Услуги ФФ
|
||||
fulfillmentConsumables: Supply[] // Расходники ФФ
|
||||
sellerConsumables: Supply[] // Расходники селлера
|
||||
}
|
||||
|
||||
// Упаковочная информация (опциональная)
|
||||
packagesCount?: number // Количество грузовых мест
|
||||
volume?: number // Объем груза в м³
|
||||
readyDate?: Date // Дата готовности к отгрузке
|
||||
notes?: string // Комментарии
|
||||
}
|
||||
```
|
||||
|
||||
### ТАБЛИЦА ДОСТУПА:
|
||||
|
||||
| Данные | SELLER | WHOLESALE | FULFILLMENT | LOGIST |
|
||||
| ---------------------------------- | ------ | --------- | ----------- | ------ |
|
||||
| **Базовая информация** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **productPrice** (закупочная цена) | ✅ | ✅ | ❌ | ❌ |
|
||||
| **fulfillmentServicePrice** | ✅ | ❌ | ✅ | ❌ |
|
||||
| **logisticsPrice** | ✅ | ❌ | ✅ | ✅ |
|
||||
| **totalAmount для SELLER** | ✅ | ❌ | ❌ | ❌ |
|
||||
| **totalAmount для FULFILLMENT** | ❌ | ❌ | ✅ | ❌ |
|
||||
| **recipe (рецептура)** | ✅ | ❌ | ✅ | ❌ |
|
||||
| **packagesCount, volume** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Контакты других участников** | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
## 📊 РАСЧЕТ СТОИМОСТЕЙ ПО РОЛЯМ
|
||||
|
||||
### ДЛЯ SELLER (полная стоимость):
|
||||
|
||||
```typescript
|
||||
totalAmountForSeller =
|
||||
productPrice + // Закупка у поставщика
|
||||
fulfillmentServicePrice + // Услуги ФФ
|
||||
logisticsPrice + // Доставка
|
||||
fulfillmentConsumablesPrice + // Расходники ФФ
|
||||
sellerConsumablesPrice // Свои расходники (price × quantity)
|
||||
```
|
||||
|
||||
### ДЛЯ FULFILLMENT (без закупочных цен):
|
||||
|
||||
```typescript
|
||||
totalAmountForFulfillment =
|
||||
fulfillmentServicePrice + // Свои услуги
|
||||
logisticsPrice + // Доставка (для планирования)
|
||||
fulfillmentConsumablesPrice // Свои расходники
|
||||
// НЕ ВИДИТ: productPrice, sellerConsumablesPrice
|
||||
```
|
||||
|
||||
### ДЛЯ WHOLESALE (только свои товары):
|
||||
|
||||
```typescript
|
||||
totalAmountForWholesale =
|
||||
productPrice × quantity // Только стоимость своих товаров
|
||||
// НЕ ВИДИТ: услуги ФФ, логистику, рецептуру
|
||||
```
|
||||
|
||||
### ДЛЯ LOGIST (только доставка):
|
||||
|
||||
```typescript
|
||||
totalAmountForLogist = logisticsPrice // Только стоимость доставки
|
||||
// НЕ ВИДИТ: цены товаров, услуги, рецептуру
|
||||
```
|
||||
|
||||
## 🛡️ РЕАЛИЗАЦИЯ БЕЗОПАСНОСТИ
|
||||
|
||||
### 1. ФИЛЬТРАЦИЯ НА УРОВНЕ RESOLVER
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/supply-data-filter.ts
|
||||
|
||||
export class SupplyDataFilter {
|
||||
/**
|
||||
* Фильтрует данные поставки в зависимости от роли пользователя
|
||||
*/
|
||||
static filterSupplyOrderByRole(order: SupplyOrder, userRole: OrganizationType, userId: string): FilteredSupplyOrder {
|
||||
switch (userRole) {
|
||||
case 'SELLER':
|
||||
return this.filterForSeller(order, userId)
|
||||
|
||||
case 'WHOLESALE':
|
||||
return this.filterForWholesale(order, userId)
|
||||
|
||||
case 'FULFILLMENT':
|
||||
return this.filterForFulfillment(order, userId)
|
||||
|
||||
case 'LOGIST':
|
||||
return this.filterForLogist(order, userId)
|
||||
|
||||
default:
|
||||
throw new GraphQLError('Unauthorized organization type')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SELLER видит всю информацию по своим поставкам
|
||||
*/
|
||||
private static filterForSeller(order: SupplyOrder, userId: string): FilteredSupplyOrder {
|
||||
// Проверка, что это поставка данного селлера
|
||||
if (order.organizationId !== userId) {
|
||||
throw new GraphQLError('Access denied to this supply order')
|
||||
}
|
||||
|
||||
return {
|
||||
...order,
|
||||
// Селлер видит все данные своей поставки
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHOLESALE видит только свои товары без рецептуры
|
||||
*/
|
||||
private static filterForWholesale(order: SupplyOrder, userId: string): FilteredSupplyOrder {
|
||||
// Фильтруем только позиции данного поставщика
|
||||
const myItems = order.items.filter((item) => item.product.organizationId === userId)
|
||||
|
||||
if (myItems.length === 0) {
|
||||
throw new GraphQLError('No items from your organization in this order')
|
||||
}
|
||||
|
||||
return {
|
||||
...order,
|
||||
items: myItems.map((item) => ({
|
||||
...item,
|
||||
// Убираем рецептуру
|
||||
recipe: null,
|
||||
services: [],
|
||||
fulfillmentConsumables: [],
|
||||
sellerConsumables: [],
|
||||
})),
|
||||
// Скрываем общие суммы и услуги
|
||||
totalAmount: null,
|
||||
fulfillmentServicePrice: null,
|
||||
logisticsPrice: null,
|
||||
// Оставляем информацию об упаковке
|
||||
packagesCount: order.packagesCount,
|
||||
volume: order.volume,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FULFILLMENT видит рецептуру, но не видит закупочные цены
|
||||
*/
|
||||
private static filterForFulfillment(order: SupplyOrder, userId: string): FilteredSupplyOrder {
|
||||
// Проверка, что поставка для данного ФФ
|
||||
if (order.fulfillmentCenterId !== userId) {
|
||||
throw new GraphQLError('Access denied to this supply order')
|
||||
}
|
||||
|
||||
return {
|
||||
...order,
|
||||
items: order.items.map((item) => ({
|
||||
...item,
|
||||
// Скрываем закупочные цены
|
||||
price: null,
|
||||
productPrice: null,
|
||||
// Оставляем рецептуру
|
||||
recipe: item.recipe,
|
||||
// Для расходников селлера показываем только ID и количество
|
||||
sellerConsumables: item.sellerConsumables?.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
quantity: c.quantity,
|
||||
// НЕ показываем цену
|
||||
})),
|
||||
})),
|
||||
// Показываем только свою часть общей суммы
|
||||
totalAmount: this.calculateFulfillmentTotal(order),
|
||||
productPrice: null, // Скрыто
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LOGIST видит только информацию о доставке
|
||||
*/
|
||||
private static filterForLogist(order: SupplyOrder, userId: string): FilteredSupplyOrder {
|
||||
// Проверка, что логистика назначена на этот заказ
|
||||
if (order.logisticsPartnerId !== userId) {
|
||||
throw new GraphQLError('Access denied to this supply order')
|
||||
}
|
||||
|
||||
return {
|
||||
// Базовая информация
|
||||
id: order.id,
|
||||
status: order.status,
|
||||
deliveryDate: order.deliveryDate,
|
||||
|
||||
// Информация о маршруте
|
||||
routes: order.routes.map((route) => ({
|
||||
from: route.from,
|
||||
fromAddress: route.fromAddress,
|
||||
to: route.to,
|
||||
toAddress: route.toAddress,
|
||||
// Только количество мест и объем
|
||||
packagesCount: route.packagesCount,
|
||||
volume: route.volume,
|
||||
})),
|
||||
|
||||
// Только логистическая информация
|
||||
logisticsPrice: order.logisticsPrice,
|
||||
totalAmount: order.logisticsPrice, // Только своя сумма
|
||||
|
||||
// Скрываем все остальное
|
||||
items: [],
|
||||
recipe: null,
|
||||
productPrice: null,
|
||||
fulfillmentServicePrice: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Расчет суммы для фулфилмента
|
||||
*/
|
||||
private static calculateFulfillmentTotal(order: SupplyOrder): number {
|
||||
return (
|
||||
Number(order.fulfillmentServicePrice || 0) +
|
||||
Number(order.logisticsPrice || 0) +
|
||||
order.items.reduce((sum, item) => {
|
||||
const consumablesPrice =
|
||||
item.fulfillmentConsumables?.reduce((cSum, c) => cSum + c.pricePerUnit * c.quantity, 0) || 0
|
||||
return sum + consumablesPrice
|
||||
}, 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. ИЗОЛЯЦИЯ ДАННЫХ МЕЖДУ УЧАСТНИКАМИ
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/participant-isolation.ts
|
||||
|
||||
export class ParticipantIsolation {
|
||||
/**
|
||||
* Проверяет, что селлеры не видят данные друг друга
|
||||
*/
|
||||
static async validateSellerIsolation(
|
||||
prisma: PrismaClient,
|
||||
currentUserId: string,
|
||||
targetSellerId: string,
|
||||
): Promise<boolean> {
|
||||
// Селлер может видеть только свои данные
|
||||
if (currentUserId !== targetSellerId) {
|
||||
throw new GraphQLError('Access denied to other seller data')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет доступ к данным через партнерство
|
||||
*/
|
||||
static async validatePartnerAccess(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
partnerId: string,
|
||||
): Promise<boolean> {
|
||||
const partnership = await prisma.counterparty.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
organizationId: organizationId,
|
||||
counterpartyId: partnerId,
|
||||
status: 'ACCEPTED',
|
||||
},
|
||||
{
|
||||
organizationId: partnerId,
|
||||
counterpartyId: organizationId,
|
||||
status: 'ACCEPTED',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (!partnership) {
|
||||
throw new GraphQLError('No active partnership found')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Группировка заказов для логистики с изоляцией селлеров
|
||||
*/
|
||||
static groupOrdersForLogistics(orders: SupplyOrder[]): GroupedLogisticsOrder[] {
|
||||
// Группируем по маршрутам, скрывая информацию о селлерах
|
||||
const grouped = orders.reduce(
|
||||
(acc, order) => {
|
||||
const routeKey = `${order.route.from}-${order.route.to}`
|
||||
|
||||
if (!acc[routeKey]) {
|
||||
acc[routeKey] = {
|
||||
route: {
|
||||
from: order.route.from,
|
||||
to: order.route.to,
|
||||
},
|
||||
orders: [],
|
||||
totalPackages: 0,
|
||||
totalVolume: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем заказ БЕЗ информации о селлере
|
||||
acc[routeKey].orders.push({
|
||||
id: order.id,
|
||||
packagesCount: order.packagesCount || 0,
|
||||
volume: order.volume || 0,
|
||||
// НЕ добавляем: organizationId, sellerName и т.д.
|
||||
})
|
||||
|
||||
acc[routeKey].totalPackages += order.packagesCount || 0
|
||||
acc[routeKey].totalVolume += order.volume || 0
|
||||
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, GroupedLogisticsOrder>,
|
||||
)
|
||||
|
||||
return Object.values(grouped)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. КОНТРОЛЬ ДОСТУПА К РЕЦЕПТУРЕ
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/recipe-access-control.ts
|
||||
|
||||
export class RecipeAccessControl {
|
||||
/**
|
||||
* Фильтрует рецептуру в зависимости от роли
|
||||
*/
|
||||
static filterRecipeByRole(
|
||||
recipe: ProductRecipe,
|
||||
userRole: OrganizationType,
|
||||
userOrgId: string,
|
||||
fulfillmentId?: string,
|
||||
): FilteredRecipe | null {
|
||||
switch (userRole) {
|
||||
case 'SELLER':
|
||||
// Селлер видит полную рецептуру
|
||||
return recipe
|
||||
|
||||
case 'FULFILLMENT':
|
||||
// ФФ видит рецептуру только если это его заказ
|
||||
if (fulfillmentId === userOrgId) {
|
||||
return {
|
||||
services: recipe.services,
|
||||
fulfillmentConsumables: recipe.fulfillmentConsumables.map((c) => ({
|
||||
...c,
|
||||
// Показываем pricePerUnit для расчета, НЕ закупочную цену
|
||||
price: undefined,
|
||||
pricePerUnit: c.pricePerUnit,
|
||||
})),
|
||||
sellerConsumables: recipe.sellerConsumables.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
quantity: c.quantity,
|
||||
// НЕ показываем цены расходников селлера
|
||||
})),
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
case 'WHOLESALE':
|
||||
case 'LOGIST':
|
||||
// Поставщик и логистика НЕ видят рецептуру
|
||||
return null
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет доступ к услугам фулфилмента
|
||||
*/
|
||||
static async validateServiceAccess(
|
||||
prisma: PrismaClient,
|
||||
serviceIds: string[],
|
||||
fulfillmentId: string,
|
||||
): Promise<boolean> {
|
||||
const services = await prisma.service.findMany({
|
||||
where: {
|
||||
id: { in: serviceIds },
|
||||
organizationId: fulfillmentId,
|
||||
},
|
||||
})
|
||||
|
||||
if (services.length !== serviceIds.length) {
|
||||
throw new GraphQLError('Some services do not belong to this fulfillment center')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. АУДИТ ДОСТУПА К КОММЕРЧЕСКИМ ДАННЫМ
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/commercial-data-audit.ts
|
||||
|
||||
export class CommercialDataAudit {
|
||||
/**
|
||||
* Логирует доступ к коммерческим данным
|
||||
*/
|
||||
static async logAccess(params: {
|
||||
userId: string
|
||||
organizationType: OrganizationType
|
||||
accessType: 'VIEW_PRICE' | 'VIEW_RECIPE' | 'VIEW_CONTACTS'
|
||||
resourceType: 'SUPPLY_ORDER' | 'PRODUCT' | 'SERVICE'
|
||||
resourceId: string
|
||||
metadata?: Record<string, any>
|
||||
}): Promise<void> {
|
||||
const { userId, organizationType, accessType, resourceType, resourceId, metadata } = params
|
||||
|
||||
// Критические типы доступа требующие особого внимания
|
||||
const criticalAccess = [
|
||||
'VIEW_PRICE', // Просмотр коммерческих цен
|
||||
'VIEW_RECIPE', // Просмотр производственных секретов
|
||||
]
|
||||
|
||||
if (criticalAccess.includes(accessType)) {
|
||||
console.warn(
|
||||
`🔐 CRITICAL DATA ACCESS: User ${userId} (${organizationType}) accessed ${accessType} for ${resourceType} ${resourceId}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Сохраняем в базу данных
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
organizationType,
|
||||
action: `DATA_ACCESS:${accessType}`,
|
||||
resourceType,
|
||||
resourceId,
|
||||
metadata: metadata || {},
|
||||
ipAddress: metadata?.ipAddress,
|
||||
userAgent: metadata?.userAgent,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Проверка на подозрительную активность
|
||||
await this.checkSuspiciousActivity(userId, accessType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка подозрительной активности
|
||||
*/
|
||||
private static async checkSuspiciousActivity(userId: string, accessType: string): Promise<void> {
|
||||
// Считаем количество обращений за последний час
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
|
||||
|
||||
const accessCount = await prisma.auditLog.count({
|
||||
where: {
|
||||
userId,
|
||||
action: { contains: accessType },
|
||||
timestamp: { gte: oneHourAgo },
|
||||
},
|
||||
})
|
||||
|
||||
// Пороги для разных типов доступа
|
||||
const thresholds = {
|
||||
VIEW_PRICE: 100, // Максимум 100 просмотров цен в час
|
||||
VIEW_RECIPE: 50, // Максимум 50 просмотров рецептур в час
|
||||
VIEW_CONTACTS: 200, // Максимум 200 просмотров контактов в час
|
||||
}
|
||||
|
||||
if (accessCount > thresholds[accessType]) {
|
||||
// Отправляем алерт администраторам
|
||||
await this.sendSecurityAlert({
|
||||
userId,
|
||||
type: 'EXCESSIVE_DATA_ACCESS',
|
||||
message: `User ${userId} exceeded ${accessType} threshold: ${accessCount} accesses in 1 hour`,
|
||||
severity: 'HIGH',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправка алертов безопасности
|
||||
*/
|
||||
private static async sendSecurityAlert(alert: {
|
||||
userId: string
|
||||
type: string
|
||||
message: string
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
}): Promise<void> {
|
||||
console.error(`🚨 SECURITY ALERT [${alert.severity}]: ${alert.message}`)
|
||||
|
||||
// TODO: Интеграция с системой алертов (email, SMS, Slack)
|
||||
// await notificationService.sendAlert(alert)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔒 ПРАКТИЧЕСКИЕ ПРИМЕРЫ
|
||||
|
||||
### ПРИМЕР 1: Селлер создает поставку товаров
|
||||
|
||||
```typescript
|
||||
// Селлер видит полную информацию
|
||||
{
|
||||
"id": "supply-001",
|
||||
"status": "PENDING",
|
||||
"items": [{
|
||||
"product": { "name": "Товар A", "price": 1000 }, // ✅ Видит закупочную цену
|
||||
"quantity": 10,
|
||||
"recipe": { // ✅ Видит рецептуру
|
||||
"services": ["Упаковка", "Маркировка"],
|
||||
"fulfillmentConsumables": ["Пленка", "Скотч"],
|
||||
"sellerConsumables": ["Этикетка бренда"]
|
||||
}
|
||||
}],
|
||||
"totalAmount": 15000, // ✅ Видит полную сумму
|
||||
"productPrice": 10000,
|
||||
"fulfillmentServicePrice": 3000,
|
||||
"logisticsPrice": 2000
|
||||
}
|
||||
```
|
||||
|
||||
### ПРИМЕР 2: Поставщик видит тот же заказ
|
||||
|
||||
```typescript
|
||||
// Поставщик видит только свою часть
|
||||
{
|
||||
"id": "supply-001",
|
||||
"status": "PENDING",
|
||||
"deliveryDate": "2024-01-15",
|
||||
"items": [{
|
||||
"product": { "name": "Товар A", "price": 1000 }, // ✅ Видит свою цену
|
||||
"quantity": 10
|
||||
// ❌ НЕ видит recipe
|
||||
}],
|
||||
"packagesCount": 2, // ✅ Видит упаковочную информацию
|
||||
"volume": 0.5,
|
||||
// ❌ НЕ видит totalAmount, услуги ФФ, логистику
|
||||
}
|
||||
```
|
||||
|
||||
### ПРИМЕР 3: Фулфилмент видит тот же заказ
|
||||
|
||||
```typescript
|
||||
// Фулфилмент видит рецептуру без закупочных цен
|
||||
{
|
||||
"id": "supply-001",
|
||||
"status": "PENDING",
|
||||
"items": [{
|
||||
"product": { "name": "Товар A" }, // ❌ НЕ видит закупочную цену
|
||||
"quantity": 10,
|
||||
"recipe": { // ✅ Видит рецептуру
|
||||
"services": ["Упаковка", "Маркировка"],
|
||||
"fulfillmentConsumables": [{
|
||||
"name": "Пленка",
|
||||
"pricePerUnit": 50 // ✅ Видит свою цену расходника
|
||||
}],
|
||||
"sellerConsumables": [{
|
||||
"name": "Этикетка бренда",
|
||||
"quantity": 10
|
||||
// ❌ НЕ видит цену расходников селлера
|
||||
}]
|
||||
}
|
||||
}],
|
||||
"totalAmount": 5000, // ✅ Только сумма услуг ФФ + логистика + расходники ФФ
|
||||
"fulfillmentServicePrice": 3000,
|
||||
"logisticsPrice": 2000
|
||||
}
|
||||
```
|
||||
|
||||
### ПРИМЕР 4: Логистика видит только доставку
|
||||
|
||||
```typescript
|
||||
// Логистика видит минимум информации
|
||||
{
|
||||
"id": "supply-001",
|
||||
"status": "LOGISTICS_CONFIRMED",
|
||||
"routes": [{
|
||||
"from": "Склад поставщика",
|
||||
"fromAddress": "ул. Садовая, 1",
|
||||
"to": "Фулфилмент центр",
|
||||
"toAddress": "ул. Складская, 10",
|
||||
"packagesCount": 2, // ✅ Видит количество мест
|
||||
"volume": 0.5 // ✅ Видит объем
|
||||
}],
|
||||
"logisticsPrice": 2000, // ✅ Видит только свою стоимость
|
||||
// ❌ НЕ видит товары, цены, рецептуру, участников
|
||||
}
|
||||
```
|
||||
|
||||
## ⚠️ КРИТИЧЕСКИЕ ПРАВИЛА БЕЗОПАСНОСТИ
|
||||
|
||||
### 1. НИКОГДА НЕ ПОКАЗЫВАТЬ:
|
||||
|
||||
- **Фулфилменту** - закупочные цены поставщика (`productPrice`)
|
||||
- **Поставщику** - рецептуру и услуги фулфилмента
|
||||
- **Логистике** - коммерческую информацию и рецептуру
|
||||
- **Селлерам** - данные других селлеров
|
||||
|
||||
### 2. ВСЕГДА ПРОВЕРЯТЬ:
|
||||
|
||||
- Партнерские отношения перед доступом к данным
|
||||
- Принадлежность заказа текущей организации
|
||||
- Роль пользователя перед фильтрацией данных
|
||||
- Подозрительную активность в логах
|
||||
|
||||
### 3. ОБЯЗАТЕЛЬНО ЛОГИРОВАТЬ:
|
||||
|
||||
- Все обращения к коммерческим данным
|
||||
- Попытки несанкционированного доступа
|
||||
- Массовые запросы данных
|
||||
- Изменения критических полей
|
||||
|
||||
## 🛠️ IMPLEMENTATION CHECKLIST
|
||||
|
||||
- [ ] Реализовать `SupplyDataFilter` класс для фильтрации по ролям
|
||||
- [ ] Добавить `ParticipantIsolation` для изоляции участников
|
||||
- [ ] Внедрить `RecipeAccessControl` для контроля рецептур
|
||||
- [ ] Настроить `CommercialDataAudit` для аудита
|
||||
- [ ] Обновить GraphQL резолверы с новыми фильтрами
|
||||
- [ ] Добавить тесты безопасности для каждой роли
|
||||
- [ ] Настроить мониторинг и алерты
|
||||
- [ ] Провести security review кода
|
||||
|
||||
## 📚 СВЯЗАННЫЕ ДОКУМЕНТЫ
|
||||
|
||||
- [SECURITY_PRACTICES.md](../infrastructure/SECURITY_PRACTICES.md) - Общие практики безопасности
|
||||
- [SUPPLY_CHAIN_WORKFLOW.md](./SUPPLY_CHAIN_WORKFLOW.md) - Workflow поставок
|
||||
- [GRAPHQL_SCHEMA_RULES.md](../api-layer/GRAPHQL_SCHEMA_RULES.md) - Правила GraphQL API
|
||||
|
||||
---
|
||||
|
||||
_Дата создания: 2025-08-22_
|
||||
_Автор: Claude (Anthropic)_
|
||||
_Критически важный документ для безопасности коммерческих данных_
|
842
docs/development/SUPPLY_DATA_SECURITY_IMPLEMENTATION_PLAN.md
Normal file
842
docs/development/SUPPLY_DATA_SECURITY_IMPLEMENTATION_PLAN.md
Normal file
@ -0,0 +1,842 @@
|
||||
# ПЛАН РЕАЛИЗАЦИИ БЕЗОПАСНОСТИ ДАННЫХ В ПОСТАВКАХ
|
||||
|
||||
## 🎯 ОБЗОР ПЛАНА
|
||||
|
||||
План поэтапной реализации системы безопасности данных в поставках с минимальными рисками для существующей функциональности.
|
||||
|
||||
### КЛЮЧЕВЫЕ ПРИНЦИПЫ РЕАЛИЗАЦИИ:
|
||||
|
||||
1. **Постепенное внедрение** - каждая фаза независима и тестируема
|
||||
2. **Обратная совместимость** - не ломаем существующий функционал
|
||||
3. **Мониторинг на каждом этапе** - отслеживаем влияние изменений
|
||||
4. **Откат при проблемах** - возможность быстро вернуться к предыдущей версии
|
||||
|
||||
## 📅 TIMELINE И ПРИОРИТЕТЫ
|
||||
|
||||
| Фаза | Название | Длительность | Приоритет | Риски |
|
||||
| --------- | --------------------------- | ------------- | -------------- | ------- |
|
||||
| **1** | Подготовка инфраструктуры | 2-3 дня | 🔴 Критический | Низкие |
|
||||
| **2** | Базовые классы безопасности | 3-4 дня | 🔴 Критический | Низкие |
|
||||
| **3** | Обновление резолверов | 5-7 дней | 🔴 Критический | Средние |
|
||||
| **4** | Система аудита | 2-3 дня | 🟡 Высокий | Низкие |
|
||||
| **5** | Тестирование | 3-4 дня | 🟡 Высокий | Низкие |
|
||||
| **6** | Оптимизация | 2-3 дня | 🟢 Средний | Низкие |
|
||||
| **ИТОГО** | | **17-24 дня** | | |
|
||||
|
||||
## 🛠️ ФАЗА 1: ПОДГОТОВКА ИНФРАСТРУКТУРЫ (2-3 дня)
|
||||
|
||||
### Цель:
|
||||
|
||||
Подготовить кодовую базу для внедрения безопасности без нарушения работы системы.
|
||||
|
||||
### Задачи:
|
||||
|
||||
#### 1.1 Создание структуры директорий
|
||||
|
||||
```bash
|
||||
src/
|
||||
├── graphql/
|
||||
│ ├── security/ # Новая папка для безопасности
|
||||
│ │ ├── index.ts # Экспорт всех модулей
|
||||
│ │ ├── supply-data-filter.ts
|
||||
│ │ ├── participant-isolation.ts
|
||||
│ │ ├── recipe-access-control.ts
|
||||
│ │ ├── commercial-data-audit.ts
|
||||
│ │ └── types.ts # Типы для безопасности
|
||||
│ └── resolvers/
|
||||
│ └── supply-orders/ # Рефакторинг резолверов
|
||||
│ ├── queries.ts
|
||||
│ ├── mutations.ts
|
||||
│ └── helpers.ts
|
||||
```
|
||||
|
||||
#### 1.2 Создание feature flag для постепенного внедрения
|
||||
|
||||
```typescript
|
||||
// src/config/features.ts
|
||||
export const FEATURE_FLAGS = {
|
||||
SUPPLY_DATA_SECURITY: {
|
||||
enabled: process.env.ENABLE_SUPPLY_SECURITY === 'true',
|
||||
auditEnabled: process.env.ENABLE_SECURITY_AUDIT === 'true',
|
||||
strictMode: process.env.SECURITY_STRICT_MODE === 'true',
|
||||
},
|
||||
}
|
||||
|
||||
// Использование в коде
|
||||
if (FEATURE_FLAGS.SUPPLY_DATA_SECURITY.enabled) {
|
||||
// Новая логика безопасности
|
||||
return SupplyDataFilter.filterByRole(data, userRole)
|
||||
} else {
|
||||
// Старая логика
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 Настройка логирования для отладки
|
||||
|
||||
```typescript
|
||||
// src/lib/security-logger.ts
|
||||
export class SecurityLogger {
|
||||
private static readonly DEBUG = process.env.SECURITY_DEBUG === 'true'
|
||||
|
||||
static logDataAccess(params: { userId: string; action: string; resource: string; filtered: boolean }) {
|
||||
if (this.DEBUG) {
|
||||
console.log('[SECURITY]', {
|
||||
...params,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4 Создание миграции БД для аудита (без применения)
|
||||
|
||||
```sql
|
||||
-- prisma/migrations/add_audit_log_table.sql
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"organizationType" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"resourceType" TEXT NOT NULL,
|
||||
"resourceId" TEXT,
|
||||
"metadata" JSONB DEFAULT '{}',
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
|
||||
CREATE INDEX "AuditLog_timestamp_idx" ON "AuditLog"("timestamp");
|
||||
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
|
||||
```
|
||||
|
||||
### Результаты фазы 1:
|
||||
|
||||
- ✅ Структура готова для новых классов
|
||||
- ✅ Feature flags позволяют безопасное тестирование
|
||||
- ✅ Логирование настроено для отладки
|
||||
- ✅ Миграция БД подготовлена
|
||||
|
||||
## 🔐 ФАЗА 2: БАЗОВЫЕ КЛАССЫ БЕЗОПАСНОСТИ (3-4 дня)
|
||||
|
||||
### Цель:
|
||||
|
||||
Реализовать основные классы фильтрации данных с полным покрытием тестами.
|
||||
|
||||
### Задачи:
|
||||
|
||||
#### 2.1 Создание типов безопасности
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/types.ts
|
||||
export interface SecurityContext {
|
||||
user: {
|
||||
id: string
|
||||
organizationId: string
|
||||
organizationType: OrganizationType
|
||||
}
|
||||
ipAddress?: string
|
||||
userAgent?: string
|
||||
}
|
||||
|
||||
export interface FilteredData<T> {
|
||||
data: T
|
||||
filtered: boolean
|
||||
removedFields: string[]
|
||||
}
|
||||
|
||||
export type DataAccessLevel = 'FULL' | 'PARTIAL' | 'NONE'
|
||||
```
|
||||
|
||||
#### 2.2 Реализация SupplyDataFilter
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/supply-data-filter.ts
|
||||
export class SupplyDataFilter {
|
||||
// Статические методы для фильтрации
|
||||
static filterSupplyOrder(order: SupplyOrder, context: SecurityContext): FilteredData<Partial<SupplyOrder>> {
|
||||
const { organizationType, organizationId } = context.user
|
||||
|
||||
// Логика фильтрации по ролям
|
||||
switch (organizationType) {
|
||||
case 'SELLER':
|
||||
return this.filterForSeller(order, organizationId)
|
||||
case 'WHOLESALE':
|
||||
return this.filterForWholesale(order, organizationId)
|
||||
case 'FULFILLMENT':
|
||||
return this.filterForFulfillment(order, organizationId)
|
||||
case 'LOGIST':
|
||||
return this.filterForLogist(order, organizationId)
|
||||
default:
|
||||
throw new GraphQLError('Unauthorized organization type')
|
||||
}
|
||||
}
|
||||
|
||||
// Приватные методы для каждой роли
|
||||
private static filterForSeller(/*...*/) {
|
||||
/*...*/
|
||||
}
|
||||
private static filterForWholesale(/*...*/) {
|
||||
/*...*/
|
||||
}
|
||||
private static filterForFulfillment(/*...*/) {
|
||||
/*...*/
|
||||
}
|
||||
private static filterForLogist(/*...*/) {
|
||||
/*...*/
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 Реализация ParticipantIsolation
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/participant-isolation.ts
|
||||
export class ParticipantIsolation {
|
||||
static async checkAccess(
|
||||
prisma: PrismaClient,
|
||||
context: SecurityContext,
|
||||
resourceId: string,
|
||||
resourceType: 'SUPPLY_ORDER' | 'PRODUCT' | 'SERVICE',
|
||||
): Promise<boolean> {
|
||||
// Проверка доступа к ресурсу
|
||||
const hasAccess = await this.validateResourceAccess(prisma, context, resourceId, resourceType)
|
||||
|
||||
if (!hasAccess) {
|
||||
// Логируем попытку несанкционированного доступа
|
||||
await CommercialDataAudit.logUnauthorizedAccess({
|
||||
...context,
|
||||
resourceId,
|
||||
resourceType,
|
||||
})
|
||||
}
|
||||
|
||||
return hasAccess
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.4 Создание тестов для классов
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/__tests__/supply-data-filter.test.ts
|
||||
describe('SupplyDataFilter', () => {
|
||||
describe('filterForFulfillment', () => {
|
||||
it('should hide product prices from fulfillment', () => {
|
||||
const order = createMockSupplyOrder()
|
||||
const context = createMockContext('FULFILLMENT')
|
||||
|
||||
const filtered = SupplyDataFilter.filterSupplyOrder(order, context)
|
||||
|
||||
expect(filtered.data.items[0].price).toBeNull()
|
||||
expect(filtered.data.productPrice).toBeNull()
|
||||
expect(filtered.removedFields).toContain('productPrice')
|
||||
})
|
||||
|
||||
it('should show recipe to fulfillment', () => {
|
||||
const order = createMockSupplyOrder()
|
||||
const context = createMockContext('FULFILLMENT')
|
||||
|
||||
const filtered = SupplyDataFilter.filterSupplyOrder(order, context)
|
||||
|
||||
expect(filtered.data.items[0].recipe).toBeDefined()
|
||||
expect(filtered.data.items[0].recipe.services).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Результаты фазы 2:
|
||||
|
||||
- ✅ Базовые классы реализованы
|
||||
- ✅ 100% покрытие тестами
|
||||
- ✅ Готовы к интеграции в резолверы
|
||||
|
||||
## 🔄 ФАЗА 3: ОБНОВЛЕНИЕ РЕЗОЛВЕРОВ (5-7 дней)
|
||||
|
||||
### Цель:
|
||||
|
||||
Интегрировать классы безопасности в существующие GraphQL резолверы с минимальным риском.
|
||||
|
||||
### Задачи:
|
||||
|
||||
#### 3.1 Создание обертки для безопасных резолверов
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/secure-resolver.ts
|
||||
export function createSecureResolver<TArgs, TResult>(
|
||||
resolver: (parent: any, args: TArgs, context: Context) => Promise<TResult>,
|
||||
options: {
|
||||
resourceType: string
|
||||
requiredRole?: OrganizationType[]
|
||||
auditAction: string
|
||||
},
|
||||
) {
|
||||
return async (parent: any, args: TArgs, context: Context): Promise<TResult> => {
|
||||
// Проверка аутентификации
|
||||
if (!context.user) {
|
||||
throw new GraphQLError('Authentication required')
|
||||
}
|
||||
|
||||
// Проверка роли если требуется
|
||||
if (options.requiredRole && !options.requiredRole.includes(context.user.organizationType)) {
|
||||
throw new GraphQLError('Insufficient permissions')
|
||||
}
|
||||
|
||||
// Логирование доступа
|
||||
if (FEATURE_FLAGS.SUPPLY_DATA_SECURITY.auditEnabled) {
|
||||
await CommercialDataAudit.logAccess({
|
||||
userId: context.user.id,
|
||||
organizationType: context.user.organizationType,
|
||||
action: options.auditAction,
|
||||
resourceType: options.resourceType,
|
||||
metadata: { args },
|
||||
})
|
||||
}
|
||||
|
||||
// Выполнение оригинального резолвера
|
||||
const result = await resolver(parent, args, context)
|
||||
|
||||
// Фильтрация результата если включена безопасность
|
||||
if (FEATURE_FLAGS.SUPPLY_DATA_SECURITY.enabled) {
|
||||
return filterResultByRole(result, context)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Постепенное обновление резолверов
|
||||
|
||||
```typescript
|
||||
// src/graphql/resolvers/supply-orders/queries.ts
|
||||
export const supplyOrderQueries = {
|
||||
// Старый резолвер
|
||||
mySupplyOrders_OLD: async (parent, args, context) => {
|
||||
// Существующая логика
|
||||
},
|
||||
|
||||
// Новый безопасный резолвер
|
||||
mySupplyOrders: createSecureResolver(
|
||||
async (parent, args, context) => {
|
||||
// Получаем данные
|
||||
const orders = await prisma.supplyOrder.findMany({
|
||||
where: buildWhereClause(context.user, args),
|
||||
include: fullInclude,
|
||||
})
|
||||
|
||||
// Фильтруем если безопасность включена
|
||||
if (FEATURE_FLAGS.SUPPLY_DATA_SECURITY.enabled) {
|
||||
return orders.map((order) => SupplyDataFilter.filterSupplyOrder(order, context).data)
|
||||
}
|
||||
|
||||
return orders
|
||||
},
|
||||
{
|
||||
resourceType: 'SUPPLY_ORDER',
|
||||
auditAction: 'VIEW_SUPPLY_ORDERS',
|
||||
},
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 A/B тестирование с метриками
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/metrics.ts
|
||||
export class SecurityMetrics {
|
||||
static async compareResults(oldResult: any, newResult: any, context: SecurityContext) {
|
||||
const differences = this.findDifferences(oldResult, newResult)
|
||||
|
||||
if (differences.length > 0) {
|
||||
await this.logDifferences({
|
||||
userId: context.user.id,
|
||||
organizationType: context.user.organizationType,
|
||||
differences,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
// Отправка метрик в мониторинг
|
||||
metrics.increment('security.filter.applied', {
|
||||
organizationType: context.user.organizationType,
|
||||
hasDifferences: differences.length > 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4 Поэтапная миграция резолверов
|
||||
|
||||
```typescript
|
||||
// План миграции резолверов
|
||||
const MIGRATION_PLAN = [
|
||||
// Неделя 1: Читающие запросы
|
||||
{ resolver: 'mySupplyOrders', risk: 'LOW', rollout: '10%' },
|
||||
{ resolver: 'supplyOrder', risk: 'LOW', rollout: '25%' },
|
||||
{ resolver: 'searchSupplies', risk: 'MEDIUM', rollout: '10%' },
|
||||
|
||||
// Неделя 2: Мутации
|
||||
{ resolver: 'createSupplyOrder', risk: 'HIGH', rollout: '5%' },
|
||||
{ resolver: 'updateSupplyOrderStatus', risk: 'HIGH', rollout: '5%' },
|
||||
|
||||
// Неделя 3: Полный rollout
|
||||
{ resolver: '*', risk: 'MEDIUM', rollout: '100%' },
|
||||
]
|
||||
```
|
||||
|
||||
### Результаты фазы 3:
|
||||
|
||||
- ✅ Резолверы обновлены с feature flags
|
||||
- ✅ A/B тестирование настроено
|
||||
- ✅ Метрики собираются для анализа
|
||||
|
||||
## 📊 ФАЗА 4: СИСТЕМА АУДИТА (2-3 дня)
|
||||
|
||||
### Цель:
|
||||
|
||||
Реализовать полноценную систему аудита доступа к коммерческим данным.
|
||||
|
||||
### Задачи:
|
||||
|
||||
#### 4.1 Применение миграции БД
|
||||
|
||||
```bash
|
||||
# Применяем подготовленную миграцию
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Обновляем Prisma Client
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
#### 4.2 Реализация CommercialDataAudit
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/commercial-data-audit.ts
|
||||
export class CommercialDataAudit {
|
||||
private static readonly ALERT_THRESHOLDS = {
|
||||
VIEW_PRICE: { perHour: 100, perDay: 500 },
|
||||
VIEW_RECIPE: { perHour: 50, perDay: 200 },
|
||||
BULK_EXPORT: { perHour: 5, perDay: 20 },
|
||||
}
|
||||
|
||||
static async logAccess(params: AuditParams): Promise<void> {
|
||||
// Сохраняем в БД
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: params.userId,
|
||||
organizationType: params.organizationType,
|
||||
action: params.action,
|
||||
resourceType: params.resourceType,
|
||||
resourceId: params.resourceId,
|
||||
metadata: params.metadata || {},
|
||||
ipAddress: params.ipAddress,
|
||||
userAgent: params.userAgent,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Проверяем на подозрительную активность
|
||||
await this.checkSuspiciousActivity(params)
|
||||
}
|
||||
|
||||
private static async checkSuspiciousActivity(params: AuditParams) {
|
||||
const threshold = this.ALERT_THRESHOLDS[params.action]
|
||||
if (!threshold) return
|
||||
|
||||
// Считаем активность за последний час
|
||||
const hourlyCount = await this.getActivityCount(
|
||||
params.userId,
|
||||
params.action,
|
||||
60 * 60 * 1000, // 1 час
|
||||
)
|
||||
|
||||
if (hourlyCount > threshold.perHour) {
|
||||
await this.sendAlert({
|
||||
type: 'EXCESSIVE_ACCESS',
|
||||
severity: 'HIGH',
|
||||
userId: params.userId,
|
||||
action: params.action,
|
||||
count: hourlyCount,
|
||||
threshold: threshold.perHour,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3 Dashboard для мониторинга
|
||||
|
||||
```typescript
|
||||
// src/pages/admin/security-audit.tsx
|
||||
export function SecurityAuditDashboard() {
|
||||
const [alerts, setAlerts] = useState<SecurityAlert[]>([])
|
||||
const [metrics, setMetrics] = useState<SecurityMetrics>()
|
||||
|
||||
// Real-time подписка на алерты
|
||||
useEffect(() => {
|
||||
const subscription = subscribeToSecurityAlerts((alert) => {
|
||||
setAlerts(prev => [alert, ...prev])
|
||||
|
||||
// Показываем критичные алерты
|
||||
if (alert.severity === 'HIGH') {
|
||||
toast.error(`Security Alert: ${alert.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="security-dashboard">
|
||||
<AlertsList alerts={alerts} />
|
||||
<AccessMetrics metrics={metrics} />
|
||||
<SuspiciousActivityLog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Результаты фазы 4:
|
||||
|
||||
- ✅ Аудит логирует все обращения
|
||||
- ✅ Алерты работают в real-time
|
||||
- ✅ Dashboard для мониторинга
|
||||
|
||||
## ✅ ФАЗА 5: ТЕСТИРОВАНИЕ (3-4 дня)
|
||||
|
||||
### Цель:
|
||||
|
||||
Обеспечить полное покрытие тестами и проверить все сценарии безопасности.
|
||||
|
||||
### Задачи:
|
||||
|
||||
#### 5.1 Unit тесты для каждой роли
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/__tests__/role-based-filtering.test.ts
|
||||
describe('Role-based filtering', () => {
|
||||
const testCases = [
|
||||
{
|
||||
role: 'SELLER',
|
||||
canSee: ['productPrice', 'recipe', 'totalAmount'],
|
||||
cannotSee: [],
|
||||
},
|
||||
{
|
||||
role: 'WHOLESALE',
|
||||
canSee: ['productPrice', 'packagesCount'],
|
||||
cannotSee: ['recipe', 'fulfillmentServicePrice'],
|
||||
},
|
||||
{
|
||||
role: 'FULFILLMENT',
|
||||
canSee: ['recipe', 'fulfillmentServicePrice'],
|
||||
cannotSee: ['productPrice'],
|
||||
},
|
||||
{
|
||||
role: 'LOGIST',
|
||||
canSee: ['logisticsPrice', 'routes'],
|
||||
cannotSee: ['productPrice', 'recipe', 'items'],
|
||||
},
|
||||
]
|
||||
|
||||
testCases.forEach(({ role, canSee, cannotSee }) => {
|
||||
describe(`${role} role`, () => {
|
||||
canSee.forEach((field) => {
|
||||
it(`should see ${field}`, async () => {
|
||||
const result = await testQuery(role, SUPPLY_ORDER_QUERY)
|
||||
expect(result.data.supplyOrder[field]).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
cannotSee.forEach((field) => {
|
||||
it(`should NOT see ${field}`, async () => {
|
||||
const result = await testQuery(role, SUPPLY_ORDER_QUERY)
|
||||
expect(result.data.supplyOrder[field]).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### 5.2 Integration тесты
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/__tests__/integration.test.ts
|
||||
describe('Supply chain security integration', () => {
|
||||
it('should isolate data between competitors', async () => {
|
||||
// Создаем двух селлеров-конкурентов
|
||||
const seller1 = await createTestSeller()
|
||||
const seller2 = await createTestSeller()
|
||||
|
||||
// Seller1 создает поставку
|
||||
const supply1 = await createSupplyOrder(seller1, {
|
||||
productPrice: 1000,
|
||||
recipe: { services: ['Packing'] },
|
||||
})
|
||||
|
||||
// Seller2 пытается получить доступ
|
||||
const result = await querySupplyOrder(seller2, supply1.id)
|
||||
|
||||
expect(result.errors[0].message).toBe('Access denied')
|
||||
})
|
||||
|
||||
it('should allow partners to see limited data', async () => {
|
||||
const seller = await createTestSeller()
|
||||
const wholesale = await createTestWholesale()
|
||||
const fulfillment = await createTestFulfillment()
|
||||
|
||||
// Создаем партнерства
|
||||
await createPartnership(seller, wholesale)
|
||||
await createPartnership(seller, fulfillment)
|
||||
|
||||
// Создаем поставку
|
||||
const supply = await createSupplyOrder(seller, {
|
||||
partnerId: wholesale.id,
|
||||
fulfillmentCenterId: fulfillment.id,
|
||||
productPrice: 1000,
|
||||
recipe: { services: ['Packing'] },
|
||||
})
|
||||
|
||||
// Поставщик видит свою часть
|
||||
const wholesaleView = await querySupplyOrder(wholesale, supply.id)
|
||||
expect(wholesaleView.data.productPrice).toBe(1000)
|
||||
expect(wholesaleView.data.recipe).toBeNull()
|
||||
|
||||
// Фулфилмент видит свою часть
|
||||
const fulfillmentView = await querySupplyOrder(fulfillment, supply.id)
|
||||
expect(fulfillmentView.data.productPrice).toBeNull()
|
||||
expect(fulfillmentView.data.recipe).toBeDefined()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### 5.3 Performance тесты
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/__tests__/performance.test.ts
|
||||
describe('Security performance', () => {
|
||||
it('should not significantly impact query performance', async () => {
|
||||
const iterations = 100
|
||||
|
||||
// Тест без фильтрации
|
||||
const withoutSecurity = await measurePerformance(async () => {
|
||||
await queryWithoutSecurity(COMPLEX_SUPPLY_QUERY)
|
||||
}, iterations)
|
||||
|
||||
// Тест с фильтрацией
|
||||
const withSecurity = await measurePerformance(async () => {
|
||||
await queryWithSecurity(COMPLEX_SUPPLY_QUERY)
|
||||
}, iterations)
|
||||
|
||||
const overhead = (withSecurity.avg - withoutSecurity.avg) / withoutSecurity.avg
|
||||
|
||||
// Допустимый overhead - 15%
|
||||
expect(overhead).toBeLessThan(0.15)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Результаты фазы 5:
|
||||
|
||||
- ✅ Полное покрытие unit тестами
|
||||
- ✅ Integration тесты проверяют изоляцию
|
||||
- ✅ Performance overhead < 15%
|
||||
|
||||
## 🚀 ФАЗА 6: ОПТИМИЗАЦИЯ И ФИНАЛИЗАЦИЯ (2-3 дня)
|
||||
|
||||
### Цель:
|
||||
|
||||
Оптимизировать производительность и подготовить к production.
|
||||
|
||||
### Задачи:
|
||||
|
||||
#### 6.1 Кеширование фильтров
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/cache.ts
|
||||
export class SecurityCache {
|
||||
private static cache = new LRUCache<string, FilteredData>({
|
||||
max: 1000,
|
||||
ttl: 5 * 60 * 1000, // 5 минут
|
||||
})
|
||||
|
||||
static getCacheKey(resourceId: string, userId: string, organizationType: string): string {
|
||||
return `${resourceId}:${userId}:${organizationType}`
|
||||
}
|
||||
|
||||
static get(key: string): FilteredData | undefined {
|
||||
return this.cache.get(key)
|
||||
}
|
||||
|
||||
static set(key: string, data: FilteredData): void {
|
||||
this.cache.set(key, data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.2 Batch фильтрация
|
||||
|
||||
```typescript
|
||||
// src/graphql/security/batch-filter.ts
|
||||
export class BatchFilter {
|
||||
static async filterSupplyOrders(
|
||||
orders: SupplyOrder[],
|
||||
context: SecurityContext,
|
||||
): Promise<FilteredData<SupplyOrder>[]> {
|
||||
// Группируем по типам доступа
|
||||
const grouped = this.groupByAccessLevel(orders, context)
|
||||
|
||||
// Применяем фильтры параллельно
|
||||
const filtered = await Promise.all([
|
||||
this.filterFullAccess(grouped.full, context),
|
||||
this.filterPartialAccess(grouped.partial, context),
|
||||
this.filterNoAccess(grouped.none, context),
|
||||
])
|
||||
|
||||
return filtered.flat()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.3 Документация для разработчиков
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @example Использование безопасных резолверов
|
||||
*
|
||||
* // Для queries
|
||||
* export const mySecureQuery = createSecureResolver(
|
||||
* async (parent, args, context) => {
|
||||
* // Ваша логика
|
||||
* },
|
||||
* {
|
||||
* resourceType: 'SUPPLY_ORDER',
|
||||
* auditAction: 'VIEW_ORDERS'
|
||||
* }
|
||||
* )
|
||||
*
|
||||
* // Для mutations
|
||||
* export const mySecureMutation = createSecureResolver(
|
||||
* async (parent, args, context) => {
|
||||
* // Ваша логика
|
||||
* },
|
||||
* {
|
||||
* resourceType: 'SUPPLY_ORDER',
|
||||
* requiredRole: ['SELLER', 'WHOLESALE'],
|
||||
* auditAction: 'CREATE_ORDER'
|
||||
* }
|
||||
* )
|
||||
*/
|
||||
```
|
||||
|
||||
### Результаты фазы 6:
|
||||
|
||||
- ✅ Performance оптимизирован
|
||||
- ✅ Документация готова
|
||||
- ✅ Готово к production
|
||||
|
||||
## 🔍 МОНИТОРИНГ И МЕТРИКИ
|
||||
|
||||
### Ключевые метрики для отслеживания:
|
||||
|
||||
```typescript
|
||||
interface SecurityMetrics {
|
||||
// Performance метрики
|
||||
filteringOverhead: number // Процент замедления
|
||||
cacheHitRate: number // Эффективность кеша
|
||||
|
||||
// Security метрики
|
||||
unauthorizedAccessAttempts: number // Попытки несанкц. доступа
|
||||
dataLeaksPrevented: number // Предотвращенные утечки
|
||||
|
||||
// Business метрики
|
||||
affectedQueries: number // Количество затронутых запросов
|
||||
userComplaints: number // Жалобы пользователей
|
||||
}
|
||||
```
|
||||
|
||||
### Алерты:
|
||||
|
||||
```yaml
|
||||
alerts:
|
||||
- name: high_unauthorized_access
|
||||
condition: rate(unauthorized_access) > 10/min
|
||||
severity: critical
|
||||
|
||||
- name: performance_degradation
|
||||
condition: filtering_overhead > 25%
|
||||
severity: warning
|
||||
|
||||
- name: audit_log_failure
|
||||
condition: audit_write_errors > 0
|
||||
severity: critical
|
||||
```
|
||||
|
||||
## ✅ КОНТРОЛЬНЫЙ СПИСОК ГОТОВНОСТИ
|
||||
|
||||
### Перед каждой фазой:
|
||||
|
||||
- [ ] Feature flag настроен и протестирован
|
||||
- [ ] Rollback план готов
|
||||
- [ ] Метрики и логирование настроены
|
||||
- [ ] Команда проинформирована
|
||||
|
||||
### Перед production:
|
||||
|
||||
- [ ] Все тесты проходят (unit, integration, e2e)
|
||||
- [ ] Performance overhead < 15%
|
||||
- [ ] Security review пройден
|
||||
- [ ] Документация обновлена
|
||||
- [ ] Мониторинг настроен
|
||||
- [ ] Support команда обучена
|
||||
|
||||
### После deployment:
|
||||
|
||||
- [ ] Мониторинг метрик первые 24 часа
|
||||
- [ ] Анализ логов на ошибки
|
||||
- [ ] Feedback от пользователей
|
||||
- [ ] Performance отчет
|
||||
|
||||
## 🚨 ПЛАН ОТКАТА
|
||||
|
||||
### Быстрый откат (< 5 минут):
|
||||
|
||||
```bash
|
||||
# Отключение через environment
|
||||
ENABLE_SUPPLY_SECURITY=false
|
||||
ENABLE_SECURITY_AUDIT=false
|
||||
|
||||
# Перезапуск сервисов
|
||||
kubectl rollout restart deployment/api-server
|
||||
```
|
||||
|
||||
### Полный откат (< 30 минут):
|
||||
|
||||
```bash
|
||||
# Откат к предыдущей версии
|
||||
kubectl rollout undo deployment/api-server
|
||||
|
||||
# Откат миграции БД если нужно
|
||||
npx prisma migrate resolve --rolled-back
|
||||
```
|
||||
|
||||
## 📈 КРИТЕРИИ УСПЕХА
|
||||
|
||||
1. **Безопасность**: 0 утечек коммерческих данных
|
||||
2. **Performance**: Overhead < 15%
|
||||
3. **Стабильность**: 0 критических инцидентов
|
||||
4. **UX**: 0 жалоб на недоступность данных
|
||||
5. **Аудит**: 100% логирование критических операций
|
||||
|
||||
---
|
||||
|
||||
_План разработан с учетом минимизации рисков и постепенного внедрения_
|
||||
_Дата: 2025-08-22_
|
||||
_Estimated effort: 17-24 дня_
|
||||
_Risk level: MEDIUM с правильным подходом_
|
@ -465,8 +465,262 @@ interface WholesaleWorkflowUI {
|
||||
|
||||
> Поставщик **НЕ МОЖЕТ** изменять статусы заказов напрямую, только через бизнес-процессы
|
||||
|
||||
## 💻 ТЕХНИЧЕСКИЕ КОМПОНЕНТЫ КАБИНЕТА
|
||||
|
||||
### АРХИТЕКТУРА КОМПОНЕНТОВ:
|
||||
|
||||
```typescript
|
||||
src/components/
|
||||
├── warehouse/ # Компоненты склада поставщика
|
||||
│ ├── warehouse-dashboard.tsx # Главный dashboard склада
|
||||
│ ├── product-card.tsx # Карточка товара
|
||||
│ ├── product-form.tsx # Форма создания/редактирования товара
|
||||
│ └── warehouse-statistics.tsx # Статистика склада
|
||||
├── supplier-orders/ # Компоненты обработки заказов
|
||||
│ ├── supplier-orders-dashboard.tsx # Главный dashboard заказов
|
||||
│ ├── supplier-order-card.tsx # Карточка заказа
|
||||
│ ├── supplier-orders-tabs.tsx # Табы по статусам заказов
|
||||
│ ├── supplier-orders-search.tsx # Поиск и фильтры
|
||||
│ └── supplier-order-stats.tsx # Статистика заказов
|
||||
└── economics/ # Экономическая аналитика
|
||||
└── wholesale-economics-page.tsx # Финансовая отчетность
|
||||
```
|
||||
|
||||
### СТРАНИЦЫ (NEXT.JS ROUTES):
|
||||
|
||||
```typescript
|
||||
src/app/
|
||||
├── warehouse/
|
||||
│ └── page.tsx # /warehouse - управление складом
|
||||
├── supplier-orders/
|
||||
│ └── page.tsx # /supplier-orders - обработка заказов
|
||||
└── economics/
|
||||
└── page.tsx # /economics - финансовая аналитика
|
||||
```
|
||||
|
||||
## 🛠️ GRAPHQL API ПОСТАВЩИКОВ
|
||||
|
||||
### ОСНОВНЫЕ ЗАПРОСЫ:
|
||||
|
||||
```graphql
|
||||
# Получение товаров поставщика
|
||||
query GetMyProducts {
|
||||
myProducts {
|
||||
id
|
||||
name
|
||||
article
|
||||
price
|
||||
quantity
|
||||
organization {
|
||||
id
|
||||
name
|
||||
market # Физический рынок поставщика
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Получение заказов поставщика
|
||||
query GetSupplierOrders {
|
||||
supplyOrders(where: { partnerId: $myOrgId }) {
|
||||
id
|
||||
status
|
||||
totalAmount
|
||||
organization {
|
||||
name
|
||||
} # Заказчик
|
||||
}
|
||||
}
|
||||
|
||||
# Получение партнеров
|
||||
query GetMyCounterparties {
|
||||
myCounterparties {
|
||||
id
|
||||
name
|
||||
type
|
||||
market
|
||||
fullName
|
||||
inn
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### МУТАЦИИ ОБРАБОТКИ ЗАКАЗОВ:
|
||||
|
||||
```graphql
|
||||
# Одобрение заказа поставщиком
|
||||
mutation SupplierApproveOrder($orderId: ID!) {
|
||||
supplierApproveOrder(id: $orderId) {
|
||||
success
|
||||
order {
|
||||
id
|
||||
status
|
||||
organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Отклонение заказа
|
||||
mutation SupplierRejectOrder($orderId: ID!, $reason: String) {
|
||||
supplierRejectOrder(id: $orderId, reason: $reason) {
|
||||
success
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
# Отгрузка заказа
|
||||
mutation SupplierShipOrder($orderId: ID!) {
|
||||
supplierShipOrder(id: $orderId) {
|
||||
success
|
||||
order {
|
||||
id
|
||||
status # SHIPPED -> IN_TRANSIT
|
||||
organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Создание товара
|
||||
mutation CreateProduct($input: ProductInput!) {
|
||||
createProduct(input: $input) {
|
||||
success
|
||||
product {
|
||||
id
|
||||
article
|
||||
organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ПРАВИЛА ПАРТНЕРСТВА В API:
|
||||
|
||||
```typescript
|
||||
// ✅ ПРАВИЛЬНО: Поставщики берутся ТОЛЬКО из партнеров
|
||||
const suppliers = await useQuery(GET_MY_COUNTERPARTIES, {
|
||||
variables: { type: 'WHOLESALE' },
|
||||
})
|
||||
|
||||
// ❌ НЕПРАВИЛЬНО: Прямой запрос поставщиков
|
||||
const suppliers = await useQuery(GET_SUPPLY_SUPPLIERS)
|
||||
```
|
||||
|
||||
## 🔐 ТЕХНИЧЕСКИЕ ПРАВИЛА БЕЗОПАСНОСТИ
|
||||
|
||||
### КОНТРОЛЬ ДОСТУПА НА УРОВНЕ КОМПОНЕНТОВ:
|
||||
|
||||
```typescript
|
||||
// Проверка типа организации в UI
|
||||
{user?.organization?.type === "WHOLESALE" && (
|
||||
<WarehouseDashboard />
|
||||
)}
|
||||
|
||||
// Условный рендеринг функций поставщика
|
||||
{user?.organization?.type === 'WHOLESALE' ? (
|
||||
<SupplierOrdersTabs />
|
||||
) : (
|
||||
<AccessDenied />
|
||||
)}
|
||||
```
|
||||
|
||||
### ПРОВЕРКИ В GRAPHQL РЕЗОЛВЕРАХ:
|
||||
|
||||
```typescript
|
||||
// Проверка что пользователь - поставщик
|
||||
if (context.user.organization.type !== 'WHOLESALE') {
|
||||
throw new Error('Access denied: Wholesale access required')
|
||||
}
|
||||
|
||||
// Проверка доступа к своим товарам
|
||||
const product = await prisma.product.findFirst({
|
||||
where: {
|
||||
id: productId,
|
||||
organizationId: context.user.organizationId,
|
||||
},
|
||||
})
|
||||
|
||||
// Фильтрация заказов для поставщика
|
||||
let whereClause
|
||||
if (currentUser.organization.type === 'WHOLESALE') {
|
||||
// Поставщик видит заказы, где он является поставщиком
|
||||
whereClause = { partnerId: currentUser.organization.id }
|
||||
} else {
|
||||
// Остальные видят заказы, которые они создали
|
||||
whereClause = { organizationId: currentUser.organization.id }
|
||||
}
|
||||
```
|
||||
|
||||
### ОБЯЗАТЕЛЬНЫЕ ВАЛИДАЦИИ:
|
||||
|
||||
```typescript
|
||||
const wholesaleValidations = {
|
||||
// Основные проверки
|
||||
organizationType: 'organization.type === "WHOLESALE"',
|
||||
accessControl: 'GraphQL resolver level validation',
|
||||
inventoryControl: 'Stock availability before order confirmation',
|
||||
|
||||
// Запрещенные действия
|
||||
forbidden: [
|
||||
'Создание товаров с типами DEFECT или FINISHED_PRODUCT',
|
||||
'Изменение статусов заказов минуя workflow',
|
||||
'Показ данных других поставщиков',
|
||||
'Прямое изменение статусов без бизнес-процессов',
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## 🚨 КРИТИЧЕСКИЕ ПРОБЛЕМЫ И РЕШЕНИЯ
|
||||
|
||||
### ВЫЯВЛЕННЫЕ ПРОБЛЕМЫ В WORKFLOW:
|
||||
|
||||
```typescript
|
||||
// ❌ ПРОБЛЕМА: Отображается статус вместо только кнопок действий
|
||||
// Поставщик видит "ожидает подтверждения" вместо чистых кнопок
|
||||
|
||||
// ❌ ПРОБЛЕМА: Нет валидации минимальных количеств заказа
|
||||
// Отсутствует проверка доступности товаров у поставщика
|
||||
|
||||
// ❌ ПРОБЛЕМА: Нет уведомления поставщика о новом заказе
|
||||
// Поставщик не знает о поступивших заказах в реальном времени
|
||||
|
||||
// 🔧 РЕШЕНИЕ: Исправленный код фильтрации заказов
|
||||
const fixedOrderFiltering = `
|
||||
if (currentUser.organization.type === 'WHOLESALE') {
|
||||
whereClause = {
|
||||
partnerId: currentUser.organization.id, // Мы - поставщик
|
||||
}
|
||||
}
|
||||
`
|
||||
```
|
||||
|
||||
### ТРЕБОВАНИЯ К ИСПРАВЛЕНИЯМ:
|
||||
|
||||
```typescript
|
||||
interface WholesaleFixes {
|
||||
ui: {
|
||||
orderButtons: 'Показывать только кнопки действий, скрывать статусы'
|
||||
realTimeNotifications: 'Уведомления о новых заказах'
|
||||
inventoryValidation: 'Проверка остатков перед подтверждением'
|
||||
}
|
||||
|
||||
backend: {
|
||||
minOrderValidation: 'Валидация минимальных количеств'
|
||||
stockAvailability: 'Проверка доступности товаров'
|
||||
notificationSystem: 'Система уведомлений поставщиков'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_Извлечено из анализа: GraphQL resolvers, supply chain workflow, бизнес-логика поставщиков_
|
||||
_Дата создания: 2025-08-21_
|
||||
_Основано на коде: src/graphql/resolvers.ts, supply order management, wholesale patterns_
|
||||
_Дополнено техническими деталями из: legacy-rules/wholesale-cabinet-rules.md, правила создания поставки товаров.md_
|
||||
_Дата обновления: 2025-08-22_
|
||||
_Основано на коде: src/components/supplier-orders/, src/graphql/resolvers.ts, supply order management_
|
||||
|
@ -755,6 +755,265 @@ const componentVariants = cva(
|
||||
{loading ? null : <ProductCard data={product} />}
|
||||
```
|
||||
|
||||
## 🏪 КОМПОНЕНТЫ КАБИНЕТА ПОСТАВЩИКА (WHOLESALE)
|
||||
|
||||
### АРХИТЕКТУРА КОМПОНЕНТОВ ПОСТАВЩИКА:
|
||||
|
||||
```typescript
|
||||
src/components/
|
||||
├── warehouse/ # Компоненты склада поставщика
|
||||
│ ├── warehouse-dashboard.tsx # Главный dashboard склада
|
||||
│ ├── product-card.tsx # Карточка товара
|
||||
│ ├── product-form.tsx # Форма создания/редактирования товара
|
||||
│ └── warehouse-statistics.tsx # Статистика склада
|
||||
├── supplier-orders/ # Компоненты обработки заказов
|
||||
│ ├── supplier-orders-dashboard.tsx # Главный dashboard заказов
|
||||
│ ├── supplier-order-card.tsx # Карточка заказа
|
||||
│ ├── supplier-orders-tabs.tsx # Табы по статусам заказов
|
||||
│ ├── supplier-orders-search.tsx # Поиск и фильтры
|
||||
│ └── supplier-order-stats.tsx # Статистика заказов
|
||||
└── economics/ # Экономическая аналитика
|
||||
└── wholesale-economics-page.tsx # Финансовая отчетность
|
||||
```
|
||||
|
||||
### 🏢 КАРТОЧКА ПОСТАВЩИКА В ИНТЕРФЕЙСЕ:
|
||||
|
||||
**Структура карточки:**
|
||||
|
||||
```jsx
|
||||
<div className="supplier-card glass-card">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Аватар организации */}
|
||||
<OrganizationAvatar organization={supplier} size="sm" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Название поставщика */}
|
||||
<h4 className="text-white font-medium text-sm truncate">{supplier.name || supplier.fullName}</h4>
|
||||
|
||||
{/* ИНН и рынок */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-white/60 text-xs font-mono">ИНН: {supplier.inn}</p>
|
||||
{supplier.market && <Badge className="market-badge">{getMarketLabel(supplier.market)}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Визуальные правила карточки поставщика:**
|
||||
|
||||
- **Аватар**: Размер `sm`, позиционирование слева от текста
|
||||
- **Название**: Приоритет `name` над `fullName`, с усечением `truncate`
|
||||
- **ИНН**: Моноширинный шрифт `font-mono`, цвет `text-white/60`
|
||||
- **Рынок**: Badge компонент с индивидуальными цветовыми схемами
|
||||
- **Glass эффект**: `glass-card` класс с полупрозрачным фоном
|
||||
|
||||
### 🔍 ПОИСКОВЫЙ ИНТЕРФЕЙС ПОСТАВЩИКОВ:
|
||||
|
||||
```jsx
|
||||
<Input
|
||||
placeholder="Поиск поставщиков..."
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/50 pl-10 h-9"
|
||||
onChange={(e) => handleSupplierSearch(e.target.value)}
|
||||
/>
|
||||
```
|
||||
|
||||
**Особенности поиска:**
|
||||
|
||||
- Glass эффект: `bg-white/5 border-white/10`
|
||||
- Плейсхолдер: `placeholder:text-white/50`
|
||||
- Левый отступ для иконки: `pl-10`
|
||||
- Высота: `h-9` (36px)
|
||||
|
||||
### 🎨 ЦВЕТОВЫЕ СХЕМЫ РЫНКОВ ПОСТАВЩИКОВ:
|
||||
|
||||
```typescript
|
||||
// Примеры цветовых схем для физических рынков
|
||||
const marketColors = {
|
||||
sadovod: 'bg-green-500/20 text-green-300 border-green-500/30',
|
||||
'tyak-moscow': 'bg-blue-500/20 text-blue-300 border-blue-500/30',
|
||||
default: 'bg-gray-500/20 text-gray-300 border-gray-500/30',
|
||||
}
|
||||
|
||||
// Функция получения метки рынка
|
||||
function getMarketLabel(market: string): string {
|
||||
const labels = {
|
||||
sadovod: 'Садовод',
|
||||
'tyak-moscow': 'ТЯК Москва',
|
||||
default: 'Рынок',
|
||||
}
|
||||
return labels[market] || labels.default
|
||||
}
|
||||
```
|
||||
|
||||
### 📦 БЛОКИ ПОСТАВЩИКОВ В СЕЛЛЕР ИНТЕРФЕЙСЕ:
|
||||
|
||||
**Правила горизонтальной прокрутки:**
|
||||
|
||||
```jsx
|
||||
{
|
||||
/* Контейнер с горизонтальной прокруткой */
|
||||
}
|
||||
;<div className="flex gap-3 overflow-x-auto scrollbar-hide pb-2">
|
||||
{suppliers.map((supplier) => (
|
||||
<div
|
||||
key={supplier.id}
|
||||
className="flex-none w-64" // Фиксированная ширина 256px
|
||||
>
|
||||
<SupplierCard supplier={supplier} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Требования к горизонтальным блокам:**
|
||||
|
||||
- Фиксированная ширина карточек: `w-64` (256px)
|
||||
- Отсутствие сжатия: `flex-none`
|
||||
- Скрытие скроллбара: `scrollbar-hide`
|
||||
- Отступ от низа: `pb-2` для визуального комфорта
|
||||
|
||||
### 🚨 CRITICAL UI RULES ДЛЯ ПОСТАВЩИКОВ:
|
||||
|
||||
#### **1. СТАТУСЫ vs КНОПКИ ДЕЙСТВИЙ:**
|
||||
|
||||
```jsx
|
||||
{
|
||||
/* ❌ НЕПРАВИЛЬНО: Показывать статус поставщику */
|
||||
}
|
||||
{
|
||||
user.organization.type === 'WHOLESALE' && <StatusBadge status={order.status}>Ожидает подтверждения</StatusBadge>
|
||||
}
|
||||
|
||||
{
|
||||
/* ✅ ПРАВИЛЬНО: Только кнопки действий для поставщика */
|
||||
}
|
||||
{
|
||||
user.organization.type === 'WHOLESALE' && order.status === 'PENDING' && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="glass" size="sm" onClick={() => approveOrder(order.id)}>
|
||||
Одобрить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => rejectOrder(order.id)}>
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### **2. ОПЦИОНАЛЬНЫЕ ПОЛЯ УПАКОВКИ ПРИ ОДОБРЕНИИ:**
|
||||
|
||||
```jsx
|
||||
{
|
||||
/* ОПЦИОНАЛЬНЫЕ поля для поставщика - отображаются сразу при одобрении заказа */
|
||||
}
|
||||
;<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="packagesCount">Количество грузовых мест</Label>
|
||||
<Input
|
||||
id="packagesCount"
|
||||
type="number"
|
||||
placeholder="Введите количество (опционально)"
|
||||
aria-describedby="packages-help"
|
||||
/>
|
||||
<p id="packages-help" className="text-xs text-white/60 mt-1">
|
||||
Используется логистикой для расчета тарифов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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 className="col-span-2">
|
||||
<Label htmlFor="readyDate">Дата готовности к отгрузке</Label>
|
||||
<GlassDatePicker
|
||||
id="readyDate"
|
||||
value={readyDate}
|
||||
onChange={setReadyDate}
|
||||
placeholder="Выберите дату (опционально)"
|
||||
aria-describedby="ready-date-help"
|
||||
/>
|
||||
<p id="ready-date-help" className="text-xs text-white/60 mt-1">
|
||||
Когда товары будут готовы к передаче логистике
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<Label htmlFor="notes">Комментарии для логистики</Label>
|
||||
<Textarea id="notes" placeholder="Дополнительная информация (опционально)" aria-describedby="notes-help" />
|
||||
<p id="notes-help" className="text-xs text-white/60 mt-1">
|
||||
Особые требования к транспортировке или упаковке
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* ВАЖНО: Поля показываются на 1-м уровне визуализации поставки */
|
||||
}
|
||||
;<div className="mt-4">
|
||||
<p className="text-sm text-white/80">ℹ️ Все поля опциональны, но рекомендуются для точного планирования логистики</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### **3. ARIA LABELS ДЛЯ КОМПОНЕНТОВ ПОСТАВЩИКА:**
|
||||
|
||||
```jsx
|
||||
// Кнопки действий с описательными ARIA-атрибутами
|
||||
<Button
|
||||
variant="glass"
|
||||
aria-label={`Одобрить заказ №${order.number} от ${order.organization.name}`}
|
||||
onClick={() => approveOrder(order.id)}
|
||||
>
|
||||
Одобрить
|
||||
</Button>
|
||||
|
||||
// Поля ввода с полными описаниями
|
||||
<Input
|
||||
aria-label="Количество грузовых мест для логистического расчета"
|
||||
aria-required="true"
|
||||
aria-describedby="packages-error packages-help"
|
||||
/>
|
||||
```
|
||||
|
||||
#### **4. СПЕЦИАЛЬНЫЕ РАЗМЕРЫ ДЛЯ КАБИНЕТА ПОСТАВЩИКА:**
|
||||
|
||||
```typescript
|
||||
// Размеры карточек в кабинете поставщика
|
||||
const wholesaleSizes = {
|
||||
supplierCard: 'h-[164px] w-64', // 164px высота, 256px ширина
|
||||
orderCard: 'min-h-[120px]', // Минимум 120px для заказов
|
||||
productCard: 'h-[180px]', // 180px для товарных карточек
|
||||
containerWithPadding: 'h-[196px]', // 164 + 32px отступы сверху/снизу
|
||||
}
|
||||
```
|
||||
|
||||
### 📐 ФОРМУЛА РАСЧЕТА РАЗМЕРОВ КОНТЕЙНЕРОВ:
|
||||
|
||||
```typescript
|
||||
// ОБЯЗАТЕЛЬНАЯ формула для всех контейнеров поставщика
|
||||
const containerHeight = {
|
||||
formula: 'Высота контента + padding-top + padding-bottom',
|
||||
example: {
|
||||
content: '164px', // Высота карточки поставщика
|
||||
paddingTop: '16px',
|
||||
paddingBottom: '16px',
|
||||
totalContainer: '196px' // 164 + 16 + 16 = 196px
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ ЗАПРЕЩЕНО: Произвольные размеры без расчета
|
||||
<div className="h-200"> {/* Откуда 200px? */}
|
||||
|
||||
// ✅ ПРАВИЛЬНО: С математическим обоснованием
|
||||
<div className="h-[196px]"> {/* 164px + 32px отступы */}
|
||||
```
|
||||
|
||||
## 📱 АДАПТИВНОСТЬ
|
||||
|
||||
### Responsive Breakpoints:
|
||||
|
Reference in New Issue
Block a user