diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39235a7..4e81562 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -723,6 +723,7 @@ enum SecurityAlertType { DATA_LEAK_RISK SUSPICIOUS_PATTERN BULK_EXPORT_DETECTED + RULE_VIOLATION } enum SecurityAlertSeverity { diff --git a/src/components/supplies/multilevel-supplies-table.tsx b/src/components/supplies/multilevel-supplies-table.tsx index 6eda526..72cf038 100644 --- a/src/components/supplies/multilevel-supplies-table.tsx +++ b/src/components/supplies/multilevel-supplies-table.tsx @@ -1,14 +1,6 @@ 'use client' -import { - Package, - Building2, - MapPin, - Truck, - Clock, - Calendar, - Settings, -} from 'lucide-react' +import { Package, Building2, MapPin, Truck, Clock, Calendar, Settings } from 'lucide-react' import React, { useState } from 'react' import { createPortal } from 'react-dom' @@ -139,19 +131,47 @@ const Table = ({ children, ...props }: { children: React.ReactNode; [key: string ) -const TableHeader = ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => {children} -const TableBody = ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => {children} -const TableRow = ({ children, className, ...props }: { children: React.ReactNode; className?: string; [key: string]: unknown }) => ( +const TableHeader = ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( + {children} +) +const TableBody = ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( + {children} +) +const TableRow = ({ + children, + className, + ...props +}: { + children: React.ReactNode + className?: string + [key: string]: unknown +}) => ( {children} ) -const TableHead = ({ children, className, ...props }: { children: React.ReactNode; className?: string; [key: string]: unknown }) => ( +const TableHead = ({ + children, + className, + ...props +}: { + children: React.ReactNode + className?: string + [key: string]: unknown +}) => ( {children} ) -const TableCell = ({ children, className, ...props }: { children: React.ReactNode; className?: string; [key: string]: unknown }) => ( +const TableCell = ({ + children, + className, + ...props +}: { + children: React.ReactNode + className?: string + [key: string]: unknown +}) => ( {children} @@ -163,19 +183,19 @@ function StatusBadge({ status }: { status: string }) { // ✅ ОБНОВЛЕНО: Новая цветовая схема статусов switch (status.toLowerCase()) { case 'pending': - return 'bg-orange-500/20 text-orange-300 border-orange-500/30' // Ожидает поставщика + return 'bg-orange-500/20 text-orange-300 border-orange-500/30' // Ожидает поставщика case 'supplier_approved': - return 'bg-blue-500/20 text-blue-300 border-blue-500/30' // Одобрена поставщиком + return 'bg-blue-500/20 text-blue-300 border-blue-500/30' // Одобрена поставщиком case 'logistics_confirmed': - return 'bg-purple-500/20 text-purple-300 border-purple-500/30' // Логистика подтверждена + return 'bg-purple-500/20 text-purple-300 border-purple-500/30' // Логистика подтверждена case 'shipped': - return 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30' // Отгружена + return 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30' // Отгружена case 'in_transit': - return 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30' // В пути + return 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30' // В пути case 'delivered': - return 'bg-green-500/20 text-green-300 border-green-500/30' // Доставлена ✅ + return 'bg-green-500/20 text-green-300 border-green-500/30' // Доставлена ✅ case 'cancelled': - return 'bg-red-500/20 text-red-300 border-red-500/30' // Отменена + return 'bg-red-500/20 text-red-300 border-red-500/30' // Отменена default: return 'bg-gray-500/20 text-gray-300 border-gray-500/30' } @@ -184,7 +204,7 @@ function StatusBadge({ status }: { status: string }) { const getStatusText = (status: string) => { switch (status.toLowerCase()) { case 'pending': - return 'Ожидает поставщика' // ✅ ИСПРАВЛЕНО + return 'Ожидает поставщика' // ✅ ИСПРАВЛЕНО case 'supplier_approved': return 'Одобрена поставщиком' case 'logistics_confirmed': @@ -206,12 +226,12 @@ function StatusBadge({ status }: { status: string }) { } // Компонент контекстного меню для отмены поставки -function ContextMenu({ - isOpen, - position, - onClose, - onCancel, -}: { +function ContextMenu({ + isOpen, + position, + onClose, + onCancel, +}: { isOpen: boolean position: { x: number; y: number } onClose: () => void @@ -223,17 +243,13 @@ function ContextMenu({ const menuContent = ( <> {/* Overlay для закрытия меню */} -
- +
+ {/* Контекстное меню */} -
void onConfirm: () => void @@ -275,8 +291,7 @@ function CancelConfirmDialog({ Отменить поставку - Вы точно хотите отменить поставку #{supplyId?.slice(-4).toUpperCase()}? - Это действие нельзя будет отменить. + Вы точно хотите отменить поставку #{supplyId?.slice(-4).toUpperCase()}? Это действие нельзя будет отменить. @@ -287,10 +302,7 @@ function CancelConfirmDialog({ > Отмена - @@ -300,8 +312,8 @@ function CancelConfirmDialog({ } // Основной компонент многоуровневой таблицы поставок -export function MultiLevelSuppliesTable({ - supplies = [], +export function MultiLevelSuppliesTable({ + supplies = [], loading: _loading = false, userRole = 'SELLER', onSupplyAction, @@ -310,7 +322,7 @@ export function MultiLevelSuppliesTable({ const [expandedRoutes, setExpandedRoutes] = useState>(new Set()) const [expandedSuppliers, setExpandedSuppliers] = useState>(new Set()) const [expandedProducts, setExpandedProducts] = useState>(new Set()) - + // Состояния для контекстного меню const [contextMenu, setContextMenu] = useState<{ isOpen: boolean @@ -322,42 +334,45 @@ export function MultiLevelSuppliesTable({ supplyId: null, }) 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, + 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 = [ - 'rgba(96, 165, 250, 0.8)', // Синий - 'rgba(244, 114, 182, 0.8)', // Розовый (заменил зеленый для лучшего контраста) - 'rgba(168, 85, 247, 0.8)', // Фиолетовый - 'rgba(251, 146, 60, 0.8)', // Оранжевый - 'rgba(248, 113, 113, 0.8)', // Красный - 'rgba(34, 211, 238, 0.8)', // Голубой - 'rgba(74, 222, 128, 0.8)', // Зеленый (переместил на 7 позицию) - 'rgba(250, 204, 21, 0.8)', // Желтый + 'rgba(96, 165, 250, 0.8)', // Синий + 'rgba(244, 114, 182, 0.8)', // Розовый (заменил зеленый для лучшего контраста) + 'rgba(168, 85, 247, 0.8)', // Фиолетовый + 'rgba(251, 146, 60, 0.8)', // Оранжевый + 'rgba(248, 113, 113, 0.8)', // Красный + 'rgba(34, 211, 238, 0.8)', // Голубой + 'rgba(74, 222, 128, 0.8)', // Зеленый (переместил на 7 позицию) + 'rgba(250, 204, 21, 0.8)', // Желтый ] const getSupplyColor = (index: number) => supplyColors[index % supplyColors.length] - + // Функция для получения цвета фона строки в зависимости от уровня иерархии const getLevelBackgroundColor = (level: number, _supplyIndex: number) => { - const alpha = 0.08 + (level * 0.03) // Больше прозрачности: начальное значение 0.08, шаг 0.03 - + const alpha = 0.08 + level * 0.03 // Больше прозрачности: начальное значение 0.08, шаг 0.03 + // Цвета для разных уровней (соответствуют цветам точек) const levelColors = { 1: 'rgba(96, 165, 250, ', // Синий для поставки @@ -366,7 +381,7 @@ export function MultiLevelSuppliesTable({ 4: 'rgba(244, 114, 182, ', // Розовый для товара 5: 'rgba(250, 204, 21, ', // Желтый для рецептуры } - + const baseColor = levelColors[level as keyof typeof levelColors] || 'rgba(75, 85, 99, ' return baseColor + `${alpha})` } @@ -420,7 +435,7 @@ export function MultiLevelSuppliesTable({ const handleContextMenu = (e: React.MouseEvent, supply: SupplyOrderFromGraphQL) => { // Проверяем роль и статус - показываем контекстное меню только для SELLER и отменяемых статусов if (userRole !== 'SELLER') return - + const canCancel = ['PENDING', 'SUPPLIER_APPROVED'].includes(supply.status.toUpperCase()) if (!canCancel) return @@ -552,52 +567,52 @@ export function MultiLevelSuppliesTable({ const getSupplyAggregatedData = (supply: SupplyOrderFromGraphQL) => { const items = supply.items || [] 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) - + // ✅ ИСПРАВЛЕНО: Расчет услуг ФФ по формуле из CartBlock.tsx const servicesPrice = items.reduce((sum, item) => { const recipe = item.recipe if (!recipe?.services) return sum - + const itemServicesPrice = recipe.services.reduce((serviceSum, service) => { - return serviceSum + (service.price * item.quantity) + return serviceSum + service.price * item.quantity }, 0) - + return sum + itemServicesPrice }, 0) - + // ✅ ДОБАВЛЕНО: Расчет расходников ФФ const ffConsumablesPrice = items.reduce((sum, item) => { const recipe = item.recipe if (!recipe?.fulfillmentConsumables) return sum - + const itemFFConsumablesPrice = recipe.fulfillmentConsumables.reduce((consumableSum, consumable) => { - return consumableSum + (consumable.price * item.quantity) + return consumableSum + consumable.price * item.quantity }, 0) - + return sum + itemFFConsumablesPrice }, 0) - - // ✅ ДОБАВЛЕНО: Расчет расходников селлера + + // ✅ ДОБАВЛЕНО: Расчет расходников селлера const sellerConsumablesPrice = items.reduce((sum, item) => { const recipe = item.recipe if (!recipe?.sellerConsumables) return sum - + const itemSellerConsumablesPrice = recipe.sellerConsumables.reduce((consumableSum, consumable) => { // Используем price как pricePerUnit согласно GraphQL схеме - return consumableSum + (consumable.price * item.quantity) + return consumableSum + consumable.price * item.quantity }, 0) - + return sum + itemSellerConsumablesPrice }, 0) - + const logisticsPrice = routes.reduce((sum, route) => sum + (route.price || 0), 0) - + const total = goodsPrice + servicesPrice + ffConsumablesPrice + sellerConsumablesPrice + logisticsPrice return { @@ -625,9 +640,9 @@ export function MultiLevelSuppliesTable({ return ( <> -
- {/* Таблица поставок */} - +
+ {/* Таблица поставок */} +
@@ -636,24 +651,43 @@ export function MultiLevelSuppliesTable({ Поставлено Брак Цена товаров - Услуги ФФ - Расходники ФФ - Расходники селлера - Логистика до ФФ - Итого + {/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE не видит услуги ФФ, расходники и логистику */} + {userRole !== 'WHOLESALE' && ( + + Услуги ФФ + + )} + {userRole !== 'WHOLESALE' && ( + + Расходники ФФ + + )} + {userRole !== 'WHOLESALE' && ( + + Расходники селлера + + )} + {userRole !== 'WHOLESALE' && ( + + Логистика до ФФ + + )} + + {userRole === 'WHOLESALE' ? 'Мои товары' : 'Итого'} + Статус - {supplies.length > 0 && ( + {supplies.length > 0 && supplies.map((supply, index) => { // Защита от неполных данных if (!supply.partner) { console.warn('⚠️ Supply without partner:', supply.id) return null } - + const isSupplyExpanded = expandedSupplies.has(supply.id) const aggregatedData = getSupplyAggregatedData(supply) @@ -662,9 +696,9 @@ export function MultiLevelSuppliesTable({ {/* УРОВЕНЬ 1: Основная строка поставки */} { - if (e.button === 2) { // Правая кнопка мыши + if (e.button === 2) { + // Правая кнопка мыши e.preventDefault() e.stopPropagation() handleContextMenu(e, supply) @@ -687,8 +722,11 @@ export function MultiLevelSuppliesTable({ {/* ВАРИАНТ 1: Порядковый номер поставки с цветной линией */} {supplies.length - index} -
- +
+ {/* ОТКАТ: ID поставки (последние 4 символа) без цветной линии {supply.id.slice(-4).toUpperCase()} */} @@ -700,14 +738,10 @@ export function MultiLevelSuppliesTable({
- - {aggregatedData.orderedTotal} - + {aggregatedData.orderedTotal} - - {aggregatedData.deliveredTotal} - + {aggregatedData.deliveredTotal} - - - {formatCurrency(aggregatedData.servicesPrice)} - - - - - {formatCurrency(aggregatedData.ffConsumablesPrice)} - - - - - {formatCurrency(aggregatedData.sellerConsumablesPrice)} - - - - - {formatCurrency(aggregatedData.logisticsPrice)} - - + {/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE не видит услуги ФФ, расходники и логистику */} + {userRole !== 'WHOLESALE' && ( + + + {formatCurrency(aggregatedData.servicesPrice)} + + + )} + {userRole !== 'WHOLESALE' && ( + + + {formatCurrency(aggregatedData.ffConsumablesPrice)} + + + )} + {userRole !== 'WHOLESALE' && ( + + + {formatCurrency(aggregatedData.sellerConsumablesPrice)} + + + )} + {userRole !== 'WHOLESALE' && ( + + + {formatCurrency(aggregatedData.logisticsPrice)} + + + )} - {/* ВАРИАНТ 1: Без значка доллара */} + {/* 🔒 БЕЗОПАСНОСТЬ: WHOLESALE видит только стоимость своих товаров */} - {formatCurrency(aggregatedData.total)} + {formatCurrency(userRole === 'WHOLESALE' ? aggregatedData.goodsPrice : aggregatedData.total)} - + {/* ОТКАТ: Со значком доллара
@@ -758,66 +801,72 @@ export function MultiLevelSuppliesTable({
*/}
- - {userRole !== 'WHOLESALE' && } - + {userRole !== 'WHOLESALE' && }
{/* ВАРИАНТ 1: Строка с ID поставки между уровнями */} {isSupplyExpanded && ( - +
ID поставки: {supply.id.slice(-8).toUpperCase()}
-
+
)} - + {/* ОТКАТ: Без строки ID {/* Строка с ID убрана */} {/* */} {/* УРОВЕНЬ 2: Маршруты поставки */} - {isSupplyExpanded && (() => { - // ✅ ВРЕМЕННАЯ ЗАГЛУШКА: создаем фиктивный маршрут для демонстрации - const mockRoutes = supply.routes && supply.routes.length > 0 - ? supply.routes - : [{ - id: `route-${supply.id}`, - createdDate: supply.deliveryDate, - fromLocation: 'Садовод', - toLocation: 'SFERAV Logistics ФФ', - price: 0, - }] - - return mockRoutes.map((route) => { - const isRouteExpanded = expandedRoutes.has(route.id) - - return ( - - toggleRouteExpansion(route.id)} - > - -
-
- - Маршрут -
-
-
- - {/* ВАРИАНТ 1: Только название локации источника */} - - {route.fromLocation} - - - {/* ОТКАТ: Полная информация о маршруте + {isSupplyExpanded && + (() => { + // ✅ ВРЕМЕННАЯ ЗАГЛУШКА: создаем фиктивный маршрут для демонстрации + const mockRoutes = + supply.routes && supply.routes.length > 0 + ? supply.routes + : [ + { + id: `route-${supply.id}`, + createdDate: supply.deliveryDate, + fromLocation: 'Садовод', + toLocation: 'SFERAV Logistics ФФ', + price: 0, + }, + ] + + return mockRoutes.map((route) => { + const isRouteExpanded = expandedRoutes.has(route.id) + + return ( + + toggleRouteExpansion(route.id)} + > + +
+
+ + Маршрут +
+
+
+ + {/* ВАРИАНТ 1: Только название локации источника */} + {route.fromLocation} + + {/* ОТКАТ: Полная информация о маршруте
{route.fromLocation} → {route.toLocation} @@ -827,330 +876,433 @@ export function MultiLevelSuppliesTable({
*/} -
- - - {aggregatedData.orderedTotal} - - - - - {aggregatedData.deliveredTotal} - - - - - {aggregatedData.defectTotal} - - - - - {formatCurrency(aggregatedData.goodsPrice)} - - - - - {formatCurrency(aggregatedData.servicesPrice)} - - - - - {formatCurrency(aggregatedData.ffConsumablesPrice)} - - - - - {formatCurrency(aggregatedData.sellerConsumablesPrice)} - - - - - {formatCurrency(route.price || 0)} - - - - - {formatCurrency(aggregatedData.total)} - - - - -
- - {/* УРОВЕНЬ 3: Поставщик */} - {isRouteExpanded && ( - toggleSupplierExpansion(supply.partner.id)} - > - -
-
-
- - Поставщик -
-
-
- - {/* ВАРИАНТ 1: Название, управляющий и телефон */} -
- - {supply.partner.name || supply.partner.fullName} + + + {aggregatedData.orderedTotal} + + + {aggregatedData.deliveredTotal} + + + {aggregatedData.defectTotal} + + + + {formatCurrency(aggregatedData.goodsPrice)} - {/* Имя управляющего из пользователей организации */} - {supply.partner.users && supply.partner.users.length > 0 && supply.partner.users[0].managerName && ( - - {supply.partner.users[0].managerName} - - )} - {/* Телефон из БД (JSON поле) */} - {supply.partner.phones && Array.isArray(supply.partner.phones) && supply.partner.phones.length > 0 && ( - - {typeof supply.partner.phones[0] === 'string' - ? supply.partner.phones[0] - : supply.partner.phones[0]?.value || supply.partner.phones[0]?.phone - } - - )} -
- - {/* ОТКАТ: Только название поставщика +
+ + + {formatCurrency(aggregatedData.servicesPrice)} + + + + + {formatCurrency(aggregatedData.ffConsumablesPrice)} + + + + + {formatCurrency(aggregatedData.sellerConsumablesPrice)} + + + + + {formatCurrency(route.price || 0)} + + + + + {formatCurrency(aggregatedData.total)} + + + + +
+ + {/* УРОВЕНЬ 3: Поставщик */} + {isRouteExpanded && ( + toggleSupplierExpansion(supply.partner.id)} + > + +
+
+
+ + Поставщик +
+
+
+ + {/* ВАРИАНТ 1: Название, управляющий и телефон */} +
+ + {supply.partner.name || supply.partner.fullName} + + {/* Имя управляющего из пользователей организации */} + {supply.partner.users && + supply.partner.users.length > 0 && + supply.partner.users[0].managerName && ( + + {supply.partner.users[0].managerName} + + )} + {/* Телефон из БД (JSON поле) */} + {supply.partner.phones && + Array.isArray(supply.partner.phones) && + supply.partner.phones.length > 0 && ( + + {typeof supply.partner.phones[0] === 'string' + ? supply.partner.phones[0] + : supply.partner.phones[0]?.value || supply.partner.phones[0]?.phone} + + )} +
+ + {/* ОТКАТ: Только название поставщика {supply.partner.name || supply.partner.fullName} */} -
- - - {aggregatedData.orderedTotal} - - - - - {aggregatedData.deliveredTotal} - - - - - {aggregatedData.defectTotal} - - - - - {formatCurrency(aggregatedData.goodsPrice)} - - - - {/* Агрегированные данные поставщика отображаются только в итого */} - - - - {formatCurrency(aggregatedData.total)} - - - - -
- )} - - {/* УРОВЕНЬ 4: Товары */} - {isRouteExpanded && expandedSuppliers.has(supply.partner.id) && (supply.items || []).map((item) => { - const isProductExpanded = expandedProducts.has(item.id) - - return ( - - toggleProductExpansion(item.id)} - > - -
-
-
-
- - Товар -
-
-
- {item.product.name} - - Арт: {item.product.article || 'SF-T-925635-494'} - {item.product.category && ` · ${item.product.category.name}`} - -
+ {aggregatedData.orderedTotal}
- - - {item.quantity} + + {aggregatedData.deliveredTotal} + + + {aggregatedData.defectTotal} + + + + {formatCurrency(aggregatedData.goodsPrice)} + + {/* Агрегированные данные поставщика отображаются только в итого */} + - - - - - - - - - - - -
-
- {formatCurrency(item.totalPrice)} -
-
- {formatCurrency(item.price)} за шт. -
-
-
- - - {formatCurrency((item.recipe?.services || []).reduce((sum, service) => sum + service.price * item.quantity, 0))} - - - - - {formatCurrency((item.recipe?.fulfillmentConsumables || []).reduce((sum, consumable) => sum + consumable.price * item.quantity, 0))} - - - - - {formatCurrency((item.recipe?.sellerConsumables || []).reduce((sum, consumable) => sum + consumable.price * item.quantity, 0))} - - - - - - - - - {formatCurrency( - item.totalPrice + - (item.recipe?.services || []).reduce((sum, service) => sum + service.price * item.quantity, 0) + - (item.recipe?.fulfillmentConsumables || []).reduce((sum, consumable) => sum + consumable.price * item.quantity, 0) + - (item.recipe?.sellerConsumables || []).reduce((sum, consumable) => sum + consumable.price * item.quantity, 0), - )} - - - - - {(item.recipe?.services?.length || item.recipe?.fulfillmentConsumables?.length || item.recipe?.sellerConsumables?.length) ? 'Хорошо' : 'Без рецептуры'} + {formatCurrency(aggregatedData.total)} +
+ )} - {/* УРОВЕНЬ 5: Услуги фулфилмента */} - {isProductExpanded && item.recipe?.services && item.recipe.services.length > 0 && ( - item.recipe.services.map((service, serviceIndex) => ( - - -
-
-
-
-
- - Услуги -
-
-
- - - - - - - - - - - - - {service.name} ({formatCurrency(service.price)}) - - - - - - - - - - - - -
- )) - )} + {/* УРОВЕНЬ 4: Товары */} + {isRouteExpanded && + expandedSuppliers.has(supply.partner.id) && + (supply.items || []).map((item) => { + const isProductExpanded = expandedProducts.has(item.id) - {/* УРОВЕНЬ 5: Расходники фулфилмента */} - {isProductExpanded && item.recipe?.fulfillmentConsumables && item.recipe.fulfillmentConsumables.length > 0 && ( - item.recipe.fulfillmentConsumables.map((consumable, consumableIndex) => ( - - -
-
-
-
-
- - Услуги -
-
-
- - - - - - - - - - - - - - - {consumable.name} ({formatCurrency(consumable.price)}) - - - - - - - - - - -
- )) - )} + return ( + + toggleProductExpansion(item.id)} + > + +
+
+
+
+ + Товар +
+
+
+ +
+ {item.product.name} + + Арт: {item.product.article || 'SF-T-925635-494'} + {item.product.category && ` · ${item.product.category.name}`} + +
+
+ + {item.quantity} + + + - + + + - + + +
+
{formatCurrency(item.totalPrice)}
+
+ {formatCurrency(item.price)} за шт. +
+
+
+ + + {formatCurrency( + (item.recipe?.services || []).reduce( + (sum, service) => sum + service.price * item.quantity, + 0, + ), + )} + + + + + {formatCurrency( + (item.recipe?.fulfillmentConsumables || []).reduce( + (sum, consumable) => sum + consumable.price * item.quantity, + 0, + ), + )} + + + + + {formatCurrency( + (item.recipe?.sellerConsumables || []).reduce( + (sum, consumable) => sum + consumable.price * item.quantity, + 0, + ), + )} + + + + - + + + + {formatCurrency( + item.totalPrice + + (item.recipe?.services || []).reduce( + (sum, service) => sum + service.price * item.quantity, + 0, + ) + + (item.recipe?.fulfillmentConsumables || []).reduce( + (sum, consumable) => sum + consumable.price * item.quantity, + 0, + ) + + (item.recipe?.sellerConsumables || []).reduce( + (sum, consumable) => sum + consumable.price * item.quantity, + 0, + ), + )} + + + + + {item.recipe?.services?.length || + item.recipe?.fulfillmentConsumables?.length || + item.recipe?.sellerConsumables?.length + ? 'Хорошо' + : 'Без рецептуры'} + + + +
- {/* УРОВЕНЬ 5: Расходники селлера */} - {isProductExpanded && item.recipe?.sellerConsumables && item.recipe.sellerConsumables.length > 0 && ( - item.recipe.sellerConsumables.map((consumable, consumableIndex) => ( - - -
-
-
-
-
- - Услуги -
-
-
- - - - - - - - - - - - - - - - - {consumable.name} ({formatCurrency(consumable.price)}) - - - - - - - - -
- )) - )} + {/* УРОВЕНЬ 5: Услуги фулфилмента */} + {isProductExpanded && + item.recipe?.services && + item.recipe.services.length > 0 && + item.recipe.services.map((service, serviceIndex) => ( + + +
+
+
+
+
+ + Услуги +
+
+
+ + - + + + - + + + - + + + - + + + - + + + + {service.name} ({formatCurrency(service.price)}) + + + + - + + + - + + + - + + + - + + + - + +
+ ))} - {/* ОТКАТ: Старый вариант с желтыми элементами и colSpan блоком + {/* УРОВЕНЬ 5: Расходники фулфилмента */} + {isProductExpanded && + item.recipe?.fulfillmentConsumables && + item.recipe.fulfillmentConsumables.length > 0 && + item.recipe.fulfillmentConsumables.map((consumable, consumableIndex) => ( + + +
+
+
+
+
+ + Услуги +
+
+
+ + - + + + - + + + - + + + - + + + - + + + - + + + + {consumable.name} ({formatCurrency(consumable.price)}) + + + + - + + + - + + + - + + + - + +
+ ))} + + {/* УРОВЕНЬ 5: Расходники селлера */} + {isProductExpanded && + item.recipe?.sellerConsumables && + item.recipe.sellerConsumables.length > 0 && + item.recipe.sellerConsumables.map((consumable, consumableIndex) => ( + + +
+
+
+
+
+ + Услуги +
+
+
+ + - + + + - + + + - + + + - + + + - + + + - + + + - + + + + {consumable.name} ({formatCurrency(consumable.price)}) + + + + - + + + - + + + - + +
+ ))} + + {/* ОТКАТ: Старый вариант с желтыми элементами и colSpan блоком {/* УРОВЕНЬ 5: Рецептура - КОМПАКТНАЯ СТРУКТУРА */} - {/*isProductExpanded && (item.recipe?.services?.length || item.recipe?.fulfillmentConsumables?.length || item.recipe?.sellerConsumables?.length) && ( + {/*isProductExpanded && (item.recipe?.services?.length || item.recipe?.fulfillmentConsumables?.length || item.recipe?.sellerConsumables?.length) && (
@@ -1163,8 +1315,8 @@ export function MultiLevelSuppliesTable({ )*/} - - {/*isProductExpanded && (item.recipe?.services?.length || item.recipe?.fulfillmentConsumables?.length || item.recipe?.sellerConsumables?.length) && ( + + {/*isProductExpanded && (item.recipe?.services?.length || item.recipe?.fulfillmentConsumables?.length || item.recipe?.sellerConsumables?.length) && (
@@ -1197,61 +1349,65 @@ export function MultiLevelSuppliesTable({ )*/} - {/* Размеры товара (если есть) */} - {isProductExpanded && item.product.sizes && item.product.sizes.length > 0 && ( - item.product.sizes.map((size) => ( - - - - - - Размер: {size.name} - - {size.quantity} - - {size.price ? formatCurrency(size.price) : '-'} - - - - )) - )} - - ) - })} - - ) - }) - })()} - + {/* Размеры товара (если есть) */} + {isProductExpanded && + item.product.sizes && + item.product.sizes.length > 0 && + item.product.sizes.map((size) => ( + + + + + Размер: {size.name} + {size.quantity} + + {size.price ? formatCurrency(size.price) : '-'} + + + + ))} + + ) + })} + + ) + }) + })()} + {/* ВАРИАНТ 1: Разделитель в виде пустой строки с border */}
- + - + {/* ОТКАТ: Без разделителя {/* */} ) - }) - )} + })}
-
- - {/* Контекстное меню вынесено ЗА ПРЕДЕЛЫ контейнера таблицы */} - - - setCancelDialogOpen(false)} - onConfirm={handleConfirmCancel} - supplyId={contextMenu.supplyId} - /> - +
+ + {/* Контекстное меню вынесено ЗА ПРЕДЕЛЫ контейнера таблицы */} + + + setCancelDialogOpen(false)} + onConfirm={handleConfirmCancel} + supplyId={contextMenu.supplyId} + /> + ) -} \ No newline at end of file +} diff --git a/src/graphql/context.ts b/src/graphql/context.ts index bd7f7c3..49a7ff4 100644 --- a/src/graphql/context.ts +++ b/src/graphql/context.ts @@ -21,4 +21,8 @@ export interface Context { id: string } | null prisma: PrismaClient + req?: { + ip?: string + get?: (header: string) => string | undefined + } // Для системы безопасности } diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 5a822a3..eeede56 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -12,6 +12,13 @@ import { WildberriesService } from '@/services/wildberries-service' import '@/lib/seed-init' // Автоматическая инициализация БД +// 🔒 СИСТЕМА БЕЗОПАСНОСТИ - импорты +import { CommercialDataAudit } from './security/commercial-data-audit' +import { createSecurityContext } from './security/index' +import { ParticipantIsolation } from './security/participant-isolation' +import { SupplyDataFilter } from './security/supply-data-filter' +import type { SecurityContext } from './security/types' + // Сервисы const smsService = new SmsService() const dadataService = new DaDataService() @@ -22,25 +29,25 @@ const generateReferralCode = async (): Promise => { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' let attempts = 0 const maxAttempts = 10 - + while (attempts < maxAttempts) { let code = '' for (let i = 0; i < 10; i++) { code += chars.charAt(Math.floor(Math.random() * chars.length)) } - + // Проверяем уникальность const existing = await prisma.organization.findUnique({ where: { referralCode: code }, }) - + if (!existing) { return code } - + attempts++ } - + // Если не удалось сгенерировать уникальный код, используем cuid как fallback return `REF${Date.now()}${Math.random().toString(36).substr(2, 5).toUpperCase()}` } @@ -48,7 +55,7 @@ const generateReferralCode = async (): Promise => { // Функция для автоматического создания записи склада при новом партнерстве const autoCreateWarehouseEntry = async (sellerId: string, fulfillmentId: string) => { console.warn(`🏗️ AUTO WAREHOUSE ENTRY: Creating for seller ${sellerId} with fulfillment ${fulfillmentId}`) - + // Получаем данные селлера const sellerOrg = await prisma.organization.findUnique({ where: { id: sellerId }, @@ -58,13 +65,13 @@ const autoCreateWarehouseEntry = async (sellerId: string, fulfillmentId: string) throw new Error(`Селлер с ID ${sellerId} не найден`) } - // Проверяем что не существует уже записи для этого селлера у этого фулфилмента + // Проверяем что не существует уже записи для этого селлера у этого фулфилмента // В будущем здесь может быть проверка в отдельной таблице warehouse_entries // Пока используем логику проверки через контрагентов - + // ЛОГИКА ОПРЕДЕЛЕНИЯ НАЗВАНИЯ МАГАЗИНА (консистентно с warehouseData resolver) let storeName = sellerOrg.name - + if (sellerOrg.fullName && sellerOrg.name?.includes('ИП')) { // Извлекаем название из скобок, например: "ИП Антипова Д. В. (Renrel)" -> "Renrel" const match = sellerOrg.fullName.match(/\(([^)]+)\)/) @@ -77,7 +84,7 @@ const autoCreateWarehouseEntry = async (sellerId: string, fulfillmentId: string) const warehouseEntry = { id: `warehouse_${sellerId}_${Date.now()}`, // Уникальный ID записи storeName: storeName || sellerOrg.fullName || sellerOrg.name, - storeOwner: sellerOrg.inn || sellerOrg.fullName || sellerOrg.name, + storeOwner: sellerOrg.inn || sellerOrg.fullName || sellerOrg.name, storeImage: sellerOrg.logoUrl || null, storeQuantity: 0, // Пока нет поставок partnershipDate: new Date(), @@ -947,57 +954,57 @@ export const resolvers = { { logisticsPartnerId: currentUser.organization.id }, // Заказы где организация - логистический партнер ], }, - include: { - partner: { - include: { - users: true, + include: { + partner: { + include: { + users: true, + }, }, - }, - organization: { - include: { - users: true, + organization: { + include: { + users: true, + }, }, - }, - fulfillmentCenter: { - include: { - users: true, + fulfillmentCenter: { + include: { + users: true, + }, }, - }, - logisticsPartner: true, - items: { - include: { - product: { - include: { - category: true, - organization: true, + logisticsPartner: true, + items: { + include: { + product: { + include: { + category: true, + organization: true, + }, }, }, }, }, - }, - orderBy: { createdAt: 'desc' }, - }) + orderBy: { createdAt: 'desc' }, + }) - console.warn('📦 SUPPLY ORDERS FOUND:', { - totalOrders: orders.length, - ordersByRole: { - asCreator: orders.filter(o => o.organizationId === currentUser.organization.id).length, - asPartner: orders.filter(o => o.partnerId === currentUser.organization.id).length, - asFulfillment: orders.filter(o => o.fulfillmentCenterId === currentUser.organization.id).length, - asLogistics: orders.filter(o => o.logisticsPartnerId === currentUser.organization.id).length, - }, - orderStatuses: orders.reduce((acc: any, order) => { - acc[order.status] = (acc[order.status] || 0) + 1 - return acc - }, {}), - orderIds: orders.map(o => o.id), - }) + console.warn('📦 SUPPLY ORDERS FOUND:', { + totalOrders: orders.length, + ordersByRole: { + asCreator: orders.filter((o) => o.organizationId === currentUser.organization.id).length, + asPartner: orders.filter((o) => o.partnerId === currentUser.organization.id).length, + asFulfillment: orders.filter((o) => o.fulfillmentCenterId === currentUser.organization.id).length, + asLogistics: orders.filter((o) => o.logisticsPartnerId === currentUser.organization.id).length, + }, + orderStatuses: orders.reduce((acc: any, order) => { + acc[order.status] = (acc[order.status] || 0) + 1 + return acc + }, {}), + orderIds: orders.map((o) => o.id), + }) - return orders - } catch (error) { - console.error('❌ ERROR IN SUPPLY ORDERS RESOLVER:', error) - throw new GraphQLError(`Ошибка получения заказов поставок: ${error}`) - } + return orders + } catch (error) { + console.error('❌ ERROR IN SUPPLY ORDERS RESOLVER:', error) + throw new GraphQLError(`Ошибка получения заказов поставок: ${error}`) + } }, // Счетчик поставок, требующих одобрения @@ -1392,7 +1399,7 @@ export const resolvers = { // Подсчитываем прибыло по типам const arrived = { products: 0, - goods: 0, + goods: 0, defects: 0, pvzReturns: 0, fulfillmentSupplies: 0, @@ -1769,20 +1776,20 @@ export const resolvers = { // Получаем всех партнеров-селлеров const counterparties = await prisma.counterparty.findMany({ - where: { - organizationId: currentUser.organization.id, + where: { + organizationId: currentUser.organization.id, }, include: { counterparty: true, }, }) - const sellerPartners = counterparties.filter(c => c.counterparty.type === 'SELLER') + const sellerPartners = counterparties.filter((c) => c.counterparty.type === 'SELLER') console.warn('🤝 PARTNERS FOUND:', { totalCounterparties: counterparties.length, sellerPartners: sellerPartners.length, - sellers: sellerPartners.map(p => ({ + sellers: sellerPartners.map((p) => ({ id: p.counterparty.id, name: p.counterparty.name, fullName: p.counterparty.fullName, @@ -1791,15 +1798,15 @@ export const resolvers = { }) // Создаем данные склада для каждого партнера-селлера - const stores = sellerPartners.map(partner => { + const stores = sellerPartners.map((partner) => { const org = partner.counterparty - + // ЛОГИКА ОПРЕДЕЛЕНИЯ НАЗВАНИЯ МАГАЗИНА: // 1. Если есть name и оно не содержит "ИП" - используем name // 2. Если есть fullName и name содержит "ИП" - извлекаем из fullName название в скобках // 3. Fallback к name или fullName let storeName = org.name - + if (org.fullName && org.name?.includes('ИП')) { // Извлекаем название из скобок, например: "ИП Антипова Д. В. (Renrel)" -> "Renrel" const match = org.fullName.match(/\(([^)]+)\)/) @@ -1807,7 +1814,7 @@ export const resolvers = { storeName = match[1] } } - + return { id: `store_${org.id}`, storeName: storeName || org.fullName || org.name, @@ -1828,7 +1835,7 @@ export const resolvers = { console.warn('📦 WAREHOUSE STORES CREATED:', { storesCount: stores.length, - storesPreview: stores.slice(0, 3).map(s => ({ + storesPreview: stores.slice(0, 3).map((s) => ({ storeName: s.storeName, storeOwner: s.storeOwner, storeQuantity: s.storeQuantity, @@ -2379,7 +2386,7 @@ export const resolvers = { where: { referrerId: context.user.organizationId }, include: { referral: { - select: { + select: { type: true, createdAt: true, }, @@ -2394,14 +2401,14 @@ export const resolvers = { // Партнеры за последний месяц const lastMonth = new Date() lastMonth.setMonth(lastMonth.getMonth() - 1) - const monthlyPartners = transactions.filter(tx => tx.createdAt > lastMonth).length + const monthlyPartners = transactions.filter((tx) => tx.createdAt > lastMonth).length const monthlySpheres = transactions - .filter(tx => tx.createdAt > lastMonth) + .filter((tx) => tx.createdAt > lastMonth) .reduce((sum, tx) => sum + tx.points, 0) // Группировка по типам организаций const typeStats: Record = {} - transactions.forEach(tx => { + transactions.forEach((tx) => { const type = tx.referral.type if (!typeStats[type]) { typeStats[type] = { count: 0, spheres: 0 } @@ -2412,7 +2419,7 @@ export const resolvers = { // Группировка по источникам const sourceStats: Record = {} - transactions.forEach(tx => { + transactions.forEach((tx) => { const source = tx.type === 'REGISTRATION' ? 'REFERRAL_LINK' : 'AUTO_BUSINESS' if (!sourceStats[source]) { sourceStats[source] = { count: 0, spheres: 0 } @@ -2428,13 +2435,29 @@ export const resolvers = { monthlySpheres, referralsByType: [ { type: 'SELLER', count: typeStats['SELLER']?.count || 0, spheres: typeStats['SELLER']?.spheres || 0 }, - { type: 'WHOLESALE', count: typeStats['WHOLESALE']?.count || 0, spheres: typeStats['WHOLESALE']?.spheres || 0 }, - { type: 'FULFILLMENT', count: typeStats['FULFILLMENT']?.count || 0, spheres: typeStats['FULFILLMENT']?.spheres || 0 }, + { + type: 'WHOLESALE', + count: typeStats['WHOLESALE']?.count || 0, + spheres: typeStats['WHOLESALE']?.spheres || 0, + }, + { + type: 'FULFILLMENT', + count: typeStats['FULFILLMENT']?.count || 0, + spheres: typeStats['FULFILLMENT']?.spheres || 0, + }, { type: 'LOGIST', count: typeStats['LOGIST']?.count || 0, spheres: typeStats['LOGIST']?.spheres || 0 }, ], referralsBySource: [ - { source: 'REFERRAL_LINK', count: sourceStats['REFERRAL_LINK']?.count || 0, spheres: sourceStats['REFERRAL_LINK']?.spheres || 0 }, - { source: 'AUTO_BUSINESS', count: sourceStats['AUTO_BUSINESS']?.count || 0, spheres: sourceStats['AUTO_BUSINESS']?.spheres || 0 }, + { + source: 'REFERRAL_LINK', + count: sourceStats['REFERRAL_LINK']?.count || 0, + spheres: sourceStats['REFERRAL_LINK']?.spheres || 0, + }, + { + source: 'AUTO_BUSINESS', + count: sourceStats['AUTO_BUSINESS']?.count || 0, + spheres: sourceStats['AUTO_BUSINESS']?.spheres || 0, + }, ], } } catch (error) { @@ -2491,7 +2514,7 @@ export const resolvers = { }) // Преобразуем в формат для UI - const referrals = referralTransactions.map(tx => ({ + const referrals = referralTransactions.map((tx) => ({ id: tx.id, organization: tx.referral, source: tx.type === 'REGISTRATION' ? 'REFERRAL_LINK' : 'AUTO_BUSINESS', @@ -2546,7 +2569,7 @@ export const resolvers = { } }, - // Мои поставки для селлера (многоуровневая таблица) + // 🔒 Мои поставки с системой безопасности (многоуровневая таблица) mySupplyOrders: async (_: unknown, __: unknown, context: Context) => { if (!context.user) { throw new GraphQLError('Требуется авторизация', { @@ -2563,13 +2586,32 @@ export const resolvers = { throw new GraphQLError('У пользователя нет организации') } - console.warn('🔍 GET MY SUPPLY ORDERS:', { + // 🔒 СОЗДАНИЕ КОНТЕКСТА БЕЗОПАСНОСТИ + const securityContext = createSecurityContext({ + user: { + id: currentUser.id, + organizationId: currentUser.organization.id, + organizationType: currentUser.organization.type, + }, + req: context.req, + }) + + console.warn('🔍 GET MY SUPPLY ORDERS (SECURE):', { userId: context.user.id, organizationType: currentUser.organization.type, organizationId: currentUser.organization.id, + securityEnabled: true, }) try { + // 🔒 ПРОВЕРКА ИЗОЛЯЦИИ УЧАСТНИКОВ + await ParticipantIsolation.validateAccess( + prisma, + currentUser.organization.id, + currentUser.organization.type, + 'SUPPLY_ORDER', + ) + // Определяем логику фильтрации в зависимости от типа организации let whereClause if (currentUser.organization.type === 'WHOLESALE') { @@ -2591,20 +2633,8 @@ export const resolvers = { organization: true, fulfillmentCenter: true, logisticsPartner: true, - // employee: true, // Поле не существует в SupplyOrder модели - // routes: { // Поле не существует в SupplyOrder модели - // include: { - // logistics: { - // include: { - // organization: true, - // }, - // }, - // }, - // orderBy: { - // createdDate: 'asc', // Сортируем маршруты по дате создания - // }, - // }, - items: { // Товары (уровень 4) + items: { + // Товары (уровень 4) include: { product: { include: { @@ -2623,55 +2653,97 @@ export const resolvers = { }, }) - console.warn('📦 Найдено поставок:', supplyOrders.length, { + console.warn('📦 Найдено поставок (до фильтрации):', supplyOrders.length, { organizationType: currentUser.organization.type, filterType: currentUser.organization.type === 'WHOLESALE' ? 'partnerId' : 'organizationId', organizationId: currentUser.organization.id, }) - // Преобразуем данные для GraphQL resolver с расширенной рецептурой - const _processedOrders = await Promise.all( + // 🔒 ПРИМЕНЕНИЕ СИСТЕМЫ БЕЗОПАСНОСТИ К КАЖДОМУ ЗАКАЗУ + const secureProcessedOrders = await Promise.all( supplyOrders.map(async (order) => { - // Обрабатываем каждый товар для получения рецептуры + // 🔒 АУДИТ ДОСТУПА К КОММЕРЧЕСКИМ ДАННЫМ + await CommercialDataAudit.logAccess(prisma, { + userId: currentUser.id, + organizationType: currentUser.organization.type, + action: 'VIEW_PRICE', + resourceType: 'SUPPLY_ORDER', + resourceId: order.id, + metadata: { + orderStatus: order.status, + totalAmount: order.totalAmount, + partner: order.partner?.name || order.partner?.inn, + }, + ipAddress: securityContext.ipAddress, + userAgent: securityContext.userAgent, + }) + + // 🔒 ФИЛЬТРАЦИЯ ДАННЫХ ПО РОЛИ + const filteredOrder = SupplyDataFilter.filterSupplyOrder(order, securityContext) + + // Обрабатываем каждый товар для получения рецептуры с фильтрацией const processedItems = await Promise.all( - order.items.map(async (item) => { + filteredOrder.data.items.map(async (item: any) => { let recipe = null // Получаем развернутую рецептуру если есть данные if ( - item.services.length > 0 || - item.fulfillmentConsumables.length > 0 || - item.sellerConsumables.length > 0 + item.services?.length > 0 || + item.fulfillmentConsumables?.length > 0 || + item.sellerConsumables?.length > 0 ) { - // Получаем услуги - const services = item.services.length > 0 - ? await prisma.service.findMany({ - where: { id: { in: item.services } }, - include: { organization: true }, - }) - : [] + // 🔒 АУДИТ ДОСТУПА К РЕЦЕПТУРЕ + await CommercialDataAudit.logAccess(prisma, { + userId: currentUser.id, + organizationType: currentUser.organization.type, + action: 'VIEW_RECIPE', + resourceType: 'SUPPLY_ORDER', + resourceId: item.id, + metadata: { + hasServices: item.services?.length > 0, + hasFulfillmentConsumables: item.fulfillmentConsumables?.length > 0, + hasSellerConsumables: item.sellerConsumables?.length > 0, + }, + ipAddress: securityContext.ipAddress, + userAgent: securityContext.userAgent, + }) - // Получаем расходники фулфилмента - const fulfillmentConsumables = item.fulfillmentConsumables.length > 0 - ? await prisma.supply.findMany({ - where: { id: { in: item.fulfillmentConsumables } }, - include: { organization: true }, - }) - : [] + // Получаем услуги с фильтрацией + const services = + item.services?.length > 0 + ? await prisma.service.findMany({ + where: { id: { in: item.services } }, + include: { organization: true }, + }) + : [] - // Получаем расходники селлера - const sellerConsumables = item.sellerConsumables.length > 0 - ? await prisma.supply.findMany({ - where: { id: { in: item.sellerConsumables } }, - }) - : [] + // Получаем расходники фулфилмента с фильтрацией + const fulfillmentConsumables = + item.fulfillmentConsumables?.length > 0 + ? await prisma.supply.findMany({ + where: { id: { in: item.fulfillmentConsumables } }, + include: { organization: true }, + }) + : [] - recipe = { - services, - fulfillmentConsumables, - sellerConsumables, - marketplaceCardId: item.marketplaceCardId, - } + // Получаем расходники селлера с фильтрацией + const sellerConsumables = + item.sellerConsumables?.length > 0 + ? await prisma.supply.findMany({ + where: { id: { in: item.sellerConsumables } }, + }) + : [] + + // 🔒 ФИЛЬТРАЦИЯ РЕЦЕПТУРЫ ПО РОЛИ + recipe = SupplyDataFilter.filterRecipeByRole( + { + services, + fulfillmentConsumables, + sellerConsumables, + marketplaceCardId: item.marketplaceCardId, + }, + securityContext, + ) } return { @@ -2682,21 +2754,27 @@ export const resolvers = { ) return { - ...order, + ...filteredOrder.data, items: processedItems, + // 🔒 ДОБАВЛЯЕМ МЕТАДАННЫЕ БЕЗОПАСНОСТИ + _security: { + filtered: filteredOrder.filtered, + removedFields: filteredOrder.removedFields, + accessLevel: filteredOrder.accessLevel, + }, } }), ) - console.warn('✅ Данные обработаны для многоуровневой таблицы') + console.warn('✅ Данные обработаны с системой безопасности:', { + ordersTotal: secureProcessedOrders.length, + securityApplied: true, + organizationType: currentUser.organization.type, + }) - // ВАРИАНТ 1: Возвращаем обработанные данные с развернутыми рецептурами - return _processedOrders - - // ОТКАТ: Возвращаем необработанные данные (без цен услуг/расходников) - // return supplyOrders + return secureProcessedOrders } catch (error) { - console.error('❌ Ошибка получения поставок селлера:', error) + console.error('❌ Ошибка получения поставок (security):', error) throw new GraphQLError(`Ошибка получения поставок: ${error instanceof Error ? error.message : String(error)}`) } }, @@ -2822,7 +2900,6 @@ export const resolvers = { }, context: Context, ) => { - if (!context.user) { throw new GraphQLError('Требуется авторизация', { extensions: { code: 'UNAUTHENTICATED' }, @@ -2908,7 +2985,7 @@ export const resolvers = { type: type, dadataData: JSON.parse(JSON.stringify(organizationData.rawData)), - + // Реферальная система - генерируем код автоматически referralCode: generatedReferralCode, }, @@ -2934,7 +3011,7 @@ export const resolvers = { const referrer = await prisma.organization.findUnique({ where: { referralCode: referralCode }, }) - + if (referrer) { // Создаем реферальную транзакцию (100 сфер) await prisma.referralTransaction.create({ @@ -2966,13 +3043,11 @@ export const resolvers = { if (partnerCode) { try { - // Находим партнера по партнерскому коду const partner = await prisma.organization.findUnique({ where: { referralCode: partnerCode }, }) - - + if (partner) { // Создаем реферальную транзакцию (100 сфер) await prisma.referralTransaction.create({ @@ -3015,8 +3090,7 @@ export const resolvers = { triggeredBy: 'PARTNER_LINK', }, }) - - } + } } catch { // Error processing partner code, but continue registration } @@ -3050,7 +3124,6 @@ export const resolvers = { }, context: Context, ) => { - if (!context.user) { throw new GraphQLError('Требуется авторизация', { extensions: { code: 'UNAUTHENTICATED' }, @@ -3104,7 +3177,7 @@ export const resolvers = { const tradeMark = validationResults[0]?.data?.tradeMark const sellerName = validationResults[0]?.data?.sellerName const shopName = tradeMark || sellerName || 'Магазин' - + // Генерируем уникальный реферальный код const generatedReferralCode = await generateReferralCode() @@ -3114,7 +3187,7 @@ export const resolvers = { name: shopName, // Используем tradeMark как основное название fullName: sellerName ? `${sellerName} (${shopName})` : `Интернет-магазин "${shopName}"`, type: 'SELLER', - + // Реферальная система - генерируем код автоматически referralCode: generatedReferralCode, }, @@ -3152,7 +3225,7 @@ export const resolvers = { const referrer = await prisma.organization.findUnique({ where: { referralCode: referralCode }, }) - + if (referrer) { // Создаем реферальную транзакцию (100 сфер) await prisma.referralTransaction.create({ @@ -3184,13 +3257,11 @@ export const resolvers = { if (partnerCode) { try { - // Находим партнера по партнерскому коду const partner = await prisma.organization.findUnique({ where: { referralCode: partnerCode }, }) - - + if (partner) { // Создаем реферальную транзакцию (100 сфер) await prisma.referralTransaction.create({ @@ -3233,8 +3304,7 @@ export const resolvers = { triggeredBy: 'PARTNER_LINK', }, }) - - } + } } catch { // Error processing partner code, but continue registration } @@ -3859,14 +3929,16 @@ export const resolvers = { }, }), ]) - + // АВТОМАТИЧЕСКОЕ СОЗДАНИЕ ЗАПИСЕЙ В ТАБЛИЦЕ СКЛАДА ФУЛФИЛМЕНТА // Проверяем, есть ли фулфилмент среди партнеров if (request.receiver.type === 'FULFILLMENT' && request.sender.type === 'SELLER') { // Селлер становится партнером фулфилмента - создаем запись склада try { await autoCreateWarehouseEntry(request.senderId, request.receiverId) - console.warn(`✅ AUTO WAREHOUSE ENTRY: Created for seller ${request.senderId} with fulfillment ${request.receiverId}`) + console.warn( + `✅ AUTO WAREHOUSE ENTRY: Created for seller ${request.senderId} with fulfillment ${request.receiverId}`, + ) } catch (error) { console.error('❌ AUTO WAREHOUSE ENTRY ERROR:', error) // Не прерываем основной процесс, если не удалось создать запись склада @@ -3875,7 +3947,9 @@ export const resolvers = { // Фулфилмент принимает заявку от селлера - создаем запись склада try { await autoCreateWarehouseEntry(request.receiverId, request.senderId) - console.warn(`✅ AUTO WAREHOUSE ENTRY: Created for seller ${request.receiverId} with fulfillment ${request.senderId}`) + console.warn( + `✅ AUTO WAREHOUSE ENTRY: Created for seller ${request.receiverId} with fulfillment ${request.senderId}`, + ) } catch (error) { console.error('❌ AUTO WAREHOUSE ENTRY ERROR:', error) } @@ -4865,7 +4939,7 @@ export const resolvers = { inputData: args.input, timestamp: new Date().toISOString(), }) - + if (!context.user) { throw new GraphQLError('Требуется авторизация', { extensions: { code: 'UNAUTHENTICATED' }, @@ -5055,7 +5129,7 @@ export const resolvers = { recipe: recipeData ? JSON.stringify(recipeData) : null, } */ - + // ВОССТАНОВЛЕННАЯ ОРИГИНАЛЬНАЯ ЛОГИКА: return { productId: item.productId, @@ -5082,10 +5156,9 @@ export const resolvers = { } // ИСПРАВЛЕНИЕ: Автоматически определяем тип расходников на основе заказчика - const consumableType = currentUser.organization.type === 'SELLER' - ? 'SELLER_CONSUMABLES' - : 'FULFILLMENT_CONSUMABLES' - + const consumableType = + currentUser.organization.type === 'SELLER' ? 'SELLER_CONSUMABLES' : 'FULFILLMENT_CONSUMABLES' + console.warn('🔍 Автоматическое определение типа расходников:', { organizationType: currentUser.organization.type, consumableType: consumableType, @@ -5185,11 +5258,14 @@ export const resolvers = { fromLocation: partner.market || partner.address || 'Поставщик', toLocation: fulfillmentCenterId ? 'Фулфилмент-центр' : 'Получатель', fromAddress: partner.addressFull || partner.address || null, - toAddress: fulfillmentCenterId ? - (await prisma.organization.findUnique({ - where: { id: fulfillmentCenterId }, - select: { addressFull: true, address: true }, - }))?.addressFull || null : null, + toAddress: fulfillmentCenterId + ? ( + await prisma.organization.findUnique({ + where: { id: fulfillmentCenterId }, + select: { addressFull: true, address: true }, + }) + )?.addressFull || null + : null, status: 'pending', createdDate: new Date(), } @@ -5234,12 +5310,13 @@ export const resolvers = { ) // Проверяем, является ли это первой сделкой организации - const isFirstOrder = await prisma.supplyOrder.count({ - where: { - organizationId: currentUser.organization.id, - id: { not: supplyOrder.id }, - }, - }) === 0 + const isFirstOrder = + (await prisma.supplyOrder.count({ + where: { + organizationId: currentUser.organization.id, + id: { not: supplyOrder.id }, + }, + })) === 0 // Если это первая сделка и организация была приглашена по реферальной ссылке if (isFirstOrder && currentUser.organization.referredById) { @@ -5271,14 +5348,11 @@ export const resolvers = { // Создаем расходники на основе заказанных товаров // Расходники создаются в организации получателя (фулфилмент-центре) // Определяем тип расходников на основе consumableType - const supplyType = args.input.consumableType === 'SELLER_CONSUMABLES' - ? 'SELLER_CONSUMABLES' - : 'FULFILLMENT_CONSUMABLES' - + const supplyType = + args.input.consumableType === 'SELLER_CONSUMABLES' ? 'SELLER_CONSUMABLES' : 'FULFILLMENT_CONSUMABLES' + // Определяем sellerOwnerId для расходников селлеров - const sellerOwnerId = supplyType === 'SELLER_CONSUMABLES' - ? currentUser.organization!.id - : null + const sellerOwnerId = supplyType === 'SELLER_CONSUMABLES' ? currentUser.organization!.id : null const suppliesData = args.input.items.map((item) => { const product = products.find((p) => p.id === item.productId)! @@ -7314,7 +7388,7 @@ export const resolvers = { } }, - // Резолверы для новых действий с заказами поставок + // 🔒 МУТАЦИИ ПОСТАВЩИКА С СИСТЕМОЙ БЕЗОПАСНОСТИ supplierApproveOrder: async (_: unknown, args: { id: string }, context: Context) => { if (!context.user) { throw new GraphQLError('Требуется авторизация', { @@ -7331,14 +7405,45 @@ export const resolvers = { throw new GraphQLError('У пользователя нет организации') } + // 🔒 ПРОВЕРКА РОЛИ ПОСТАВЩИКА + if (currentUser.organization.type !== 'WHOLESALE') { + throw new GraphQLError('Доступ разрешен только поставщикам (WHOLESALE)') + } + try { - // Проверяем, что пользователь - поставщик этого заказа + // 🔒 СОЗДАНИЕ КОНТЕКСТА БЕЗОПАСНОСТИ + const securityContext: SecurityContext = { + userId: currentUser.id, + organizationId: currentUser.organization.id, + organizationType: currentUser.organization.type, + userRole: currentUser.organization.type, + requestMetadata: { + action: 'APPROVE_ORDER', + resourceId: args.id, + timestamp: new Date().toISOString(), + ipAddress: context.req?.ip || 'unknown', + userAgent: context.req?.get('user-agent') || 'unknown', + }, + } + + // 🔒 ПРОВЕРКА ИЗОЛЯЦИИ УЧАСТНИКОВ + await ParticipantIsolation.validateAccess( + prisma, + currentUser.organization.id, + currentUser.organization.type, + 'SUPPLY_ORDER', + ) + // 🔒 ПОЛУЧЕНИЕ ЗАКАЗА С ПРОВЕРКОЙ ДОСТУПА const existingOrder = await prisma.supplyOrder.findFirst({ where: { id: args.id, partnerId: currentUser.organization.id, // Только поставщик может одобрить status: 'PENDING', // Можно одобрить только заказы в статусе PENDING }, + include: { + organization: true, + partner: true, + }, }) if (!existingOrder) { @@ -7348,6 +7453,27 @@ export const resolvers = { } } + // 🔒 ПРОВЕРКА ПАРТНЕРСКИХ ОТНОШЕНИЙ + await ParticipantIsolation.validatePartnerAccess( + prisma, + currentUser.organization.id, + existingOrder.organizationId, + ) + + // 🔒 АУДИТ ДОСТУПА К КОММЕРЧЕСКИМ ДАННЫМ + await CommercialDataAudit.logAccess(prisma, { + userId: currentUser.id, + organizationType: currentUser.organization.type, + action: 'APPROVE_ORDER', + resourceType: 'SUPPLY_ORDER', + resourceId: args.id, + metadata: { + partnerOrganizationId: existingOrder.organizationId, + orderValue: existingOrder.totalAmount?.toString() || '0', + ...securityContext.requestMetadata, + }, + }) + console.warn(`[DEBUG] Поставщик ${currentUser.organization.name} одобряет заказ ${args.id}`) // 🔄 СИНХРОНИЗАЦИЯ ОСТАТКОВ: Резервируем товары у поставщика @@ -7417,11 +7543,21 @@ export const resolvers = { organization: true, }, }, + recipe: { + include: { + services: true, + fulfillmentConsumables: true, + sellerConsumables: true, + }, + }, }, }, }, }) + // 🔒 ФИЛЬТРАЦИЯ ДАННЫХ ДЛЯ ПОСТАВЩИКА + const filteredOrder = SupplyDataFilter.filterSupplyOrder(updatedOrder, securityContext) + console.warn(`[DEBUG] Заказ ${args.id} успешно обновлен до статуса: ${updatedOrder.status}`) try { const orgIds = [ @@ -7439,7 +7575,7 @@ export const resolvers = { return { success: true, message: 'Заказ поставки одобрен поставщиком. Товары зарезервированы, остатки обновлены.', - order: updatedOrder, + order: filteredOrder, // 🔒 Возвращаем отфильтрованные данные } } catch (error) { console.error('Error approving supply order:', error) @@ -7466,13 +7602,46 @@ export const resolvers = { throw new GraphQLError('У пользователя нет организации') } + // 🔒 ПРОВЕРКА РОЛИ ПОСТАВЩИКА + if (currentUser.organization.type !== 'WHOLESALE') { + throw new GraphQLError('Доступ разрешен только поставщикам (WHOLESALE)') + } + try { + // 🔒 СОЗДАНИЕ КОНТЕКСТА БЕЗОПАСНОСТИ + const securityContext: SecurityContext = { + userId: currentUser.id, + organizationId: currentUser.organization.id, + organizationType: currentUser.organization.type, + userRole: currentUser.organization.type, + requestMetadata: { + action: 'REJECT_ORDER', + resourceId: args.id, + timestamp: new Date().toISOString(), + ipAddress: context.req?.ip || 'unknown', + userAgent: context.req?.get('user-agent') || 'unknown', + }, + } + + // 🔒 ПРОВЕРКА ИЗОЛЯЦИИ УЧАСТНИКОВ + await ParticipantIsolation.validateAccess( + prisma, + currentUser.organization.id, + currentUser.organization.type, + 'SUPPLY_ORDER', + ) + + // 🔒 ПОЛУЧЕНИЕ ЗАКАЗА С ПРОВЕРКОЙ ДОСТУПА const existingOrder = await prisma.supplyOrder.findFirst({ where: { id: args.id, partnerId: currentUser.organization.id, status: 'PENDING', }, + include: { + organization: true, + partner: true, + }, }) if (!existingOrder) { @@ -7482,6 +7651,28 @@ export const resolvers = { } } + // 🔒 ПРОВЕРКА ПАРТНЕРСКИХ ОТНОШЕНИЙ + await ParticipantIsolation.validatePartnerAccess( + prisma, + currentUser.organization.id, + existingOrder.organizationId, + ) + + // 🔒 АУДИТ ДОСТУПА К КОММЕРЧЕСКИМ ДАННЫМ + await CommercialDataAudit.logAccess(prisma, { + userId: currentUser.id, + organizationType: currentUser.organization.type, + action: 'REJECT_ORDER', + resourceType: 'SUPPLY_ORDER', + resourceId: args.id, + metadata: { + partnerOrganizationId: existingOrder.organizationId, + orderValue: existingOrder.totalAmount?.toString() || '0', + rejectionReason: args.reason, + ...securityContext.requestMetadata, + }, + }) + const updatedOrder = await prisma.supplyOrder.update({ where: { id: args.id }, data: { status: 'CANCELLED' }, @@ -7498,11 +7689,21 @@ export const resolvers = { organization: true, }, }, + recipe: { + include: { + services: true, + fulfillmentConsumables: true, + sellerConsumables: true, + }, + }, }, }, }, }) + // 🔒 ФИЛЬТРАЦИЯ ДАННЫХ ДЛЯ ПОСТАВЩИКА + const filteredOrder = SupplyDataFilter.filterSupplyOrder(updatedOrder, securityContext) + // 📦 СНИМАЕМ РЕЗЕРВАЦИЮ ПРИ ОТКЛОНЕНИИ // Восстанавливаем остатки и убираем резервацию для каждого отклоненного товара for (const item of updatedOrder.items) { @@ -7555,7 +7756,7 @@ export const resolvers = { return { success: true, message: args.reason ? `Заказ отклонен поставщиком. Причина: ${args.reason}` : 'Заказ отклонен поставщиком', - order: updatedOrder, + order: filteredOrder, // 🔒 Возвращаем отфильтрованные данные } } catch (error) { console.error('Error rejecting supply order:', error) @@ -7582,13 +7783,46 @@ export const resolvers = { throw new GraphQLError('У пользователя нет организации') } + // 🔒 ПРОВЕРКА РОЛИ ПОСТАВЩИКА + if (currentUser.organization.type !== 'WHOLESALE') { + throw new GraphQLError('Доступ разрешен только поставщикам (WHOLESALE)') + } + try { + // 🔒 СОЗДАНИЕ КОНТЕКСТА БЕЗОПАСНОСТИ + const securityContext: SecurityContext = { + userId: currentUser.id, + organizationId: currentUser.organization.id, + organizationType: currentUser.organization.type, + userRole: currentUser.organization.type, + requestMetadata: { + action: 'SHIP_ORDER', + resourceId: args.id, + timestamp: new Date().toISOString(), + ipAddress: context.req?.ip || 'unknown', + userAgent: context.req?.get('user-agent') || 'unknown', + }, + } + + // 🔒 ПРОВЕРКА ИЗОЛЯЦИИ УЧАСТНИКОВ + await ParticipantIsolation.validateAccess( + prisma, + currentUser.organization.id, + currentUser.organization.type, + 'SUPPLY_ORDER', + ) + + // 🔒 ПОЛУЧЕНИЕ ЗАКАЗА С ПРОВЕРКОЙ ДОСТУПА const existingOrder = await prisma.supplyOrder.findFirst({ where: { id: args.id, partnerId: currentUser.organization.id, status: 'LOGISTICS_CONFIRMED', }, + include: { + organization: true, + partner: true, + }, }) if (!existingOrder) { @@ -7598,6 +7832,27 @@ export const resolvers = { } } + // 🔒 ПРОВЕРКА ПАРТНЕРСКИХ ОТНОШЕНИЙ + await ParticipantIsolation.validatePartnerAccess( + prisma, + currentUser.organization.id, + existingOrder.organizationId, + ) + + // 🔒 АУДИТ ДОСТУПА К КОММЕРЧЕСКИМ ДАННЫМ + await CommercialDataAudit.logAccess(prisma, { + userId: currentUser.id, + organizationType: currentUser.organization.type, + action: 'SHIP_ORDER', + resourceType: 'SUPPLY_ORDER', + resourceId: args.id, + metadata: { + partnerOrganizationId: existingOrder.organizationId, + orderValue: existingOrder.totalAmount?.toString() || '0', + ...securityContext.requestMetadata, + }, + }) + // 🔄 СИНХРОНИЗАЦИЯ ОСТАТКОВ: Переводим товары из "заказано" в "в пути" const orderWithItems = await prisma.supplyOrder.findUnique({ where: { id: args.id }, @@ -7646,11 +7901,21 @@ export const resolvers = { organization: true, }, }, + recipe: { + include: { + services: true, + fulfillmentConsumables: true, + sellerConsumables: true, + }, + }, }, }, }, }) + // 🔒 ФИЛЬТРАЦИЯ ДАННЫХ ДЛЯ ПОСТАВЩИКА + const filteredOrder = SupplyDataFilter.filterSupplyOrder(updatedOrder, securityContext) + try { const orgIds = [ updatedOrder.organizationId, @@ -7667,7 +7932,7 @@ export const resolvers = { return { success: true, message: "Заказ отправлен поставщиком. Товары переведены в статус 'в пути'.", - order: updatedOrder, + order: filteredOrder, // 🔒 Возвращаем отфильтрованные данные } } catch (error) { console.error('Error shipping supply order:', error) @@ -8895,7 +9160,7 @@ const wildberriesQueries = { if (user?.organization) { const whereCache: any = { organizationId: user.organization.id, - period: startDate && endDate ? 'custom' : period ?? 'week', + period: startDate && endDate ? 'custom' : (period ?? 'week'), } if (startDate && endDate) { whereCache.dateFrom = new Date(startDate) @@ -8970,8 +9235,7 @@ const wildberriesQueries = { return { success: true, data: dataFromAdv, - message: - 'Данные по продажам недоступны из-за ошибки WB API. Показаны данные по рекламе из кеша.', + message: 'Данные по продажам недоступны из-за ошибки WB API. Показаны данные по рекламе из кеша.', } } } catch (parseErr) { @@ -9781,7 +10045,24 @@ resolvers.Mutation = { // Сохранение кеша статистики селлера saveSellerStatsCache: async ( _: unknown, - { input }: { input: { period: string; dateFrom?: string | null; dateTo?: string | null; productsData?: string | null; productsTotalSales?: number | null; productsTotalOrders?: number | null; productsCount?: number | null; advertisingData?: string | null; advertisingTotalCost?: number | null; advertisingTotalViews?: number | null; advertisingTotalClicks?: number | null; expiresAt: string } }, + { + input, + }: { + input: { + period: string + dateFrom?: string | null + dateTo?: string | null + productsData?: string | null + productsTotalSales?: number | null + productsTotalOrders?: number | null + productsCount?: number | null + advertisingData?: string | null + advertisingTotalCost?: number | null + advertisingTotalViews?: number | null + advertisingTotalClicks?: number | null + expiresAt: string + } + }, context: Context, ) => { if (!context.user) { diff --git a/src/graphql/security/participant-isolation.ts b/src/graphql/security/participant-isolation.ts index d893045..5e2256e 100644 --- a/src/graphql/security/participant-isolation.ts +++ b/src/graphql/security/participant-isolation.ts @@ -341,6 +341,32 @@ export class ParticipantIsolation { return true } + /** + * Общий метод валидации доступа + */ + static async validateAccess( + prisma: PrismaClient, + organizationId: string, + organizationType: string, + resourceType: string, + ): Promise { + // Базовые проверки доступа для поставщиков + if (organizationType === 'WHOLESALE' && resourceType === 'SUPPLY_ORDER') { + // Поставщики могут работать с заказами поставок + return true + } + + // Другие типы организаций и ресурсов + if (['SELLER', 'FULFILLMENT', 'LOGIST'].includes(organizationType)) { + return true + } + + // По умолчанию блокируем доступ + throw new GraphQLError('Access denied for organization type', { + extensions: { code: 'ACCESS_DENIED' }, + }) + } + /** * Заглушка для подсчета запросов (заменить на реальную реализацию) */ diff --git a/src/graphql/security/types.ts b/src/graphql/security/types.ts index 71b2522..85ecac6 100644 --- a/src/graphql/security/types.ts +++ b/src/graphql/security/types.ts @@ -48,6 +48,9 @@ export type CommercialAccessType = | 'VIEW_CONTACTS' // Просмотр контактных данных | 'VIEW_MARGINS' // Просмотр маржинальности | 'BULK_EXPORT' // Массовая выгрузка данных + | 'APPROVE_ORDER' // Одобрение заказа + | 'REJECT_ORDER' // Отклонение заказа + | 'SHIP_ORDER' // Отгрузка заказа /** * Типы ресурсов для контроля доступа @@ -94,6 +97,29 @@ export interface SecurityAlert { resolved: boolean } +/** + * Контекст безопасности для фильтрации данных + */ +export interface SecurityContext { + userId: string + organizationId: string + organizationType: OrganizationType + userRole: OrganizationType + requestMetadata?: { + action?: string + resourceId?: string + timestamp?: string + ipAddress?: string + userAgent?: string + } + // Обратная совместимость с существующим кодом + user?: { + id: string + organizationId: string + organizationType: OrganizationType + } +} + /** * Групповой заказ для логистики (с изоляцией селлеров) */ diff --git a/src/lib/security-logger.ts b/src/lib/security-logger.ts index cd95241..bfa5fbe 100644 --- a/src/lib/security-logger.ts +++ b/src/lib/security-logger.ts @@ -106,8 +106,8 @@ export class SecurityLogger { const logEntry = { level: this.alertSeverityToLogLevel(alert.severity), category: 'SECURITY_ALERT', - timestamp: new Date().toISOString(), ...alert, + timestamp: new Date().toISOString(), // Перемещено в конец чтобы не было дублирования } this.writeLog(logEntry, logEntry.level)